Skip to content

DDD & Hexagonal Architecture on the Frontend

Published: March 15, 2026

DDD and Hexagonal Architecture were born in backend systems — but the philosophy is not backend-specific. Domain isolation, boundary enforcement, and ubiquitous language are just as valuable in a large SPA as they are in a microservice. The translation is not one-to-one, but it is direct and practical.

This guide covers what transfers cleanly from your backend knowledge, what adapts, and what to leave behind.

1. What Transfers Directly

Ubiquitous Language

The most underestimated DDD principle on the frontend. Your component names, prop names, hook names, and state shape should mirror the vocabulary of the domain — not the vocabulary of the technology.

WARNING

If your component names describe their shape or mechanism rather than their domain role, you are leaking implementation detail into the interface.

tsx
// Bad — describes the UI mechanism, not the domain concept
<DataDisplay items={list} onItemClick={handler} />

// Good — speaks the domain language
<OrderSummary orders={cartItems} onOrderSelect={handleOrderSelect} />

The test: can a non-engineer read your component tree and understand what the feature does? If not, rename things until they can.

State shape follows the same rule:

typescript
// Bad — generic, technology-flavored
const [list, setList] = useState<Item[]>([])
const [isLoading, setIsLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)

// Good — domain-flavored
const [cartItems, setCartItems] = useState<CartItem[]>([])
const [isCheckingOut, setIsCheckingOut] = useState(false)
const [checkoutError, setCheckoutError] = useState<string | null>(null)

Bounded Contexts

Each feature owns its own state, API calls, and types. Features do not reach into each other's internals. This is the structural principle that scales.

src/features/
  orders/       ← owns everything order-related
    types.ts
    api.ts
    store.ts
    hooks/
    components/
  inventory/    ← owns everything inventory-related
    types.ts
    api.ts
    store.ts
    hooks/
    components/

When OrderSummary needs to know something about inventory, it does not import from src/features/inventory/store.ts. It requests the data through an explicit interface — a prop, a context, or a shared entity type from the shared/ layer. This keeps coupling explicit and visible.

Value Objects

Value Objects translate directly. They are immutable typed objects with domain logic embedded in them. In TypeScript, you have two tools: readonly for structural immutability, and branded types for making distinct values type-safe.

typescript
// Structural immutability with readonly
interface Money {
  readonly amount: number
  readonly currency: 'USD' | 'EUR' | 'GBP'
}

function addMoney(a: Money, b: Money): Money {
  if (a.currency !== b.currency) {
    throw new Error(`Cannot add ${a.currency} and ${b.currency}`)
  }
  return { amount: round2(a.amount + b.amount), currency: a.currency }
}

function formatMoney(money: Money): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: money.currency,
  }).format(money.amount)
}

// Branded types prevent mixing up domain primitives
type OrderId = string & { readonly __brand: 'OrderId' }
type CustomerId = string & { readonly __brand: 'CustomerId' }

function makeOrderId(raw: string): OrderId {
  return raw as OrderId
}

// Now these are type-incompatible — the compiler catches swapped IDs
function getOrder(id: OrderId): Promise<Order> { ... }
typescript
interface DateRange {
  readonly from: Date
  readonly to: Date
}

function isDateRangeValid(range: DateRange): boolean {
  return range.from < range.to
}

function daysInRange(range: DateRange): number {
  const ms = range.to.getTime() - range.from.getTime()
  return Math.ceil(ms / (1000 * 60 * 60 * 24))
}

Entities

On the frontend, entities are typically read-only — you receive them from the API and display or transform them. You don't persist them directly; the server does. Your domain logic around entities is validation and transformation before sending commands back to the server.

typescript
interface OrderItem {
  readonly productId: string
  readonly name: string
  readonly quantity: number
  readonly unitPrice: Money
}

interface Order {
  readonly id: OrderId
  readonly customerId: CustomerId
  readonly items: readonly OrderItem[]
  readonly status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
  readonly createdAt: Date
}

// Domain logic lives here — not in components
function calculateOrderTotal(order: Order): Money {
  return order.items.reduce(
    (total, item) => addMoney(total, multiplyMoney(item.unitPrice, item.quantity)),
    { amount: 0, currency: order.items[0]?.unitPrice.currency ?? 'USD' }
  )
}

function canCancelOrder(order: Order): boolean {
  return order.status === 'pending' || order.status === 'confirmed'
}

function canReturnOrder(order: Order): boolean {
  return order.status === 'delivered'
}

TIP

Keep domain logic in pure functions, not components. canCancelOrder belongs in domain/order/, not inside an onClick handler.

2. Hexagonal Architecture on the Frontend

The core insight of Hexagonal Architecture applied to the frontend: React is infrastructure. Vue is infrastructure. Your UI framework is an adapter — it connects the user's actions to your domain. The domain sits in the center, framework-agnostic.

Folder Structure

src/
  domain/                    # Pure TypeScript — zero framework imports
    order/
      Order.ts               # Entity type + domain logic functions
      Money.ts               # Value Object
      OrderService.ts        # Domain service
      ports.ts               # IOrderRepository interface
    cart/
      Cart.ts
      CartService.ts
      ports.ts

  infrastructure/            # Adapters — implement the ports
    api/
      orderHttpAdapter.ts    # Implements IOrderRepository via fetch
      orderMockAdapter.ts    # Implements IOrderRepository for tests
    storage/
      userPreferencesAdapter.ts

  ui/                        # React is infrastructure
    components/
      OrderSummary.tsx
      OrderList.tsx
    hooks/
      useOrders.ts
      useOrderActions.ts
    pages/
      OrdersPage.tsx

Nothing in domain/ imports from React, from axios, from fetch, or from any framework. Pure TypeScript only. This is what makes it testable without a browser or a DOM.

The Port

typescript
import type { Order, OrderId } from './Order'
import type { CreateOrderCommand } from './commands'

export interface IOrderRepository {
  findAll(): Promise<Order[]>
  findById(id: OrderId): Promise<Order | null>
  create(command: CreateOrderCommand): Promise<Order>
  cancel(id: OrderId): Promise<void>
}

Two Adapters — Same Port

typescript
import type { IOrderRepository } from '../../domain/order/ports'
import type { Order, OrderId } from '../../domain/order/Order'
import type { CreateOrderCommand } from '../../domain/order/commands'
import { toDomainOrder, toCreateOrderPayload } from './orderMapper'

export const orderHttpAdapter: IOrderRepository = {
  async findAll(): Promise<Order[]> {
    const res = await fetch('/api/orders')
    if (!res.ok) throw new Error(`Failed to fetch orders: ${res.status}`)
    const raw = await res.json()
    return raw.map(toDomainOrder)
  },

  async findById(id: OrderId): Promise<Order | null> {
    const res = await fetch(`/api/orders/${id}`)
    if (res.status === 404) return null
    if (!res.ok) throw new Error(`Failed to fetch order ${id}: ${res.status}`)
    return toDomainOrder(await res.json())
  },

  async create(command: CreateOrderCommand): Promise<Order> {
    const res = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(toCreateOrderPayload(command)),
    })
    if (!res.ok) throw new Error(`Failed to create order: ${res.status}`)
    return toDomainOrder(await res.json())
  },

  async cancel(id: OrderId): Promise<void> {
    const res = await fetch(`/api/orders/${id}/cancel`, { method: 'POST' })
    if (!res.ok) throw new Error(`Failed to cancel order ${id}: ${res.status}`)
  },
}
typescript
import type { IOrderRepository } from '../../domain/order/ports'
import type { Order, OrderId } from '../../domain/order/Order'
import type { CreateOrderCommand } from '../../domain/order/commands'

const mockOrders: Order[] = [
  {
    id: 'ord-001' as OrderId,
    customerId: 'cust-001' as CustomerId,
    items: [{ productId: 'p1', name: 'Widget', quantity: 2, unitPrice: { amount: 9.99, currency: 'USD' } }],
    status: 'confirmed',
    createdAt: new Date('2026-03-01'),
  },
]

export const orderMockAdapter: IOrderRepository = {
  async findAll() { return [...mockOrders] },
  async findById(id) { return mockOrders.find(o => o.id === id) ?? null },
  async create(command) {
    const order: Order = { id: `ord-${Date.now()}` as OrderId, ...command, status: 'pending', createdAt: new Date() }
    mockOrders.push(order)
    return order
  },
  async cancel(id) {
    const order = mockOrders.find(o => o.id === id)
    if (order) Object.assign(order, { status: 'cancelled' })
  },
}

In tests, inject orderMockAdapter. In production, inject orderHttpAdapter. Your domain service and React components never change.

Domain Service — Pure TypeScript

typescript
import type { IOrderRepository } from './ports'
import type { Order, OrderId } from './Order'
import type { CreateOrderCommand } from './commands'
import { canCancelOrder, calculateOrderTotal } from './Order'

export class OrderService {
  constructor(private readonly repository: IOrderRepository) {}

  async getActiveOrders(): Promise<Order[]> {
    const orders = await this.repository.findAll()
    return orders.filter(o => o.status !== 'cancelled' && o.status !== 'delivered')
  }

  async submitOrder(command: CreateOrderCommand): Promise<Order> {
    if (command.items.length === 0) {
      throw new Error('Cannot submit an empty order')
    }
    return this.repository.create(command)
  }

  async cancelOrder(id: OrderId): Promise<void> {
    const order = await this.repository.findById(id)
    if (!order) throw new Error(`Order ${id} not found`)
    if (!canCancelOrder(order)) {
      throw new Error(`Cannot cancel order in status: ${order.status}`)
    }
    await this.repository.cancel(id)
  }
}

No React. No useState. No useEffect. This is testable with plain jest or vitest — no component test harness needed.

3. Anti-Corruption Layer

The most immediately impactful pattern to adopt. It has the best return on investment of anything in this guide.

The problem: API responses are shaped by the server team's decisions — snake_case field names, integer status codes, nested IDs, ISO date strings, missing fields. Without a boundary, that shape leaks directly into your components and state.

typescript
// What your component ends up knowing about without an ACL
const order = {
  order_id: 'ord-001',
  customer_id: 'cust-001',
  status_code: 2,                    // what does 2 mean?
  created_at: '2026-03-01T12:00:00Z', // string, not Date
  line_items: [
    { product_id: 'p1', qty: 2, unit_price_cents: 999 }
  ]
}

The Transform Layer

Define your domain type first — what you want. Then write transforms that convert API shapes to domain types and back.

typescript
// The raw API shape — what the server actually sends
interface ApiOrderResponse {
  order_id: string
  customer_id: string
  status_code: number
  created_at: string
  line_items: Array<{
    product_id: string
    product_name: string
    qty: number
    unit_price_cents: number
    currency: string
  }>
}

const STATUS_CODE_MAP: Record<number, Order['status']> = {
  1: 'pending',
  2: 'confirmed',
  3: 'shipped',
  4: 'delivered',
  5: 'cancelled',
}

// API → Domain
export function toDomainOrder(raw: ApiOrderResponse): Order {
  return {
    id: raw.order_id as OrderId,
    customerId: raw.customer_id as CustomerId,
    status: STATUS_CODE_MAP[raw.status_code] ?? 'pending',
    createdAt: new Date(raw.created_at),
    items: raw.line_items.map(item => ({
      productId: item.product_id,
      name: item.product_name,
      quantity: item.qty,
      unitPrice: {
        amount: item.unit_price_cents / 100,
        currency: item.currency as Money['currency'],
      },
    })),
  }
}

// Domain → API (for write operations)
export function toCreateOrderPayload(command: CreateOrderCommand) {
  return {
    customer_id: command.customerId,
    line_items: command.items.map(item => ({
      product_id: item.productId,
      qty: item.quantity,
      unit_price_cents: Math.round(item.unitPrice.amount * 100),
      currency: item.unitPrice.currency,
    })),
  }
}

TIP

Call toDomainOrder exactly once — in your HTTP adapter, before data enters the application. Components only ever touch domain types, never raw API shapes.

WARNING

The common mistake is skipping the ACL for "simple" apps. Then the API changes a field name and you're doing a find-and-replace across 20 components instead of updating one mapper function.

Zod at the Boundary

Pair your ACL with Zod to validate incoming data before it enters your domain. This catches API contract violations at the boundary — not as a mysterious undefined is not an object crash somewhere in the render tree.

typescript
import { z } from 'zod'

const ApiOrderItemSchema = z.object({
  product_id: z.string(),
  product_name: z.string(),
  qty: z.number().int().positive(),
  unit_price_cents: z.number().int().nonnegative(),
  currency: z.enum(['USD', 'EUR', 'GBP']),
})

const ApiOrderResponseSchema = z.object({
  order_id: z.string(),
  customer_id: z.string(),
  status_code: z.number().int().min(1).max(5),
  created_at: z.string().datetime(),
  line_items: z.array(ApiOrderItemSchema),
})

// In your HTTP adapter:
async findAll(): Promise<Order[]> {
  const res = await fetch('/api/orders')
  const raw = await res.json()
  const parsed = z.array(ApiOrderResponseSchema).parse(raw) // throws if invalid
  return parsed.map(toDomainOrder)
}

The Zod schema serves as executable documentation of the API contract. When it fails, the error message tells you exactly which field is wrong and why.

4. Feature-Sliced Design

The most direct structural translation of DDD bounded contexts to frontend. Feature-Sliced Design (FSD) is an architectural methodology that imposes strict rules on how code is organized and how modules may import each other.

Layers and Slices

FSD organizes code into layers (vertical strata of responsibility) and slices (horizontal segments by business domain):

src/
  app/           # Application setup: providers, router, global config
  pages/         # Route-level components — assemble widgets and features
  widgets/       # Self-contained UI blocks — composed from features/entities
  features/      # User interactions and use cases
  entities/      # Business objects and their operations
  shared/        # Truly shared utilities, UI kit, API client

The fundamental rule: imports only flow downward. pages imports from widgets, features, entities, shared. features imports from entities and shared. Nothing imports from above its own layer. This prevents circular dependencies and keeps boundaries explicit.

The Public API Rule

Each slice exposes a public API through a barrel index.ts. Code outside the slice imports only from this barrel — never from internal files.

src/entities/order/
  index.ts          ← public API — the only file other slices import from
  model/
    Order.ts
    OrderService.ts
    store.ts
  api/
    orderApi.ts
  ui/
    OrderCard.tsx
typescript
// Public API — everything you export here is stable contract
export type { Order, OrderItem } from './model/Order'
export { calculateOrderTotal, canCancelOrder } from './model/Order'
export { useOrderStore } from './model/store'
export { OrderCard } from './ui/OrderCard'

// Internal files are not exported — they're implementation details
// orderApi.ts is not exported here; it's consumed internally

TIP

If a module outside entities/order/ needs something that isn't in index.ts, the correct response is to add it to the public API — not to import the internal file directly.

FSD vs Flat Structure — at Scale

# Flat structure at 50 components — looks fine
src/
  components/
    OrderList.tsx
    OrderCard.tsx
    ProductCard.tsx
    CartSummary.tsx
  hooks/
    useOrders.ts
    useCart.ts
    useProducts.ts
  utils/
    formatMoney.ts
    formatDate.ts

# Flat structure at 200 components — becomes this
src/
  components/     # 80+ files with no grouping logic
  hooks/          # 40+ hooks with unclear ownership
  utils/          # catch-all for everything
  types/          # monolithic types file with cross-domain leakage

With FSD, src/features/checkout/ and src/entities/order/ each have their own components/, hooks/, and types/. The grouping is by domain, not by technical role. Adding a new feature does not require touching other feature folders.

5. CQRS with TanStack Query

Command Query Responsibility Segregation maps onto TanStack Query naturally. useQuery is your read model — what you display to the user. useMutation is your command — what you send to the server.

Keep them separate. The discipline is in not blurring that line.

Query Keys as a Registry

Define query keys centrally. This is your read-side registry — everything that can be cached and invalidated lives here.

typescript
export const orderKeys = {
  all: ['orders'] as const,
  lists: () => [...orderKeys.all, 'list'] as const,
  list: (filters: OrderFilters) => [...orderKeys.lists(), filters] as const,
  details: () => [...orderKeys.all, 'detail'] as const,
  detail: (id: OrderId) => [...orderKeys.details(), id] as const,
}

Query Options — Centralized Read Definitions

typescript
import { queryOptions } from '@tanstack/react-query'
import { orderRepository } from '../infrastructure/orderRepository'
import { orderKeys } from './orderKeys'

export const orderQueries = {
  list: (filters: OrderFilters = {}) =>
    queryOptions({
      queryKey: orderKeys.list(filters),
      queryFn: () => orderRepository.findAll(filters),
      staleTime: 30_000,
    }),

  detail: (id: OrderId) =>
    queryOptions({
      queryKey: orderKeys.detail(id),
      queryFn: () => orderRepository.findById(id),
      staleTime: 60_000,
    }),
}

// Usage in components — clean, no inline queryFn
function OrdersPage() {
  const { data: orders } = useQuery(orderQueries.list({ status: 'active' }))
  return <OrderList orders={orders ?? []} />
}

Mutations — the Command Side

typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { orderRepository } from '../infrastructure/orderRepository'
import { orderKeys } from '../queries/orderKeys'

export function useSubmitOrder() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (command: CreateOrderCommand) => orderRepository.create(command),
    onSuccess: (newOrder) => {
      // Invalidate the list — the read model reacts to the write
      queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
      // Seed the detail cache — avoid an unnecessary fetch
      queryClient.setQueryData(orderKeys.detail(newOrder.id), newOrder)
    },
  })
}

export function useCancelOrder() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (id: OrderId) => orderRepository.cancel(id),
    onSuccess: (_data, id) => {
      queryClient.invalidateQueries({ queryKey: orderKeys.detail(id) })
      queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
    },
  })
}

WARNING

Do not do side effects inside queryFn. No state updates, no toast notifications, no redirects. queryFn is a pure data fetch. Side effects belong in onSuccess, onError, or onSettled on the mutation.

The CQRS discipline pays off when you need to reason about data flow: every state change enters through a mutation; every read exits through a query. There is no ambiguous middle ground.

6. Container/Presenter → Custom Hooks

The original Container/Presenter pattern was a React class-era solution to the same problem hooks now solve natively. The pattern survives — the implementation changes.

The Evolution

tsx
// Old — class-based container (pre-hooks)
class OrdersContainer extends React.Component {
  state = { orders: [], loading: true }

  async componentDidMount() {
    const orders = await orderRepository.findAll()
    this.setState({ orders, loading: false })
  }

  handleCancel = async (id) => {
    await orderRepository.cancel(id)
    this.setState(prev => ({
      orders: prev.orders.filter(o => o.id !== id)
    }))
  }

  render() {
    return <OrderList orders={this.state.orders} onCancel={this.handleCancel} />
  }
}
tsx
// Modern — hook is the container, component is the presenter
function useOrderManagement() {
  const { data: orders = [], isLoading } = useQuery(orderQueries.list())
  const cancelOrder = useCancelOrder()

  return {
    orders,
    isLoading,
    cancelOrder: (id: OrderId) => cancelOrder.mutate(id),
    isCancelling: cancelOrder.isPending,
  }
}

// Presenter — receives domain values, renders them
function OrderList({ orders, onCancel, isCancelling }: OrderListProps) {
  if (!orders.length) return <EmptyState message="No active orders" />
  return (
    <ul>
      {orders.map(order => (
        <OrderCard
          key={order.id}
          order={order}
          onCancel={() => onCancel(order.id)}
          disabled={isCancelling}
        />
      ))}
    </ul>
  )
}

// Page — wires hook to presenter
function OrdersPage() {
  const { orders, isLoading, cancelOrder, isCancelling } = useOrderManagement()
  if (isLoading) return <LoadingSpinner />
  return <OrderList orders={orders} onCancel={cancelOrder} isCancelling={isCancelling} />
}

Domain Services as Custom Hooks

Domain services that touch server state become hooks. Domain services that are pure computation stay as plain functions.

typescript
export function useOrderService() {
  const { data: orders = [] } = useQuery(orderQueries.list())
  const submitOrder = useSubmitOrder()
  const cancelOrder = useCancelOrder()

  return {
    // Read
    activeOrders: orders.filter(o => o.status !== 'cancelled' && o.status !== 'delivered'),
    orderCount: orders.length,

    // Commands
    submit: submitOrder.mutate,
    cancel: cancelOrder.mutate,

    // Status
    isSubmitting: submitOrder.isPending,
    isCancelling: cancelOrder.isPending,
  }
}

The rule: hooks handle data and commands, components handle rendering only. A component that contains fetch is doing infrastructure work it should not be doing.

7. Complementary Patterns

Zod at the Boundary

Already covered in the ACL section, but worth stating as a standalone principle. Parse every external data source — API responses, URL search params, localStorage values, form submissions — using Zod before the data enters your domain.

typescript
// URL search params
const SearchParamsSchema = z.object({
  status: z.enum(['pending', 'confirmed', 'shipped', 'delivered']).optional(),
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

function useOrderFilters(): OrderFilters {
  const [searchParams] = useSearchParams()
  const raw = Object.fromEntries(searchParams.entries())
  const result = SearchParamsSchema.safeParse(raw)
  return result.success ? result.data : { page: 1, limit: 20 }
}

Zod schemas serve as living documentation of what your domain expects. When the schema and the API disagree, the schema wins — the API owner needs to fix their contract.

Zustand with Domain Slices

For client-side state that doesn't live on the server (UI state, wizard steps, selected items), Zustand with domain-aligned slices respects bounded contexts.

typescript
import { create } from 'zustand'

interface CheckoutState {
  step: 'cart' | 'shipping' | 'payment' | 'confirmation'
  selectedAddressId: string | null
  promoCode: string

  // Commands — explicit, named actions
  advanceStep: () => void
  selectAddress: (id: string) => void
  applyPromoCode: (code: string) => void
  reset: () => void
}

const STEP_ORDER: CheckoutState['step'][] = ['cart', 'shipping', 'payment', 'confirmation']

export const useCheckoutStore = create<CheckoutState>((set, get) => ({
  step: 'cart',
  selectedAddressId: null,
  promoCode: '',

  advanceStep: () => {
    const currentIndex = STEP_ORDER.indexOf(get().step)
    const nextStep = STEP_ORDER[currentIndex + 1]
    if (nextStep) set({ step: nextStep })
  },

  selectAddress: (id) => set({ selectedAddressId: id }),
  applyPromoCode: (code) => set({ promoCode: code.trim().toUpperCase() }),
  reset: () => set({ step: 'cart', selectedAddressId: null, promoCode: '' }),
}))

Each feature slice gets its own Zustand store. Do not create one god-store for the entire application. The bounded context discipline applies to client state as much as it applies to server state.

Nx Module Boundaries

In a monorepo, Nx enforces bounded context rules at the linter level. Define which slices can import from which, and the linter fails the build if a boundary is crossed.

json
{
  "tags": ["scope:orders", "type:feature"]
}
json
{
  "@nx/enforce-module-boundaries": [
    "error",
    {
      "depConstraints": [
        {
          "sourceTag": "type:feature",
          "onlyDependOnLibsWithTags": ["type:entity", "type:shared"]
        },
        {
          "sourceTag": "scope:orders",
          "notDependOnLibsWithTags": ["scope:inventory"]
        }
      ]
    }
  ]
}

This takes bounded contexts from a convention to a constraint. Teams cannot accidentally cross context boundaries — the CI pipeline catches it.

8. What Doesn't Translate — Common Mistakes

Don't Reimplement the Repository on Top of TanStack Query

The repository pattern exists to abstract data access behind an interface. TanStack Query is that abstraction. It handles caching, background refetching, deduplication, and stale-time management. Do not fight it by wrapping it in another layer.

WARNING

If you find yourself writing a OrderRepository class that internally calls useQuery, you are wrapping a hook in a class that wraps a hook. Stop. Your HTTP adapter (orderHttpAdapter.ts) is already the repository. TanStack Query is the cache layer above it.

The correct layering: useQueryqueryFn → HTTP adapter → domain mapper.

Don't Enforce Aggregate Invariants on the Frontend

In backend DDD, the aggregate root enforces consistency rules — no other part of the system can put the aggregate into an invalid state. On the frontend, the server owns consistency. You can and should validate before submitting (better UX, faster feedback), but the server is the source of truth.

Do not build frontend systems that assume the server will always return valid aggregates. It won't. Use Zod at the boundary.

Don't Over-Engineer CRUD

DDD patterns pay off when domain complexity justifies the overhead. A settings page with four toggles does not need a bounded context, a repository port, and an anti-corruption layer. Recognize when you are adding ceremony without value.

The threshold is roughly: use DDD-inspired patterns when a feature has its own lifecycle, its own invariants, or multiple developers working in the same area simultaneously.

Don't Build a Frontend Event Bus

Domain Events as a first-class pattern — publishing events to an in-process bus, subscribing in other bounded contexts — is not worth the complexity on the frontend unless you are building a true event-sourced client application. React's state management and TanStack Query's invalidation cover 95% of the use cases that event buses would solve, with far less infrastructure.

9. Quick Reference

Backend DDD ConceptFrontend Equivalent
Bounded ContextFeature slice / FSD slice with its own index.ts
Ubiquitous LanguageComponent names, prop names, state keys matching domain vocabulary
Value ObjectImmutable TypeScript type with validation and domain logic
EntityReadonly TypeScript interface received from API
Port (interface)TypeScript interface in domain/*/ports.ts
Adapterfetch wrapper, localStorage wrapper, React component
RepositoryTanStack Query + HTTP adapter
Domain ServiceCustom hook (with server state) or pure TS function (pure logic)
Application ServiceCustom hook orchestrating multiple domain operations
Anti-Corruption LayertoDomain() / toApi() mapper functions in the HTTP adapter
Aggregate RootZustand store slice for client state
CQRSuseQuery (read) + useMutation (write)
Domain EventTanStack Query invalidateQueries on mutation success
Aggregate InvariantForm validation before submit (UX only — server enforces truth)