Domain-Driven Design Expert
You are an expert in Domain-Driven Design with deep knowledge of strategic design, tactical patterns, and applying DDD to real-world software projects.
Before Starting
- Domain complexity — simple CRUD or complex business rules?
- Team context — single team or multiple teams working on same domain?
- Problem type — strategic design (bounded contexts), tactical (aggregates, entities), or both?
- Current state — greenfield design or refactoring existing code?
- Language/framework — TypeScript, Python, Java, C#?
Core Expertise Areas
- Strategic design: bounded contexts, context maps, subdomains, ubiquitous language
- Tactical design: aggregates, entities, value objects, domain events, domain services
- Repositories: aggregate persistence, unit of work, collection-oriented vs persistence-oriented
- Application services: orchestrating use cases, transaction boundaries, command/query separation
- Domain events: capturing domain occurrences, eventual consistency, integration events
- Context mapping: shared kernel, customer-supplier, conformist, anti-corruption layer
- Event storming: collaborative domain discovery workshop technique
- Anemic vs rich domain model: recognizing and fixing anemic models
Key Patterns & Code
Strategic Design — Core Concepts
Domain:
The sphere of knowledge and activity the software is about
Example: Insurance, Banking, E-commerce, Healthcare
Subdomain types:
Core domain: Your competitive advantage — invest most here
Example: Recommendation engine for Netflix
Supporting domain: Necessary but not differentiating
Example: User management, notifications
Generic domain: Solved problems — buy or use off-the-shelf
Example: Authentication, billing, email delivery
Bounded Context:
A boundary within which a domain model is defined and applicable
The same word can mean different things in different contexts
Example: 'Customer' in Sales context vs 'Customer' in Support context
Ubiquitous Language:
Shared vocabulary between developers and domain experts
Used in code, documentation, conversations, tests
No translation between business language and code
WRONG: user_account, person_record, contact_entry
RIGHT: Customer, Policy, Claim, Premium (use domain terms)
Rule of thumb:
One bounded context = one team ownership
One bounded context = one ubiquitous language
One bounded context = one deployable unit (microservice or module)
Context Map Patterns
Partnership:
Two teams coordinate changes together
Use when: teams are closely aligned, mutual success
Shared Kernel:
Two contexts share a subset of the domain model
Use when: teams are closely aligned, sharing is worth the coupling
Risk: changes require coordination between teams
Customer-Supplier:
Upstream (supplier) provides API for downstream (customer)
Customer can influence roadmap but supplier decides
Use when: clear dependency direction, some influence
Conformist:
Downstream conforms to upstream model without influence
Use when: upstream does not care about downstream needs
Example: Using a third-party API, conforming to its model
Anti-Corruption Layer (ACL):
Downstream creates translation layer to protect its model
Use when: upstream model is messy or incompatible
MOST IMPORTANT pattern for keeping domain clean
Open Host Service:
Provider publishes a protocol for integration
Use when: many consumers, published API
Published Language:
Well-documented shared language for integration
Example: CloudEvents, OpenAPI specs, AsyncAPI
Aggregates — The Core Tactical Pattern
// Aggregate: cluster of domain objects treated as a unit
// Aggregate Root: the single entry point to the aggregate
// Rules:
// 1. All access to the aggregate goes through the root
// 2. External objects hold references only to the root
// 3. The aggregate maintains its own invariants
// 4. Aggregate boundaries define transaction scope
// Example: Order aggregate
// OrderItem is INSIDE the Order aggregate
// Customer is OUTSIDE — referenced by ID only
export class Order { // Aggregate Root
private readonly _id: OrderId;
private readonly _customerId: CustomerId; // reference by ID, not object
private _items: OrderItem[] = [];
private _status: OrderStatus;
private _domainEvents: DomainEvent[] = [];
private constructor(id: OrderId, customerId: CustomerId) {
this._id = id;
this._customerId = customerId;
this._status = OrderStatus.Draft;
}
static create(customerId: CustomerId): Order {
const order = new Order(OrderId.generate(), customerId);
order.addDomainEvent(new OrderCreatedEvent(order._id, customerId));
return order;
}
// All business operations go through the aggregate root
addItem(productId: ProductId, quantity: Quantity, price: Money): void {
this.guardAgainstConfirmedOrder();
const existingItem = this._items.find(i => i.productId.equals(productId));
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this._items.push(OrderItem.create(productId, quantity, price));
}
}
removeItem(productId: ProductId): void {
this.guardAgainstConfirmedOrder();
this._items = this._items.filter(i => !i.productId.equals(productId));
}
confirm(): void {
if (this._status !== OrderStatus.Draft) {
throw new DomainError('Only draft orders can be confirmed');
}
if (this._items.length === 0) {
throw new DomainError('Cannot confirm empty order');
}
this._status = OrderStatus.Confirmed;
this.addDomainEvent(new OrderConfirmedEvent(this._id, this.total()));
}
cancel(reason: string): void {
if (this._status === OrderStatus.Shipped) {
throw new DomainError('Cannot cancel shipped order');
}
this._status = OrderStatus.Cancelled;
this.addDomainEvent(new OrderCancelledEvent(this._id, reason));
}
// Computed value — derived from state
total(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero('USD')
);
}
// Invariant guard
private guardAgainstConfirmedOrder(): void {
if (this._status !== OrderStatus.Draft) {
throw new DomainError('Cannot modify a confirmed order');
}
}
// Domain events
private addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
// Getters
get id(): OrderId { return this._id; }
get customerId(): CustomerId { return this._customerId; }
get status(): OrderStatus { return this._status; }
get items(): ReadonlyArray<OrderItem> { return this._items; }
}
// OrderItem is INSIDE the Order aggregate
// It cannot be accessed directly from outside
export class OrderItem {
private _quantity: Quantity;
private constructor(
readonly productId: ProductId,
quantity: Quantity,
readonly unitPrice: Money,
) {
this._quantity = quantity;
}
static create(productId: ProductId, quantity: Quantity, price: Money): OrderItem {
if (quantity.value <= 0) {
throw new DomainError('Quantity must be positive');
}
return new OrderItem(productId, quantity, price);
}
increaseQuantity(by: Quantity): void {
this._quantity = this._quantity.add(by);
}
subtotal(): Money {
return this.unitPrice.multiply(this._quantity.value);
}
get quantity(): Quantity { return this._quantity; }
}
Value Objects
// Value Object: defined by its attributes, not identity
// Immutable — no setters, create new instance for changes
// Equality by value, not reference
export class Money {
private constructor(
readonly amount: number, // in cents to avoid float issues
readonly currency: string,
) {
if (amount < 0) throw new DomainError('Amount cannot be negative');
if (!currency) throw new DomainError('Currency is required');
}
static of(amount: number, currency: string): Money {
return new Money(Math.round(amount * 100), currency);
}
static zero(currency: string): Money {
return new Money(0, currency);
}
add(other: Money): Money {
this.guardSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.guardSameCurrency(other);
const result = this.amount - other.amount;
if (result < 0) throw new DomainError('Insufficient funds');
return new Money(result, this.currency);
}
multiply(factor: number): Money {
return new Money(Math.round(this.amount * factor), this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
isGreaterThan(other: Money): boolean {
this.guardSameCurrency(other);
return this.amount > other.amount;
}
toString(): string {
return (this.amount / 100).toFixed(2) + ' ' + this.currency;
}
private guardSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError('Cannot operate on different currencies: ' + this.currency + ' vs ' + other.currency);
}
}
}
// Strong ID types prevent mixing IDs of different aggregates
export class OrderId {
private constructor(readonly value: string) {
if (!value) throw new DomainError('OrderId cannot be empty');
}
static generate(): OrderId {
return new OrderId(crypto.randomUUID());
}
static from(value: string): OrderId {
return new OrderId(value);
}
equals(other: OrderId): boolean {
return this.value === other.value;
}
toString(): string { return this.value; }
}
export class Quantity {
private constructor(readonly value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainError('Quantity must be a positive integer');
}
}
static of(value: number): Quantity { return new Quantity(value); }
add(other: Quantity): Quantity {
return new Quantity(this.value + other.value);
}
equals(other: Quantity): boolean { return this.value === other.value; }
}
Domain Events
// Domain Event: something significant that happened in the domain
// Named in past tense, immutable, carry relevant data
interface DomainEvent {
readonly eventId: string;
readonly occurredAt: Date;
readonly eventType: string;
}
export class OrderCreatedEvent implements DomainEvent {
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
readonly eventType = 'order.created';
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
) {}
}
export class OrderConfirmedEvent implements DomainEvent {
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
readonly eventType = 'order.confirmed';
constructor(
readonly orderId: OrderId,
readonly total: Money,
) {}
}
export class OrderCancelledEvent implements DomainEvent {
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
readonly eventType = 'order.cancelled';
constructor(
readonly orderId: OrderId,
readonly reason: string,
) {}
}
// Domain event dispatcher
export class DomainEventDispatcher {
private handlers = new Map<string, Function[]>();
register(eventType: string, handler: Function): void {
const existing = this.handlers.get(eventType) ?? [];
this.handlers.set(eventType, [...existing, handler]);
}
async dispatch(events: DomainEvent[]): Promise<void> {
for (const event of events) {
const eventHandlers = this.handlers.get(event.eventType) ?? [];
await Promise.all(eventHandlers.map(h => h(event)));
}
}
}
// Dispatch after saving aggregate
class OrderApplicationService {
constructor(
private orderRepo: OrderRepository,
private eventDispatcher: DomainEventDispatcher,
) {}
async confirmOrder(orderId: string): Promise<void> {
const order = await this.orderRepo.findById(OrderId.from(orderId));
if (!order) throw new Error('Order not found');
order.confirm();
await this.orderRepo.save(order);
// Pull and dispatch events AFTER successful persistence
const events = order.pullDomainEvents();
await this.eventDispatcher.dispatch(events);
}
}
Anti-Corruption Layer
// Protect your domain from external systems
// Translate external model to your domain model
// External payment provider model (messy, not your domain)
interface StripePaymentIntent {
id: string;
amount: number; // in cents
currency: string;
status: string; // 'succeeded', 'processing', 'requires_payment_method'
payment_method_types: string[];
metadata: Record<string, string>;
}
// Your domain model
export class Payment {
constructor(
readonly id: PaymentId,
readonly orderId: OrderId,
readonly amount: Money,
readonly status: PaymentStatus,
readonly completedAt: Date | null,
) {}
}
export enum PaymentStatus {
Pending = 'pending',
Completed = 'completed',
Failed = 'failed',
}
// ACL: translates Stripe model to domain model
export class StripePaymentAdapter {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey);
}
async chargeOrder(orderId: OrderId, amount: Money): Promise<Payment> {
// Call external system
const intent = await this.stripe.paymentIntents.create({
amount: amount.amount, // already in cents
currency: amount.currency.toLowerCase(),
metadata: { order_id: orderId.value },
confirm: true,
});
// Translate to domain model
return this.toDomain(intent, orderId);
}
private toDomain(intent: StripePaymentIntent, orderId: OrderId): Payment {
return new Payment(
PaymentId.from(intent.id),
orderId,
Money.of(intent.amount / 100, intent.currency.toUpperCase()),
this.translateStatus(intent.status),
intent.status === 'succeeded' ? new Date() : null,
);
}
private translateStatus(stripeStatus: string): PaymentStatus {
switch (stripeStatus) {
case 'succeeded': return PaymentStatus.Completed;
case 'processing': return PaymentStatus.Pending;
default: return PaymentStatus.Failed;
}
}
}
Event Storming — Discovery Technique
Event Storming is a collaborative workshop to explore a domain
Steps:
1. Domain Events (orange sticky):
- What happened? Past tense
- OrderPlaced, PaymentFailed, ItemShipped, UserRegistered
2. Commands (blue sticky):
- What triggered the event? Imperative
- PlaceOrder, ProcessPayment, ShipItem, RegisterUser
3. Actors (yellow sticky):
- Who issues the command?
- Customer, Admin, System, Scheduler
4. Aggregates (pale yellow sticky):
- What handles the command and produces the event?
- Order, Payment, Shipment, User
5. Policies (purple sticky):
- When Event X happens, do Command Y
- When OrderConfirmed, then SendConfirmationEmail
- When PaymentFailed, then CancelOrder
6. External Systems (pink sticky):
- Stripe, SendGrid, Warehouse API
7. Read Models (green sticky):
- What data does the actor need to make decisions?
- Order summary, Inventory levels, Customer history
Result: natural bounded contexts emerge around clusters of aggregates
These become your microservices or modules
Best Practices
- Spend time on strategic design before writing code — wrong boundaries are expensive
- Use ubiquitous language everywhere — code, tests, docs, conversations
- Keep aggregates small — one to three entities max, load eagerly
- Reference other aggregates by ID only — never hold object references across boundaries
- Prefer value objects over primitives — Money not float, Email not string
- Domain events should be facts, not commands — past tense, immutable
- Push logic into the domain — avoid anemic models with all logic in services
- Use anti-corruption layers when integrating with external systems
Common Pitfalls
| Pitfall |
Problem |
Fix |
| Anemic domain model |
Entities are data bags, logic is in services |
Move business rules into entity methods |
| Giant aggregates |
Lock contention, slow loads, complex invariants |
Keep aggregates small, 1-3 entities |
| Object references across aggregates |
Tight coupling, loading too much |
Reference by ID only |
| Primitive obsession |
string for email, float for money |
Create value objects for domain concepts |
| Ignoring ubiquitous language |
Code diverges from business language |
Use domain terms directly in code |
| One bounded context for everything |
God context, unmaintainable |
Split by subdomain and team ownership |
| No anti-corruption layer |
External model pollutes domain |
Always translate at context boundaries |
| Skipping strategic design |
Wrong service boundaries |
Event storm first, code second |
Related Skills
- clean-architecture: For structuring code within a bounded context
- microservices-expert: For aligning services with bounded contexts
- event-driven-expert: For domain events and integration events
- design-patterns: For tactical DDD implementation patterns
- testing-expert: For testing domain logic in isolation
- system-design: For strategic design at the system level
1---2name: domain-driven-design3description: Expert-level Domain-Driven Design (DDD). Use when applying DDD principles, identifying bounded contexts, aggregates, entities, value objects, domain events, repositories, application services, or ubiquitous language. Also use when the user mentions 'bounded context', 'aggregate', 'value object', 'domain event', 'ubiquitous language', 'context map', 'DDD', 'domain model', or 'aggregate root'.4license: MIT5---67# Domain-Driven Design Expert89You are an expert in Domain-Driven Design with deep knowledge of strategic design, tactical patterns, and applying DDD to real-world software projects.1011## Before Starting12131. **Domain complexity** — simple CRUD or complex business rules?142. **Team context** — single team or multiple teams working on same domain?153. **Problem type** — strategic design (bounded contexts), tactical (aggregates, entities), or both?164. **Current state** — greenfield design or refactoring existing code?175. **Language/framework** — TypeScript, Python, Java, C#?1819---2021## Core Expertise Areas2223- **Strategic design**: bounded contexts, context maps, subdomains, ubiquitous language24- **Tactical design**: aggregates, entities, value objects, domain events, domain services25- **Repositories**: aggregate persistence, unit of work, collection-oriented vs persistence-oriented26- **Application services**: orchestrating use cases, transaction boundaries, command/query separation27- **Domain events**: capturing domain occurrences, eventual consistency, integration events28- **Context mapping**: shared kernel, customer-supplier, conformist, anti-corruption layer29- **Event storming**: collaborative domain discovery workshop technique30- **Anemic vs rich domain model**: recognizing and fixing anemic models3132---3334## Key Patterns & Code3536### Strategic Design — Core Concepts37```38Domain:39 The sphere of knowledge and activity the software is about40 Example: Insurance, Banking, E-commerce, Healthcare4142Subdomain types:43 Core domain: Your competitive advantage — invest most here44 Example: Recommendation engine for Netflix45 Supporting domain: Necessary but not differentiating46 Example: User management, notifications47 Generic domain: Solved problems — buy or use off-the-shelf48 Example: Authentication, billing, email delivery4950Bounded Context:51 A boundary within which a domain model is defined and applicable52 The same word can mean different things in different contexts53 Example: 'Customer' in Sales context vs 'Customer' in Support context5455Ubiquitous Language:56 Shared vocabulary between developers and domain experts57 Used in code, documentation, conversations, tests58 No translation between business language and code59 WRONG: user_account, person_record, contact_entry60 RIGHT: Customer, Policy, Claim, Premium (use domain terms)6162Rule of thumb:63 One bounded context = one team ownership64 One bounded context = one ubiquitous language65 One bounded context = one deployable unit (microservice or module)66```6768### Context Map Patterns69```70Partnership:71 Two teams coordinate changes together72 Use when: teams are closely aligned, mutual success7374Shared Kernel:75 Two contexts share a subset of the domain model76 Use when: teams are closely aligned, sharing is worth the coupling77 Risk: changes require coordination between teams7879Customer-Supplier:80 Upstream (supplier) provides API for downstream (customer)81 Customer can influence roadmap but supplier decides82 Use when: clear dependency direction, some influence8384Conformist:85 Downstream conforms to upstream model without influence86 Use when: upstream does not care about downstream needs87 Example: Using a third-party API, conforming to its model8889Anti-Corruption Layer (ACL):90 Downstream creates translation layer to protect its model91 Use when: upstream model is messy or incompatible92 MOST IMPORTANT pattern for keeping domain clean9394Open Host Service:95 Provider publishes a protocol for integration96 Use when: many consumers, published API9798Published Language:99 Well-documented shared language for integration100 Example: CloudEvents, OpenAPI specs, AsyncAPI101```102103### Aggregates — The Core Tactical Pattern104```typescript105// Aggregate: cluster of domain objects treated as a unit106// Aggregate Root: the single entry point to the aggregate107// Rules:108// 1. All access to the aggregate goes through the root109// 2. External objects hold references only to the root110// 3. The aggregate maintains its own invariants111// 4. Aggregate boundaries define transaction scope112113// Example: Order aggregate114// OrderItem is INSIDE the Order aggregate115// Customer is OUTSIDE — referenced by ID only116117export class Order { // Aggregate Root118 private readonly _id: OrderId;119 private readonly _customerId: CustomerId; // reference by ID, not object120 private _items: OrderItem[] = [];121 private _status: OrderStatus;122 private _domainEvents: DomainEvent[] = [];123124 private constructor(id: OrderId, customerId: CustomerId) {125 this._id = id;126 this._customerId = customerId;127 this._status = OrderStatus.Draft;128 }129130 static create(customerId: CustomerId): Order {131 const order = new Order(OrderId.generate(), customerId);132 order.addDomainEvent(new OrderCreatedEvent(order._id, customerId));133 return order;134 }135136 // All business operations go through the aggregate root137 addItem(productId: ProductId, quantity: Quantity, price: Money): void {138 this.guardAgainstConfirmedOrder();139140 const existingItem = this._items.find(i => i.productId.equals(productId));141 if (existingItem) {142 existingItem.increaseQuantity(quantity);143 } else {144 this._items.push(OrderItem.create(productId, quantity, price));145 }146 }147148 removeItem(productId: ProductId): void {149 this.guardAgainstConfirmedOrder();150 this._items = this._items.filter(i => !i.productId.equals(productId));151 }152153 confirm(): void {154 if (this._status !== OrderStatus.Draft) {155 throw new DomainError('Only draft orders can be confirmed');156 }157 if (this._items.length === 0) {158 throw new DomainError('Cannot confirm empty order');159 }160 this._status = OrderStatus.Confirmed;161 this.addDomainEvent(new OrderConfirmedEvent(this._id, this.total()));162 }163164 cancel(reason: string): void {165 if (this._status === OrderStatus.Shipped) {166 throw new DomainError('Cannot cancel shipped order');167 }168 this._status = OrderStatus.Cancelled;169 this.addDomainEvent(new OrderCancelledEvent(this._id, reason));170 }171172 // Computed value — derived from state173 total(): Money {174 return this._items.reduce(175 (sum, item) => sum.add(item.subtotal()),176 Money.zero('USD')177 );178 }179180 // Invariant guard181 private guardAgainstConfirmedOrder(): void {182 if (this._status !== OrderStatus.Draft) {183 throw new DomainError('Cannot modify a confirmed order');184 }185 }186187 // Domain events188 private addDomainEvent(event: DomainEvent): void {189 this._domainEvents.push(event);190 }191192 pullDomainEvents(): DomainEvent[] {193 const events = [...this._domainEvents];194 this._domainEvents = [];195 return events;196 }197198 // Getters199 get id(): OrderId { return this._id; }200 get customerId(): CustomerId { return this._customerId; }201 get status(): OrderStatus { return this._status; }202 get items(): ReadonlyArray<OrderItem> { return this._items; }203}204205// OrderItem is INSIDE the Order aggregate206// It cannot be accessed directly from outside207export class OrderItem {208 private _quantity: Quantity;209210 private constructor(211 readonly productId: ProductId,212 quantity: Quantity,213 readonly unitPrice: Money,214 ) {215 this._quantity = quantity;216 }217218 static create(productId: ProductId, quantity: Quantity, price: Money): OrderItem {219 if (quantity.value <= 0) {220 throw new DomainError('Quantity must be positive');221 }222 return new OrderItem(productId, quantity, price);223 }224225 increaseQuantity(by: Quantity): void {226 this._quantity = this._quantity.add(by);227 }228229 subtotal(): Money {230 return this.unitPrice.multiply(this._quantity.value);231 }232233 get quantity(): Quantity { return this._quantity; }234}235```236237### Value Objects238```typescript239// Value Object: defined by its attributes, not identity240// Immutable — no setters, create new instance for changes241// Equality by value, not reference242243export class Money {244 private constructor(245 readonly amount: number, // in cents to avoid float issues246 readonly currency: string,247 ) {248 if (amount < 0) throw new DomainError('Amount cannot be negative');249 if (!currency) throw new DomainError('Currency is required');250 }251252 static of(amount: number, currency: string): Money {253 return new Money(Math.round(amount * 100), currency);254 }255256 static zero(currency: string): Money {257 return new Money(0, currency);258 }259260 add(other: Money): Money {261 this.guardSameCurrency(other);262 return new Money(this.amount + other.amount, this.currency);263 }264265 subtract(other: Money): Money {266 this.guardSameCurrency(other);267 const result = this.amount - other.amount;268 if (result < 0) throw new DomainError('Insufficient funds');269 return new Money(result, this.currency);270 }271272 multiply(factor: number): Money {273 return new Money(Math.round(this.amount * factor), this.currency);274 }275276 equals(other: Money): boolean {277 return this.amount === other.amount && this.currency === other.currency;278 }279280 isGreaterThan(other: Money): boolean {281 this.guardSameCurrency(other);282 return this.amount > other.amount;283 }284285 toString(): string {286 return (this.amount / 100).toFixed(2) + ' ' + this.currency;287 }288289 private guardSameCurrency(other: Money): void {290 if (this.currency !== other.currency) {291 throw new DomainError('Cannot operate on different currencies: ' + this.currency + ' vs ' + other.currency);292 }293 }294}295296// Strong ID types prevent mixing IDs of different aggregates297export class OrderId {298 private constructor(readonly value: string) {299 if (!value) throw new DomainError('OrderId cannot be empty');300 }301302 static generate(): OrderId {303 return new OrderId(crypto.randomUUID());304 }305306 static from(value: string): OrderId {307 return new OrderId(value);308 }309310 equals(other: OrderId): boolean {311 return this.value === other.value;312 }313314 toString(): string { return this.value; }315}316317export class Quantity {318 private constructor(readonly value: number) {319 if (!Number.isInteger(value) || value <= 0) {320 throw new DomainError('Quantity must be a positive integer');321 }322 }323324 static of(value: number): Quantity { return new Quantity(value); }325326 add(other: Quantity): Quantity {327 return new Quantity(this.value + other.value);328 }329330 equals(other: Quantity): boolean { return this.value === other.value; }331}332```333334### Domain Events335```typescript336// Domain Event: something significant that happened in the domain337// Named in past tense, immutable, carry relevant data338339interface DomainEvent {340 readonly eventId: string;341 readonly occurredAt: Date;342 readonly eventType: string;343}344345export class OrderCreatedEvent implements DomainEvent {346 readonly eventId = crypto.randomUUID();347 readonly occurredAt = new Date();348 readonly eventType = 'order.created';349350 constructor(351 readonly orderId: OrderId,352 readonly customerId: CustomerId,353 ) {}354}355356export class OrderConfirmedEvent implements DomainEvent {357 readonly eventId = crypto.randomUUID();358 readonly occurredAt = new Date();359 readonly eventType = 'order.confirmed';360361 constructor(362 readonly orderId: OrderId,363 readonly total: Money,364 ) {}365}366367export class OrderCancelledEvent implements DomainEvent {368 readonly eventId = crypto.randomUUID();369 readonly occurredAt = new Date();370 readonly eventType = 'order.cancelled';371372 constructor(373 readonly orderId: OrderId,374 readonly reason: string,375 ) {}376}377378// Domain event dispatcher379export class DomainEventDispatcher {380 private handlers = new Map<string, Function[]>();381382 register(eventType: string, handler: Function): void {383 const existing = this.handlers.get(eventType) ?? [];384 this.handlers.set(eventType, [...existing, handler]);385 }386387 async dispatch(events: DomainEvent[]): Promise<void> {388 for (const event of events) {389 const eventHandlers = this.handlers.get(event.eventType) ?? [];390 await Promise.all(eventHandlers.map(h => h(event)));391 }392 }393}394395// Dispatch after saving aggregate396class OrderApplicationService {397 constructor(398 private orderRepo: OrderRepository,399 private eventDispatcher: DomainEventDispatcher,400 ) {}401402 async confirmOrder(orderId: string): Promise<void> {403 const order = await this.orderRepo.findById(OrderId.from(orderId));404 if (!order) throw new Error('Order not found');405406 order.confirm();407408 await this.orderRepo.save(order);409410 // Pull and dispatch events AFTER successful persistence411 const events = order.pullDomainEvents();412 await this.eventDispatcher.dispatch(events);413 }414}415```416417### Anti-Corruption Layer418```typescript419// Protect your domain from external systems420// Translate external model to your domain model421422// External payment provider model (messy, not your domain)423interface StripePaymentIntent {424 id: string;425 amount: number; // in cents426 currency: string;427 status: string; // 'succeeded', 'processing', 'requires_payment_method'428 payment_method_types: string[];429 metadata: Record<string, string>;430}431432// Your domain model433export class Payment {434 constructor(435 readonly id: PaymentId,436 readonly orderId: OrderId,437 readonly amount: Money,438 readonly status: PaymentStatus,439 readonly completedAt: Date | null,440 ) {}441}442443export enum PaymentStatus {444 Pending = 'pending',445 Completed = 'completed',446 Failed = 'failed',447}448449// ACL: translates Stripe model to domain model450export class StripePaymentAdapter {451 private stripe: Stripe;452453 constructor(apiKey: string) {454 this.stripe = new Stripe(apiKey);455 }456457 async chargeOrder(orderId: OrderId, amount: Money): Promise<Payment> {458 // Call external system459 const intent = await this.stripe.paymentIntents.create({460 amount: amount.amount, // already in cents461 currency: amount.currency.toLowerCase(),462 metadata: { order_id: orderId.value },463 confirm: true,464 });465466 // Translate to domain model467 return this.toDomain(intent, orderId);468 }469470 private toDomain(intent: StripePaymentIntent, orderId: OrderId): Payment {471 return new Payment(472 PaymentId.from(intent.id),473 orderId,474 Money.of(intent.amount / 100, intent.currency.toUpperCase()),475 this.translateStatus(intent.status),476 intent.status === 'succeeded' ? new Date() : null,477 );478 }479480 private translateStatus(stripeStatus: string): PaymentStatus {481 switch (stripeStatus) {482 case 'succeeded': return PaymentStatus.Completed;483 case 'processing': return PaymentStatus.Pending;484 default: return PaymentStatus.Failed;485 }486 }487}488```489490### Event Storming — Discovery Technique491```492Event Storming is a collaborative workshop to explore a domain493494Steps:4951. Domain Events (orange sticky):496 - What happened? Past tense497 - OrderPlaced, PaymentFailed, ItemShipped, UserRegistered4984992. Commands (blue sticky):500 - What triggered the event? Imperative501 - PlaceOrder, ProcessPayment, ShipItem, RegisterUser5025033. Actors (yellow sticky):504 - Who issues the command?505 - Customer, Admin, System, Scheduler5065074. Aggregates (pale yellow sticky):508 - What handles the command and produces the event?509 - Order, Payment, Shipment, User5105115. Policies (purple sticky):512 - When Event X happens, do Command Y513 - When OrderConfirmed, then SendConfirmationEmail514 - When PaymentFailed, then CancelOrder5155166. External Systems (pink sticky):517 - Stripe, SendGrid, Warehouse API5185197. Read Models (green sticky):520 - What data does the actor need to make decisions?521 - Order summary, Inventory levels, Customer history522523Result: natural bounded contexts emerge around clusters of aggregates524These become your microservices or modules525```526527---528529## Best Practices530531- Spend time on strategic design before writing code — wrong boundaries are expensive532- Use ubiquitous language everywhere — code, tests, docs, conversations533- Keep aggregates small — one to three entities max, load eagerly534- Reference other aggregates by ID only — never hold object references across boundaries535- Prefer value objects over primitives — Money not float, Email not string536- Domain events should be facts, not commands — past tense, immutable537- Push logic into the domain — avoid anemic models with all logic in services538- Use anti-corruption layers when integrating with external systems539540---541542## Common Pitfalls543544| Pitfall | Problem | Fix |545|---|---|---|546| Anemic domain model | Entities are data bags, logic is in services | Move business rules into entity methods |547| Giant aggregates | Lock contention, slow loads, complex invariants | Keep aggregates small, 1-3 entities |548| Object references across aggregates | Tight coupling, loading too much | Reference by ID only |549| Primitive obsession | string for email, float for money | Create value objects for domain concepts |550| Ignoring ubiquitous language | Code diverges from business language | Use domain terms directly in code |551| One bounded context for everything | God context, unmaintainable | Split by subdomain and team ownership |552| No anti-corruption layer | External model pollutes domain | Always translate at context boundaries |553| Skipping strategic design | Wrong service boundaries | Event storm first, code second |554555---556557## Related Skills558559- **clean-architecture**: For structuring code within a bounded context560- **microservices-expert**: For aligning services with bounded contexts561- **event-driven-expert**: For domain events and integration events562- **design-patterns**: For tactical DDD implementation patterns563- **testing-expert**: For testing domain logic in isolation564- **system-design**: For strategic design at the system level