| name | ddd-typescript |
| description | 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. |
Domain-Driven Design for TypeScript
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. Use hexagonal-typescript when you need package structure and dependency direction — how to organize ports, adapters, and use case classes.
When to Activate
- Modeling a new domain concept (entity or value object?)
- Deciding whether logic belongs in domain, use case, or adapter
- Identifying aggregate boundaries and consistency rules
- Designing domain events and their dispatch
- Reviewing for anemic domain model (objects with no behavior)
- Naming modules, types, and functions (ubiquitous language)
Building Block 1: Value Objects
Identity: none — equal when all fields are equal.
Rule: Immutable. Use readonly properties. Use factory functions or classes with private constructors.
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
}
Branded Types for Typed IDs
Prevents passing a UserId where a MarketId is expected — zero runtime cost:
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
}
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).
Building Block 2: Entities
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.
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
}
export function createMarket(name: string, slug: string): Market {
if (!name || name.trim() === '') throw new InvalidMarketError('name required')
if (!slug || !/^[a-z0-9-]+$/.test(slug)) ()
.({ : , : name.(), slug, : })
}
(): { : ; : [] } {
(market. !== ) (market.)
{
: { ...market, : },
: [{ : , : market.!, : market., : () }],
}
}
Building Block 3: Aggregates & Aggregate Root
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.
Rules
- One transaction = one aggregate — never modify two aggregates in one async flow
- Reference other aggregates by ID only — never by object reference
- One repository per Aggregate Root — no repository for child entities
- Invariants enforced inside the aggregate
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
readonly lines: readonly OrderLine[]
readonly status: 'DRAFT' | 'PLACED'
}
export function createOrder(customerId: CustomerId): Order {
.({ : , customerId, : [], : })
}
(): {
(order. !== ) (order.)
{ ...order, : [...order., line] }
}
(): {
(order.. === ) (order.)
{ ...order, : }
}
(): {
order..(
(acc, { : line.. * line., : line.. }),
(, order.[]?.. ?? ),
)
}
Building Block 4: Domain Services
When: Logic belongs in the domain but doesn't fit a single entity.
Rule: Stateless functions. No framework imports. Named after domain verbs.
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 |
Building Block 5: Domain Events
Domain events represent something that happened. Immutable facts.
export interface DomainEvent {
readonly type: string
readonly occurredAt: Date
}
export interface MarketPublishedEvent extends DomainEvent {
readonly type: 'MarketPublished'
readonly marketId: MarketId
readonly name: string
}
Dispatching Domain Events
Collect events from aggregate operations, dispatch after successful save:
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 { : updated, events } = (market)
..(updated)
.(events.( ..(e)))
}
}
Ubiquitous Language
Use the same terms in code as domain experts use. Never translate.
async function processMarketData(input: MarketInput) {}
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>
Bounded Contexts
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
Anti-Corruption Layer Between Contexts
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> {
const request = {
orderId: order.id!,
amount: amount.amount,
currency: amount.currency,
}
const response = await this.httpClient.charge(request)
return { : response., : response. }
}
}
Anti-Patterns to Avoid
Anemic Domain Model
interface Market {
status: string
setStatus(s: string): void
}
async function publishMarket(id: string) {
const market = await repo.findById(id)
if (market.status !== 'DRAFT') throw new Error('not a draft')
market.setStatus('ACTIVE')
await repo.save(market)
}
export function publishMarket(market: Market) {
if (market.status !== 'DRAFT') throw new MarketAlreadyPublishedError(market.slug)
return { ...market, status: 'ACTIVE' }
}
Primitive Obsession
async function createOrder(userId: string, marketId: string): Promise<void>
async function createOrder(userId: UserId, marketId: MarketId): Promise<void>
Repository per Entity (not per Aggregate Root)
await orderLineRepository.save(orderLine)
const updatedOrder = addLineToOrder(order, newLine)
await orderRepository.save(updatedOrder)
DDD Checklist for New Projects
Reference
- Strategic DDD (Bounded Contexts, Context Map, Subdomain classification, Event Storming): see skill
strategic-ddd
- Hexagonal Architecture (package structure, adapters): see skill
hexagonal-typescript