UTA DevHub
Architecture

Domain Architecture Guide

Guide to implementing domain-based architecture for business logic organization.

Domain Architecture Guide

Overview

This guide explains our approach to domain-based architecture, which we've implemented in the core/domains/ directory. In our experience, organizing business logic by entity (such as products, users, authentication) rather than by technical concern creates clearer boundaries and significantly improves scalability as applications grow.

Domain-based architecture helps solve several common challenges in React Native development:

  • Code organization as applications scale beyond a handful of features
  • Maintainability by creating logical boundaries between business entities
  • Team collaboration by enabling parallel work with minimal conflicts
  • Testing by isolating business logic from UI concerns

By following these patterns, we've found teams can work more efficiently and produce more robust applications.

Purpose & Scope

This document is designed to help developers at all experience levels:

  • When and why to create separate domains for business logic
  • How to structure domain folders for consistency and discoverability
  • Patterns for implementing domain services, hooks, and types
  • Best practices for maintaining proper boundaries between domains
  • Techniques for cross-domain communication without tight coupling

Domain Boundaries

Key Principles

  1. Domain Independence: Domains should be self-contained and not directly import from each other
  2. Shared Interface: Domains expose public APIs through hooks and types
  3. Feature Integration: Features compose multiple domains for UI

Cross-Domain Communication

Communication Patterns

  1. Direct Hook Usage: Features import and use hooks from multiple domains simultaneously
  2. Cross-Domain API Calls: Domain services can access other domain services through their public APIs
  3. Event-Based Communication: Domains can broadcast events that other domains listen for
  4. Shared State Access: Domains can share data through centralized state stores

What is a Domain?

A domain represents a distinct business entity or area of functionality:

  • Products: Everything related to product data and operations
  • Users: User management and profile functionality
  • Auth: Authentication and authorization logic
  • Orders: Order processing and management
  • Payments: Payment processing logic

Domain Structure

Standard Domain Anatomy

Every domain follows this structure:

core/domains/[domain-name]/
├── api.ts          # API service methods (required)
├── types.ts        # TypeScript interfaces (required)
├── hooks.ts        # React Query hooks (required)
├── queryKeys.ts    # Query key factory (optional)
├── store.ts        # Client state if needed (optional)
├── events.ts       # Event definitions (optional)
├── utils.ts        # Domain utilities (optional)
└── index.ts        # Barrel exports (required)

Required Files

All domain files follow our File Naming Conventions. Each file has a specific purpose:

1. api.ts - API Service Methods

Contains all API calls for the domain, following the mandatory public/protected pattern:

// core/domains/products/api.ts
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
import type { Product, CreateProductDto, UpdateProductDto } from './types';
 
class ProductApi {
  // Public endpoints (no auth required)
  public readonly public = {
    getAll: async (): Promise<Product[]> => {
      const { data } = await publicApi.get('/products');
      return data;
    },
 
    getById: async (id: string): Promise<Product> => {
      const { data } = await publicApi.get(`/products/${id}`);
      return data;
    },
 
    search: async (query: string): Promise<Product[]> => {
      const { data } = await publicApi.get('/products/search', {
        params: { q: query }
      });
      return data;
    },
  };
 
  // Protected endpoints (auth required)
  public readonly protected = {
    create: async (product: CreateProductDto): Promise<Product> => {
      const { data } = await authenticatedApi.post('/products', product);
      return data;
    },
 
    update: async (id: string, updates: UpdateProductDto): Promise<Product> => {
      const { data } = await authenticatedApi.put(`/products/${id}`, updates);
      return data;
    },
 
    delete: async (id: string): Promise<void> => {
      await authenticatedApi.delete(`/products/${id}`);
    },
 
    getUserProducts: async (userId: string): Promise<Product[]> => {
      const { data } = await authenticatedApi.get(`/users/${userId}/products`);
      return data;
    },
  };
}
 
export const productApi = new ProductApi();

This pattern is required for all domain APIs. Benefits:

  • Clear separation of public vs authenticated endpoints
  • No runtime flags or configuration needed
  • Type-safe API calls
  • Better developer experience with IntelliSense

2. types.ts - TypeScript Interfaces

Defines all types for the domain:

// core/domains/products/types.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  category: string;
  inStock: boolean;
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreateProductDto {
  name: string;
  description: string;
  price: number;
  category: string;
  inStock?: boolean;
}
 
export interface UpdateProductDto {
  name?: string;
  description?: string;
  price?: number;
  category?: string;
  inStock?: boolean;
}
 
export interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  inStock?: boolean;
}

3. hooks.ts - React Query Hooks

Implements data fetching hooks using TanStack Query, following the public/protected pattern:

// core/domains/products/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productApi } from './api';
import { productQueryKeys } from './queryKeys';
import type { Product, CreateProductDto, UpdateProductDto, ProductFilters } from './types';
 
// Public query hooks (no auth required)
export function useProducts(filters?: ProductFilters) {
  return useQuery({
    queryKey: productQueryKeys.list(filters),
    queryFn: () => productApi.public.getAll(filters),
  });
}
 
export function useProduct(id: string) {
  return useQuery({
    queryKey: productQueryKeys.detail(id),
    queryFn: () => productApi.public.getById(id),
    enabled: !!id,
  });
}
 
export function useProductSearch(query: string) {
  return useQuery({
    queryKey: productQueryKeys.search(query),
    queryFn: () => productApi.public.search(query),
    enabled: query.length > 2,
  });
}
 
// Protected mutation hooks (auth required)
export function useCreateProduct() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: productApi.protected.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ 
        queryKey: productQueryKeys.lists() 
      });
    },
  });
}
 
export function useUpdateProduct() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: ({ id, updates }: { id: string; updates: UpdateProductDto }) => 
      productApi.protected.update(id, updates),
    onSuccess: (data, { id }) => {
      queryClient.setQueryData(productQueryKeys.detail(id), data);
      queryClient.invalidateQueries({ 
        queryKey: productQueryKeys.lists() 
      });
    },
  });
}
 
export function useDeleteProduct() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: productApi.protected.delete,
    onSuccess: (_, id) => {
      queryClient.removeQueries({ 
        queryKey: productQueryKeys.detail(id) 
      });
      queryClient.invalidateQueries({ 
        queryKey: productQueryKeys.lists() 
      });
    },
  });
}
 
// Protected query hooks (auth required)
export function useUserProducts(userId: string) {
  return useQuery({
    queryKey: productQueryKeys.userProducts(userId),
    queryFn: () => productApi.protected.getUserProducts(userId),
    enabled: !!userId,
  });
}

Domain Data Flow Pattern

Data Flow Steps

  1. UI Component calls domain hook (e.g., useProducts())
  2. Domain hook calls API service
  3. API service makes HTTP request to backend
  4. Backend responds with data
  5. API service returns typed data to hook
  6. Hook returns cached data to UI

Optional Files

4. queryKeys.ts - Query Key Factory

Use when you have > 3 query keys:

// core/domains/products/queryKeys.ts
export const productQueryKeys = {
  all: ['products'] as const,
  lists: () => [...productQueryKeys.all, 'list'] as const,
  list: (filters?: ProductFilters) => 
    [...productQueryKeys.lists(), { filters }] as const,
  details: () => [...productQueryKeys.all, 'detail'] as const,
  detail: (id: string) => [...productQueryKeys.details(), id] as const,
  byCategory: (category: string) => 
    [...productQueryKeys.all, 'category', category] as const,
};

5. store.ts - Client State

For domain-specific UI state:

// core/domains/products/store.ts
import { create } from 'zustand';
 
interface ProductsUIState {
  selectedCategory: string | null;
  viewMode: 'grid' | 'list';
  sortBy: 'name' | 'price' | 'date';
  setSelectedCategory: (category: string | null) => void;
  setViewMode: (mode: 'grid' | 'list') => void;
  setSortBy: (sort: 'name' | 'price' | 'date') => void;
}
 
export const useProductsUIStore = create<ProductsUIState>((set) => ({
  selectedCategory: null,
  viewMode: 'grid',
  sortBy: 'name',
  setSelectedCategory: (category) => set({ selectedCategory: category }),
  setViewMode: (mode) => set({ viewMode: mode }),
  setSortBy: (sort) => set({ sortBy: sort }),
}));

6. events.ts - Event Definitions

For pub/sub communication:

// core/domains/products/events.ts
import { EventEmitter } from '@/core/shared/utils/events';
 
export type ProductEvents = {
  'product:created': { productId: string };
  'product:updated': { productId: string; changes: Record<string, any> };
  'product:deleted': { productId: string };
  'product:stockLow': { productId: string; quantity: number };
};
 
export const productEvents = new EventEmitter<ProductEvents>();
 
// Usage example
productEvents.on('product:stockLow', ({ productId, quantity }) => {
  console.log(`Product ${productId} has low stock: ${quantity}`);
});

7. index.ts - Barrel Exports

Clean public API for the domain:

// core/domains/products/index.ts
export * from './api';
export * from './types';
export * from './hooks';
export * from './queryKeys';
export { productEvents } from './events';
export { useProductsUIStore } from './store';

Real-World Examples

Authentication Domain

The auth domain has special requirements:

// core/domains/auth/tokenService.ts
import { storage } from '@/core/shared/utils/storage';
 
export const tokenService = {
  getAccessToken: () => storage.get('accessToken'),
  getRefreshToken: () => storage.get('refreshToken'),
  setTokens: (access: string, refresh: string) => {
    storage.set('accessToken', access);
    storage.set('refreshToken', refresh);
  },
  clearTokens: () => {
    storage.remove('accessToken');
    storage.remove('refreshToken');
  },
};
// core/domains/auth/events.ts
import { EventEmitter } from '@/core/shared/utils/events';
 
export type AuthEvents = {
  'auth:login': { userId: string };
  'auth:logout': void;
  'auth:sessionExpired': void;
  'auth:tokenRefreshed': { accessToken: string };
};
 
export const authEvents = new EventEmitter<AuthEvents>();

User Domain

// core/domains/users/hooks.ts
export function useCurrentUser() {
  return useQuery({
    queryKey: userQueryKeys.current(),
    queryFn: userApi.getCurrentUser,
  });
}
 
export function useUpdateProfile() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (updates: UpdateProfileDto) => 
      userApi.updateProfile(updates),
    onSuccess: (data) => {
      queryClient.setQueryData(userQueryKeys.current(), data);
    },
  });
}

Communication Between Domains

Preferred: Through Shared Cache

// In products domain
export function useProductWithUser(productId: string) {
  const { data: product } = useProduct(productId);
  const { data: user } = useUser(product?.ownerId);
  
  return { product, owner: user };
}

Alternative: Through Events

// In orders domain
import { productEvents } from '@/core/domains/products';
 
export function useCreateOrder() {
  return useMutation({
    mutationFn: orderApi.create,
    onSuccess: (order) => {
      // Notify product domain about stock changes
      order.items.forEach(item => {
        productEvents.emit('product:stockLow', {
          productId: item.productId,
          quantity: item.remainingStock,
        });
      });
    },
  });
}

Domain Creation Checklist

When creating a new domain:

1. Identify the Domain

  • Clear business entity (e.g., products, users)
  • Has distinct API endpoints
  • Contains related types and operations

2. Create the Structure

  • Create folder: core/domains/[domain-name]/
  • Add required files: api.ts, types.ts, hooks.ts
  • Add optional files as needed
  • Create barrel export: index.ts

3. Implement Core Functionality

  • Define types in types.ts
  • Implement API methods in api.ts
  • Create query/mutation hooks in hooks.ts
  • Add query keys if > 3 queries

4. Handle Special Cases

  • Add client state if needed (store.ts)
  • Implement events if needed (events.ts)
  • Add domain utilities if needed (utils.ts)

5. Update Documentation

  • Add domain to architecture docs
  • Document special behaviors
  • Update team guidelines

Best Practices

Why These Practices Matter

These patterns have evolved through our experience building and maintaining large-scale React Native applications. They help ensure consistency, maintainability, and scalability as your application grows and as team members come and go.

1. API Service Patterns

(Do ✅) Use the public/protected pattern for all domain APIs

This pattern creates a clear separation between endpoints that require authentication and those that don't, which helps prevent security mistakes and improves developer experience:

class ProductApi {
  public readonly public = {
    // Endpoints that don't require authentication
    getAll: async (): Promise<Product[]> => {
      const { data } = await publicApi.get('/products');
      return data;
    },
  };
  
  public readonly protected = {
    // Endpoints that require authentication
    create: async (product: CreateProductDto): Promise<Product> => {
      const { data } = await authenticatedApi.post('/products', product);
      return data;
    },
  };
}

The benefits of this approach include:

  • Clear visual distinction between authenticated and unauthenticated endpoints
  • Compile-time safety through TypeScript's property access checking
  • Better IntelliSense support with grouped methods
  • No runtime checks or flags needed for auth requirements

(Don't ❌) Use API clients directly or rely on configuration flags

Avoid approaches that mix authentication concerns or rely on flags:

// DON'T do this
export const productApi = {
  // Unclear which endpoints need authentication
  getAll: () => apiClient.get('/products', { skipAuth: true }),
  create: (data) => apiClient.post('/products', data),
};

This approach creates several problems:

  • Authentication requirements are not obvious from the code structure
  • Easy to forget the skipAuth flag and cause runtime errors
  • No compile-time safety for authentication requirements
  • Harder to maintain consistency as the codebase grows

2. Domain Boundaries and Focus

(Do ✅) Keep domains focused on a single business entity

A well-designed domain should represent a single business concept with clear boundaries:

// core/domains/products/api.ts
class ProductApi {
  public readonly public = {
    getAll: async (): Promise<Product[]> => {
      const { data } = await publicApi.get('/products');
      return data;
    },
    getById: async (id: string): Promise<Product> => {
      const { data } = await publicApi.get(`/products/${id}`);
      return data;
    },
  };
  
  public readonly protected = {
    create: async (product: CreateProductDto): Promise<Product> => {
      const { data } = await authenticatedApi.post('/products', product);
      return data;
    },
    update: async (id: string, updates: UpdateProductDto): Promise<Product> => {
      const { data } = await authenticatedApi.put(`/products/${id}`, updates);
      return data;
    },
    delete: async (id: string): Promise<void> => {
      await authenticatedApi.delete(`/products/${id}`);
    },
  };
}

By keeping domains focused, you gain several benefits:

  • Easier maintenance: Each domain is smaller and more manageable
  • Better code organization: Logical grouping of related functionality
  • Clearer ownership: Teams can own specific domains
  • More targeted testing: Domain-specific test suites

(Don't ❌) Mix multiple business entities in a single domain

Mixing responsibilities creates confusion and maintenance challenges:

// DON'T do this
class ProductAndUserApi {
  public readonly public = {
    // Product methods
    getProducts: async (): Promise<Product[]> => {
      const { data } = await publicApi.get('/products');
      return data;
    },
    getProductById: async (id: string): Promise<Product> => {
      const { data } = await publicApi.get(`/products/${id}`);
      return data;
    },
    
    // User methods - should be in users domain
    getUsers: async (): Promise<User[]> => {
      const { data } = await publicApi.get('/users');
      return data;
    },
    getUserById: async (id: string): Promise<User> => {
      const { data } = await publicApi.get(`/users/${id}`);
      return data;
    },
  };
};

This approach creates several problems:

  • Unclear domain boundaries: Where does one entity end and another begin?
  • Harder to maintain: Changes to one entity might affect the other
  • Difficult testing: Tests for different entities are mixed together
  • Poor code organization: Code for different business concepts is intermingled
  • Challenges for team ownership: Hard to assign ownership to different teams

3. Use Consistent Naming

Consistent naming patterns significantly improve developer experience by making code more predictable and easier to navigate. They also reduce the cognitive load when switching between different parts of the codebase.

(Do ✅) Follow established naming conventions for all domain artifacts

These conventions make code more predictable and discoverable:

TypePatternExamples
API instances[domain]ApiproductApi, userApi, orderApi
Query hooksuse[Resource]useProducts, useUser, useOrder
Mutation hooksuse[Action][Resource]useCreateProduct, useUpdateUser, useDeleteOrder
State storesuse[Domain]StoreuseProductStore, useCartStore
Events[domain]EventsproductEvents, authEvents
Query keys[domain]QueryKeysproductQueryKeys, userQueryKeys

(Consider 🤔) Creating a domain naming cheat sheet for your team

As your application grows, consider documenting naming patterns in a quick-reference format for new team members.

(Don't ❌) Mix naming conventions or create inconsistent patterns

Inconsistent naming creates confusion and slows down development:

// DON'T do this - inconsistent naming
 
// Different naming styles for similar concepts
export const productApiService = new ProductApiService(); // Too verbose
export const userApiClient = new UserApiClient();  // Different suffix
export const orderService = new OrderService();  // Missing "Api"
 
// Inconsistent hook naming
export function useGetProducts() { /* ... */ }  // "Get" is redundant
export function fetchUsers() { /* ... */ }      // Missing "use" prefix
export function orderQueryHook() { /* ... */ }  // Unclear purpose

Consistent naming provides several benefits:

  • Predictability: Developers can guess file and export names correctly
  • Clarity: The purpose of each artifact is immediately clear
  • Maintainability: Easier to implement tooling and linting rules
  • Onboarding: New team members learn patterns more quickly

4. Separate Public and Protected Hooks

The authentication requirements of your hooks should match the authentication requirements of the underlying API methods they use. This creates a consistent security model throughout your application.

(Do ✅) Match hook authentication requirements to API authentication requirements

When creating hooks, ensure their authentication requirements align with the API methods they call:

// Public hooks (no auth required) call public API methods
export function useProducts() {
  return useQuery({
    queryKey: productQueryKeys.list(),
    queryFn: () => productApi.public.getAll(),
  });
}
 
// Protected hooks (auth required) call protected API methods
export function useCreateProduct() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: productApi.protected.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: productQueryKeys.lists() });
    },
  });
}

This alignment provides several benefits:

  • Security consistency: Authentication requirements are clear at every level
  • Predictable behavior: Hooks fail in expected ways when auth is missing
  • Better developer experience: API structure guides hook organization

(Don't ❌) Mix authentication requirements or add unnecessary authentication

These approaches create confusion and security risks:

// DON'T do this
 
// Public data shouldn't require authentication
export function usePublicProducts() {
  // Wrong: Using protected API for public data
  return useQuery({
    queryKey: ['products'],
    queryFn: () => protectedApiClient.get('/public-products'),
  });
}
 
// Don't add conditional authentication
export function useProducts(requireAuth = false) {
  // Wrong: Dynamic authentication creates unpredictable behavior
  const client = requireAuth ? protectedApiClient : publicApiClient;
  return useQuery({
    queryKey: ['products', { auth: requireAuth }],
    queryFn: () => client.get('/products'),
  });
}

(Consider 🤔) Adding explicit documentation about authentication requirements

For larger teams, consider adding JSDoc comments that explicitly state authentication requirements:

/**
 * Hook to fetch products list - no authentication required
 */
export function useProducts() { /* ... */ }
 
/**
 * Hook to create a new product - requires authentication
 * @throws {UnauthorizedError} When user is not authenticated
 */
export function useCreateProduct() { /* ... */ }

5. Handle Errors Gracefully

Error handling is a critical aspect of domain hooks. Well-designed error handling improves the user experience and makes debugging easier.

(Do ✅) Implement consistent error handling in all query hooks

export function useProduct(id: string) {
  return useQuery({
    queryKey: productQueryKeys.detail(id),
    queryFn: async () => {
      try {
        return await productApi.public.getById(id);
      } catch (error) {
        // Transform API errors to more user-friendly format
        if (error.status === 404) {
          throw new ProductNotFoundError(id);
        }
        throw error; // Re-throw other errors
      }
    },
    enabled: !!id,
    retry: (failureCount, error) => {
      // Only retry for network errors, not for 404s
      if (error instanceof ProductNotFoundError) return false;
      return failureCount < 3;
    },
  });
}

6. Optimize Query Patterns

// Use query options effectively
export function useProducts(options?: UseQueryOptions<Product[]>) {
  return useQuery({
    queryKey: productQueryKeys.all(),
    queryFn: productApi.public.getAll,
    staleTime: 5 * 60 * 1000, // 5 minutes
    ...options,
  });
}

Anti-Patterns to Avoid

1. Direct State Mutation

Wrong: Mutating cache directly

const queryClient = useQueryClient();
const products = queryClient.getQueryData(['products']);
products.push(newProduct); // Don't do this!

Correct: Use proper mutations

const createProduct = useCreateProduct();
createProduct.mutate(newProduct);

2. Circular Dependencies

Wrong: Domains importing each other

// core/domains/products/api.ts
import { userApi } from '@/core/domains/users/api'; // Avoid!

Correct: Use shared hooks or events

// In feature component
const { data: product } = useProduct(id);
const { data: user } = useUser(product?.ownerId);

3. Business Logic in Features

Wrong: Calculate in features

// features/product-catalog/components/ProductCard.tsx
const discountPrice = product.price * 0.9; // Business logic!

Correct: Domain handles logic

// core/domains/products/utils.ts
export function calculateDiscount(product: Product): number {
  return product.price * 0.9;
}