Appearance
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.tsxNothing 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 clientThe 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.tsxtypescript
// 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 internallyTIP
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 leakageWith 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: useQuery → queryFn → 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 Concept | Frontend Equivalent |
|---|---|
| Bounded Context | Feature slice / FSD slice with its own index.ts |
| Ubiquitous Language | Component names, prop names, state keys matching domain vocabulary |
| Value Object | Immutable TypeScript type with validation and domain logic |
| Entity | Readonly TypeScript interface received from API |
| Port (interface) | TypeScript interface in domain/*/ports.ts |
| Adapter | fetch wrapper, localStorage wrapper, React component |
| Repository | TanStack Query + HTTP adapter |
| Domain Service | Custom hook (with server state) or pure TS function (pure logic) |
| Application Service | Custom hook orchestrating multiple domain operations |
| Anti-Corruption Layer | toDomain() / toApi() mapper functions in the HTTP adapter |
| Aggregate Root | Zustand store slice for client state |
| CQRS | useQuery (read) + useMutation (write) |
| Domain Event | TanStack Query invalidateQueries on mutation success |
| Aggregate Invariant | Form validation before submit (UX only — server enforces truth) |
Related
- DDD & Hexagonal Architecture in Python — The backend counterpart: same philosophy, Python dataclasses and FastAPI implementation.
- DDD & Hexagonal Architecture in Java — Java implementation with Spring Boot and JPA for comparison across ecosystems.
- Git Setup — House style reference for this site's documentation conventions.