Domain-Driven Design helps build software that matches business complexity. It's about understanding the domain deeply and reflecting that understanding in code.
Core Concepts#
Strategic Design:
- Bounded Context: Clear boundaries around domain models
- Ubiquitous Language: Shared vocabulary between devs and domain experts
- Context Mapping: Relationships between bounded contexts
Tactical Design:
- Entities: Objects with identity
- Value Objects: Objects defined by attributes
- Aggregates: Consistency boundaries
- Domain Events: Significant occurrences
- Repositories: Data access abstraction
Bounded Contexts#
1// Different contexts have different models of the same concept
2
3// Sales Context - Customer
4namespace Sales {
5 interface Customer {
6 id: string;
7 name: string;
8 email: string;
9 creditLimit: number;
10 paymentTerms: string;
11 }
12}
13
14// Shipping Context - Customer
15namespace Shipping {
16 interface Customer {
17 id: string;
18 name: string;
19 shippingAddresses: Address[];
20 preferredCarrier: string;
21 }
22}
23
24// Support Context - Customer
25namespace Support {
26 interface Customer {
27 id: string;
28 name: string;
29 email: string;
30 supportTier: 'basic' | 'premium' | 'enterprise';
31 openTickets: number;
32 }
33}
34
35// Anti-corruption layer between contexts
36class CustomerAdapter {
37 constructor(
38 private salesCustomer: Sales.Customer,
39 private supportService: SupportService
40 ) {}
41
42 toSupportCustomer(): Support.Customer {
43 return {
44 id: this.salesCustomer.id,
45 name: this.salesCustomer.name,
46 email: this.salesCustomer.email,
47 supportTier: this.mapPaymentTermsToTier(this.salesCustomer.paymentTerms),
48 openTickets: 0,
49 };
50 }
51
52 private mapPaymentTermsToTier(terms: string): Support.Customer['supportTier'] {
53 if (terms === 'enterprise') return 'enterprise';
54 if (terms === 'net-30') return 'premium';
55 return 'basic';
56 }
57}Entities and Value Objects#
1// Entity: Has identity, can change over time
2class Order {
3 constructor(
4 public readonly id: OrderId,
5 private customerId: CustomerId,
6 private items: OrderItem[],
7 private status: OrderStatus
8 ) {}
9
10 addItem(item: OrderItem): void {
11 if (this.status !== 'draft') {
12 throw new Error('Cannot modify non-draft order');
13 }
14 this.items.push(item);
15 }
16
17 submit(): void {
18 if (this.items.length === 0) {
19 throw new Error('Cannot submit empty order');
20 }
21 this.status = 'submitted';
22 }
23
24 get total(): Money {
25 return this.items.reduce(
26 (sum, item) => sum.add(item.subtotal),
27 Money.zero('USD')
28 );
29 }
30}
31
32// Value Object: Immutable, defined by attributes
33class Money {
34 private constructor(
35 public readonly amount: number,
36 public readonly currency: string
37 ) {
38 if (amount < 0) throw new Error('Amount cannot be negative');
39 }
40
41 static of(amount: number, currency: string): Money {
42 return new Money(amount, currency);
43 }
44
45 static zero(currency: string): Money {
46 return new Money(0, currency);
47 }
48
49 add(other: Money): Money {
50 if (this.currency !== other.currency) {
51 throw new Error('Cannot add different currencies');
52 }
53 return new Money(this.amount + other.amount, this.currency);
54 }
55
56 multiply(factor: number): Money {
57 return new Money(this.amount * factor, this.currency);
58 }
59
60 equals(other: Money): boolean {
61 return this.amount === other.amount && this.currency === other.currency;
62 }
63}
64
65// Another value object
66class Address {
67 constructor(
68 public readonly street: string,
69 public readonly city: string,
70 public readonly postalCode: string,
71 public readonly country: string
72 ) {
73 this.validate();
74 }
75
76 private validate(): void {
77 if (!this.street || !this.city || !this.country) {
78 throw new Error('Invalid address');
79 }
80 }
81
82 equals(other: Address): boolean {
83 return (
84 this.street === other.street &&
85 this.city === other.city &&
86 this.postalCode === other.postalCode &&
87 this.country === other.country
88 );
89 }
90
91 toString(): string {
92 return `${this.street}, ${this.city} ${this.postalCode}, ${this.country}`;
93 }
94}Aggregates#
1// Aggregate Root: Order controls access to OrderItems
2class Order {
3 private id: OrderId;
4 private items: Map<string, OrderItem> = new Map();
5 private status: OrderStatus = 'draft';
6 private events: DomainEvent[] = [];
7
8 constructor(id: OrderId, private customerId: CustomerId) {
9 this.id = id;
10 this.addEvent(new OrderCreated(id, customerId));
11 }
12
13 // All modifications go through the aggregate root
14 addItem(productId: ProductId, quantity: number, price: Money): void {
15 this.ensureDraft();
16
17 const existingItem = this.items.get(productId.value);
18 if (existingItem) {
19 existingItem.increaseQuantity(quantity);
20 } else {
21 this.items.set(
22 productId.value,
23 new OrderItem(productId, quantity, price)
24 );
25 }
26
27 this.addEvent(new OrderItemAdded(this.id, productId, quantity));
28 }
29
30 removeItem(productId: ProductId): void {
31 this.ensureDraft();
32 this.items.delete(productId.value);
33 this.addEvent(new OrderItemRemoved(this.id, productId));
34 }
35
36 submit(): void {
37 this.ensureDraft();
38 if (this.items.size === 0) {
39 throw new DomainError('Cannot submit empty order');
40 }
41 this.status = 'submitted';
42 this.addEvent(new OrderSubmitted(this.id, this.total));
43 }
44
45 private ensureDraft(): void {
46 if (this.status !== 'draft') {
47 throw new DomainError('Order is not modifiable');
48 }
49 }
50
51 private addEvent(event: DomainEvent): void {
52 this.events.push(event);
53 }
54
55 pullEvents(): DomainEvent[] {
56 const events = [...this.events];
57 this.events = [];
58 return events;
59 }
60
61 get total(): Money {
62 let total = Money.zero('USD');
63 for (const item of this.items.values()) {
64 total = total.add(item.subtotal);
65 }
66 return total;
67 }
68}
69
70// Part of Order aggregate - not directly accessible
71class OrderItem {
72 constructor(
73 public readonly productId: ProductId,
74 private quantity: number,
75 private unitPrice: Money
76 ) {}
77
78 increaseQuantity(amount: number): void {
79 this.quantity += amount;
80 }
81
82 get subtotal(): Money {
83 return this.unitPrice.multiply(this.quantity);
84 }
85}Domain Events#
1// Domain events represent something that happened
2interface DomainEvent {
3 readonly occurredAt: Date;
4 readonly aggregateId: string;
5}
6
7class OrderSubmitted implements DomainEvent {
8 readonly occurredAt = new Date();
9
10 constructor(
11 public readonly aggregateId: string,
12 public readonly orderId: OrderId,
13 public readonly total: Money
14 ) {}
15}
16
17class PaymentReceived implements DomainEvent {
18 readonly occurredAt = new Date();
19
20 constructor(
21 public readonly aggregateId: string,
22 public readonly orderId: OrderId,
23 public readonly amount: Money
24 ) {}
25}
26
27// Event handler
28class OrderSubmittedHandler {
29 constructor(
30 private inventoryService: InventoryService,
31 private notificationService: NotificationService
32 ) {}
33
34 async handle(event: OrderSubmitted): Promise<void> {
35 // Reserve inventory
36 await this.inventoryService.reserve(event.orderId);
37
38 // Send confirmation
39 await this.notificationService.sendOrderConfirmation(event.orderId);
40 }
41}Repositories#
1// Repository abstracts data access
2interface OrderRepository {
3 findById(id: OrderId): Promise<Order | null>;
4 save(order: Order): Promise<void>;
5 nextId(): OrderId;
6}
7
8class PrismaOrderRepository implements OrderRepository {
9 constructor(private prisma: PrismaClient, private eventBus: EventBus) {}
10
11 async findById(id: OrderId): Promise<Order | null> {
12 const data = await this.prisma.order.findUnique({
13 where: { id: id.value },
14 include: { items: true },
15 });
16
17 if (!data) return null;
18
19 return this.toDomain(data);
20 }
21
22 async save(order: Order): Promise<void> {
23 const data = this.toPersistence(order);
24
25 await this.prisma.order.upsert({
26 where: { id: data.id },
27 create: data,
28 update: data,
29 });
30
31 // Publish domain events
32 const events = order.pullEvents();
33 for (const event of events) {
34 await this.eventBus.publish(event);
35 }
36 }
37
38 nextId(): OrderId {
39 return new OrderId(crypto.randomUUID());
40 }
41
42 private toDomain(data: OrderData): Order {
43 // Reconstruct aggregate from persistence data
44 return Order.reconstitute(
45 new OrderId(data.id),
46 new CustomerId(data.customerId),
47 data.items.map(this.toOrderItem),
48 data.status as OrderStatus
49 );
50 }
51
52 private toPersistence(order: Order): OrderData {
53 // Convert aggregate to persistence format
54 return {
55 id: order.id.value,
56 customerId: order.customerId.value,
57 status: order.status,
58 items: order.items.map((item) => ({
59 productId: item.productId.value,
60 quantity: item.quantity,
61 unitPrice: item.unitPrice.amount,
62 })),
63 };
64 }
65}Application Services#
1// Application service orchestrates domain operations
2class OrderService {
3 constructor(
4 private orderRepository: OrderRepository,
5 private customerRepository: CustomerRepository,
6 private productCatalog: ProductCatalog
7 ) {}
8
9 async createOrder(customerId: string): Promise<string> {
10 const customer = await this.customerRepository.findById(new CustomerId(customerId));
11 if (!customer) throw new Error('Customer not found');
12
13 const orderId = this.orderRepository.nextId();
14 const order = new Order(orderId, customer.id);
15
16 await this.orderRepository.save(order);
17
18 return orderId.value;
19 }
20
21 async addItem(orderId: string, productId: string, quantity: number): Promise<void> {
22 const order = await this.orderRepository.findById(new OrderId(orderId));
23 if (!order) throw new Error('Order not found');
24
25 const product = await this.productCatalog.findById(new ProductId(productId));
26 if (!product) throw new Error('Product not found');
27
28 order.addItem(product.id, quantity, product.price);
29
30 await this.orderRepository.save(order);
31 }
32
33 async submitOrder(orderId: string): Promise<void> {
34 const order = await this.orderRepository.findById(new OrderId(orderId));
35 if (!order) throw new Error('Order not found');
36
37 order.submit();
38
39 await this.orderRepository.save(order);
40 }
41}Best Practices#
Modeling:
✓ Model the domain, not the database
✓ Use ubiquitous language consistently
✓ Keep aggregates small
✓ Protect invariants in aggregate roots
Implementation:
✓ Make implicit concepts explicit
✓ Use value objects for domain concepts
✓ Raise domain events for side effects
✓ Isolate domain from infrastructure
Organization:
✓ One aggregate per transaction
✓ Reference aggregates by ID
✓ Use domain events for cross-aggregate consistency
✓ Keep bounded contexts independent
Conclusion#
DDD helps manage domain complexity by aligning code with business concepts. Start with bounded contexts and ubiquitous language. Use aggregates to enforce invariants and domain events for loose coupling. The investment pays off in maintainability and clarity.