Step 2: Implement Value Objects & Entities
Value objects represent descriptive aspects of the domain with no identity. They must be immutable and equality-based.
export class Money {
constructor(
public readonly amount: number,
public readonly currency: 'USD' | 'EUR' | 'GBP'
) {
if (amount < 0) throw new Error('Amount cannot be negative');
if (!['USD', 'EUR', 'GBP'].includes(currency)) {
throw new Error('Unsupported currency');
}
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
}
Entities carry identity and lifecycle. They reference value objects and other entities but never leak infrastructure concerns.
export type OrderItemId = string;
export class OrderItem {
constructor(
public readonly id: OrderItemId,
public readonly sku: string,
public readonly quantity: number,
public readonly unitPrice: Money
) {
if (quantity <= 0) throw new Error('Quantity must be positive');
}
get lineTotal(): Money {
return new Money(this.unitPrice.amount * this.quantity, this.unitPrice.currency);
}
}
Step 3: Design Aggregates & Enforce Invariants
An aggregate is a cluster of domain objects treated as a single unit for data changes. It enforces business invariants and exposes behavior, not state.
export class Order {
private readonly _items: OrderItem[] = [];
private _status: 'draft' | 'placed' | 'cancelled' = 'draft';
constructor(
public readonly id: string,
public readonly customerId: string
) {}
addItem(item: OrderItem): void {
if (this._status !== 'draft') {
throw new Error('Cannot modify placed order');
}
this._items.push(item);
}
get total(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.lineTotal),
new Money(0, 'USD')
);
}
place(): void {
if (this._items.length === 0) {
throw new Error('Cannot place empty order');
}
this._status = 'placed';
DomainEventBus.publish(new OrderPlacedEvent(this.id, this.customerId, this.total));
}
}
Step 4: Handle Domain Events & Infrastructure Decoupling
Domain events capture state changes that matter to the business. They are emitted synchronously but handled asynchronously to preserve transactional boundaries.
export interface DomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
}
export class OrderPlacedEvent implements DomainEvent {
readonly eventId = crypto.randomUUID();
readonly occurredOn = new Date();
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly total: Money
) {}
}
export class DomainEventBus {
private static handlers = new Map<string, Array<(event: DomainEvent) => Promise<void>>>();
static subscribe<T extends DomainEvent>(
eventType: new (...args: any[]) => T,
handler: (event: T) => Promise<void>
): void {
const key = eventType.name;
if (!this.handlers.has(key)) this.handlers.set(key, []);
this.handlers.get(key)!.push(handler as any);
}
static async publish(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.constructor.name) || [];
await Promise.all(handlers.map(h => h(event)));
}
}
Step 5: Wire Repositories & Application Services
Repositories persist aggregates. They return domain objects, not DTOs, and operate on aggregate roots only.
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
export class PlaceOrderCommand {
constructor(
public readonly customerId: string,
public readonly items: Array<{ sku: string; quantity: number; unitPrice: number }>
) {}
}
export class OrderApplicationService {
constructor(private readonly orderRepo: OrderRepository) {}
async execute(cmd: PlaceOrderCommand): Promise<string> {
const order = new Order(crypto.randomUUID(), cmd.customerId);
for (const item of cmd.items) {
order.addItem(new OrderItem(
crypto.randomUUID(),
item.sku,
item.quantity,
new Money(item.unitPrice, 'USD')
));
}
order.place();
await this.orderRepo.save(order);
return order.id;
}
}
Architecture Decisions & Rationale:
- Hexagonal/Clean Architecture Integration: The domain layer contains zero infrastructure dependencies. Repositories and event handlers are implemented in outer layers, preserving testability and deployment flexibility.
- Aggregate Boundaries: Consistency is enforced within the aggregate. Cross-aggregate consistency is handled via domain events, preventing distributed transactions and locking contention.
- Behavior-Rich Models: Methods like
place() and addItem() encapsulate business rules. This eliminates anemic domain models where services become procedural wrappers around data.
- Immutable Value Objects: Prevents accidental state mutation and simplifies reasoning about equality and caching.
Pitfall Guide
- Treating DDD as a Folder Structure: Creating
domain/, infrastructure/, and application/ directories without enforcing architectural boundaries yields no benefit. The value comes from dependency direction, not topology.
- Over-Engineering Aggregates: Aggregates that span multiple entities with complex cross-references create locking bottlenecks and slow persistence. Keep aggregates small, focused on a single consistency boundary.
- Leaking Infrastructure into the Domain: Injecting databases, HTTP clients, or logging frameworks into domain objects breaks testability and couples business rules to delivery mechanisms.
- Ignoring the Ubiquitous Language: When developers, QA, and product use different terminology, models drift. Enforce shared vocabulary in code, tests, and documentation.
- Misusing Value Objects: Value objects must be immutable and identity-less. Adding mutable state or treating them as entities defeats their purpose and introduces subtle bugs.
- Skipping Context Mapping: Without explicit context maps (shared kernel, customer-supplier, anti-corruption layer), bounded contexts bleed into each other, recreating monolithic coupling.
- Applying DDD to CRUD-Heavy Domains: Not every module requires aggregates and events. Use DDD where business rules are complex, frequently changing, or carry high cost of failure.
Best Practices:
- Start with event storming or domain modeling workshops before writing code.
- Validate invariants at object creation; fail fast with explicit exceptions.
- Keep the domain layer dependency-free. Use interfaces for external concerns.
- Prefer composition over inheritance in domain models.
- Use context maps early to define integration contracts between teams.
- Measure domain model health via test coverage of invariants and frequency of cross-context calls.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency CRUD operations | Traditional layered or CQRS-lite | DDD overhead outweighs benefits for simple data flows | Low implementation cost, faster delivery |
| Complex business rules with frequent changes | Full DDD with aggregates & events | Explicit boundaries isolate volatility and reduce regression risk | Higher initial investment, lower long-term maintenance |
| Multiple teams sharing a domain | Context mapping + Anti-Corruption Layer | Prevents semantic drift and enforces explicit contracts | Moderate coordination cost, high autonomy gain |
| Real-time consistency requirements | Single aggregate boundary + synchronous validation | Distributed events cannot guarantee immediate consistency | Low latency, limited scalability |
| Eventual consistency acceptable | Domain events + message broker | Decouples services, enables independent scaling | Higher infrastructure cost, improved resilience |
Configuration Template
// tsconfig.json (domain layer)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src/domain"
},
"include": ["src/domain/**/*"]
}
// src/domain/DomainEvent.ts
export interface DomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
}
export class DomainEventBus {
private static handlers = new Map<string, Array<(event: DomainEvent) => Promise<void>>>();
static subscribe<T extends DomainEvent>(
eventType: new (...args: any[]) => T,
handler: (event: T) => Promise<void>
): void {
const key = eventType.name;
if (!this.handlers.has(key)) this.handlers.set(key, []);
this.handlers.get(key)!.push(handler as any);
}
static async publish(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.constructor.name) || [];
await Promise.all(handlers.map(h => h(event)));
}
}
// src/domain/ValueObject.ts
export abstract class ValueObject<T> {
protected abstract props: T;
public equals(other: ValueObject<T>): boolean {
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}
Quick Start Guide
- Scaffold a monorepo with separate packages for
domain, application, and infrastructure. Set strict TypeScript compilation on the domain package.
- Run a 2-hour event storming session with product and engineering. Identify commands, events, aggregates, and context boundaries. Document the ubiquitous language.
- Implement value objects and aggregates with explicit invariants. Write unit tests covering validation rules and state transitions. No mocks required.
- Define repository and event handler interfaces in the domain layer. Implement persistence and messaging in the infrastructure package. Wire dependencies via constructor injection.
- Run the application service flow end-to-end. Validate that domain events publish correctly, aggregates enforce rules, and infrastructure remains decoupled. Deploy to staging for integration validation.