Domain-Driven Design tactical patterns for TypeScript. Value Objects, Entities, Aggregates, Domain Services, Domain Events, Ubiquitous Language, and Bounded Contexts. Use when modeling domain logic in TypeScript backend services.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Tactical DDD patterns for rich, behavior-driven domain models in TypeScript.
vs
hexagonal-typescript: This skill focuses on domain modeling — Value Objects, Entities, Aggregates, Domain Events, and Ubiquitous Language. Usehexagonal-typescriptwhen you need package structure and dependency direction — how to organize ports, adapters, and use case classes.
Identity: none — equal when all fields are equal. Rule: Immutable. Use readonly properties. Use factory functions or classes with private constructors.
// domain/model/Money.ts
export interface Money {
readonly amount: number
readonly currency: string
}
export function money(amount: number, currency: string): Money {
if (amount < 0) throw new InvalidMoneyError('amount must be non-negative')
if (!currency) throw new InvalidMoneyError('currency required')
return Object.freeze({ amount, currency })
}
export function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) throw new CurrencyMismatchError(a.currency, b.currency)
return money(a.amount + b.amount, a.currency)
}
export function isZero(m: Money): boolean {
return m.amount === 0
}
Prevents passing a UserId where a MarketId is expected — zero runtime cost:
// domain/model/MarketId.ts
export type MarketId = string & { readonly _brand: 'MarketId' }
export function marketId(value: string): MarketId {
if (!value.trim()) throw new Error('MarketId cannot be empty')
return value as MarketId
}
// domain/model/UserId.ts
export type UserId = string & { readonly _brand: 'UserId' }
export function userId(value: string): UserId { return value as UserId }
Common Value Objects: Money, Email, Slug, DateRange, typed IDs (MarketId, UserId).
Identity: defined by a unique ID — two entities with the same ID are the same object. Rule: Has behavior (domain functions), not just data. Immutable updates via spread.
// domain/model/market.ts
import type { MarketId } from './MarketId'
import type { MarketPublishedEvent } from '../event/MarketPublishedEvent'
export type MarketStatus = 'DRAFT' | 'ACTIVE' | 'SUSPENDED'
export interface Market {
readonly id: MarketId | null
readonly name: string
readonly slug: string
readonly status: MarketStatus
}
// Factory — enforces creation invariants
export function createMarket(name: string, slug: string): Market {
if (!name || name.trim() === '') throw new InvalidMarketError('name required')
if (!slug || !/^[a-z0-9-]+$/.test(slug)) throw new InvalidMarketError('invalid slug')
return Object.freeze({ id: null, name: name.trim(), slug, status: 'DRAFT' })
}
// Behavior — returns new entity + events (no mutation)
export function publishMarket(market: Market): { market: Market; events: MarketPublishedEvent[] } {
if (market.status !== 'DRAFT') throw new MarketAlreadyPublishedError(market.slug)
return {
market: { ...market, status: 'ACTIVE' },
events: [{ type: 'MarketPublished', marketId: market.id!, name: market.name, occurredAt: new Date() }],
}
}
An Aggregate is a cluster of domain objects treated as a unit for data changes. The Aggregate Root is the only entry point — external code never holds references to internal entities.
// domain/model/order.ts — Aggregate Root
import type { OrderId } from './OrderId'
import type { CustomerId } from './CustomerId'
import type { Money } from './Money'
export interface OrderLine {
readonly productId: string
readonly quantity: number
readonly unitPrice: Money
}
export interface Order {
readonly id: OrderId | null
readonly customerId: CustomerId // reference by ID, not Customer object
readonly lines: readonly OrderLine[]
readonly status: 'DRAFT' | 'PLACED'
}
export function createOrder(customerId: CustomerId): Order {
return Object.freeze({ id: null, customerId, lines: [], status: 'DRAFT' })
}
// Aggregate method — enforces internal invariant, returns new state
export function addLineToOrder(order: Order, line: OrderLine): Order {
if (order.status !== 'DRAFT') throw new OrderAlreadyPlacedError(order.id)
return { ...order, lines: [...order.lines, line] }
}
export function placeOrder(order: Order): Order {
if (order.lines.length === 0) throw new EmptyOrderError(order.id)
return { ...order, status: 'PLACED' }
}
export function orderTotal(order: Order): Money {
return order.lines.reduce(
(acc, line) => addMoney(acc, { amount: line.unitPrice.amount * line.quantity, currency: line.unitPrice.currency }),
money(0, order.lines[0]?.unitPrice.currency ?? 'EUR'),
)
}
When: Logic belongs in the domain but doesn't fit a single entity. Rule: Stateless functions. No framework imports. Named after domain verbs.
// domain/service/pricingPolicy.ts
import type { Order } from '../model/order'
import type { DiscountCode } from '../model/DiscountCode'
import type { Money } from '../model/Money'
import { orderTotal, addMoney, money } from '../model'
export function calculateFinalPrice(order: Order, discountCode: DiscountCode): Money {
const base = orderTotal(order)
if (isValidDiscount(discountCode) && discountAppliesTo(discountCode, order)) {
return subtractMoney(base, discountAmount(discountCode, base))
}
return base
}
Domain Service vs Application Service (Use Case):
| Domain Service | Application Service (Use Case) | |
|---|---|---|
| Location | domain/service/ | application/usecase/ |
| Depends on | Domain model only | Ports (in + out), domain services |
| Async/await | Usually not | Yes (DB calls, external services) |
| Framework imports | Never | Can use types from config |
| Example | pricingPolicy, slugGenerator | CreateOrderService, PlaceOrderService |
Domain events represent something that happened. Immutable facts.
// domain/event/DomainEvent.ts
export interface DomainEvent {
readonly type: string
readonly occurredAt: Date
}
// domain/event/MarketPublishedEvent.ts
export interface MarketPublishedEvent extends DomainEvent {
readonly type: 'MarketPublished'
readonly marketId: MarketId
readonly name: string
}
Collect events from aggregate operations, dispatch after successful save:
// application/usecase/PublishMarketService.ts
import type { PublishMarketUseCase } from '../../domain/port/in/PublishMarketUseCase'
import type { MarketRepository } from '../../domain/port/out/MarketRepository'
import type { EventBus } from '../../domain/port/out/EventBus'
import { publishMarket } from '../../domain/model/market'
export class PublishMarketService implements PublishMarketUseCase {
constructor(
private readonly marketRepository: MarketRepository,
private readonly eventBus: EventBus,
) {}
async execute(marketId: MarketId): Promise<void> {
const market = await this.marketRepository.findById(marketId)
if (!market) throw new MarketNotFoundError(marketId)
const { market: updated, events } = publishMarket(market) // domain logic
await this.marketRepository.save(updated)
await Promise.all(events.map(e => this.eventBus.publish(e))) // dispatch after save
}
}
Use the same terms in code as domain experts use. Never translate.
// ❌ Technical naming — no domain meaning
async function processMarketData(input: MarketInput) {}
// ✅ Ubiquitous language — mirrors domain expert speech
async function publishMarket(market: Market): Promise<{ market: Market; events: DomainEvent[] }>
async function suspendMarket(market: Market, reason: SuspensionReason): Promise<Market>
async function resolveMarket(market: Market, outcome: ResolutionOutcome): Promise<Market>
Each service/module corresponds to one Bounded Context. The same word means different things in different contexts.
@startuml
!include <C4/C4_Container>
System_Boundary(oc, "Order Context") {
Container(oc_ord, "Order", "Aggregate Root", "")
Container(oc_cust, "Customer", "by ID ref", "")
Container(oc_prod, "Product", "by ID ref", "")
Container(oc_line, "OrderLine", "Type", "")
}
System_Boundary(pc, "Payment Context") {
Container(pc_inv, "Invoice", "Aggregate Root", "")
Container(pc_cust, "Customer", "different model!", "")
Container(pc_pm, "PaymentMethod", "Type", "")
}
@enduml
// adapter/out/client/PaymentContextAdapter.ts
import type { PaymentPort } from '../../../domain/port/out/PaymentPort'
import type { Order } from '../../../domain/model/order'
import type { Money } from '../../../domain/model/Money'
export class PaymentContextAdapter implements PaymentPort {
constructor(private readonly httpClient: PaymentHttpClient) {}
async initiatePayment(order: Order, amount: Money): Promise<PaymentResult> {
// Translate Order domain model → Payment API request
const request = {
orderId: order.id!,
amount: amount.amount,
currency: amount.currency,
}
const response = await this.httpClient.charge(request)
// Translate Payment API response → domain PaymentResult
return { success: response.ok, transactionId: response.txId }
}
}
// ❌ Data container — no behavior, all logic in use case
interface Market {
status: string // mutable
setStatus(s: string): void // plain setter
}
// ❌ Use case doing domain work it shouldn't
async function publishMarket(id: string) {
const market = await repo.findById(id)
if (market.status !== 'DRAFT') throw new Error('not a draft') // domain rule leaked out!
market.setStatus('ACTIVE') // mutation with no intent
await repo.save(market)
}
// ✅ Domain logic in domain
export function publishMarket(market: Market) {
if (market.status !== 'DRAFT') throw new MarketAlreadyPublishedError(market.slug)
return { ...market, status: 'ACTIVE' as const }
}
// ❌ Strings everywhere — no type safety
async function createOrder(userId: string, marketId: string): Promise<void>
// ✅ Branded types
async function createOrder(userId: UserId, marketId: MarketId): Promise<void>
// ❌ Direct access to internal entities
await orderLineRepository.save(orderLine) // bypasses Order invariants!
// ✅ Only via aggregate root
const updatedOrder = addLineToOrder(order, newLine)
await orderRepository.save(updatedOrder)
strategic-dddhexagonal-typescript