Domain-Driven Design (DDD) is an approach to software development that puts the business domain at the center of design decisions. It's particularly valuable for complex business applications.
Core Concepts#
Ubiquitous Language#
The shared vocabulary between developers and domain experts.
❌ Without ubiquitous language:
Developer: "The UserAccount entity has a status field"
Expert: "You mean the customer's membership state?"
Developer: "Sure, whatever. It's in the database."
✅ With ubiquitous language:
Developer: "When a Member's subscription expires, their MembershipStatus becomes Inactive"
Expert: "Exactly, and they lose access to premium features"
Everyone uses the same terms:
- Member (not User, Customer, or Account)
- Subscription (not Plan or Package)
- MembershipStatus (not status or state)
Bounded Contexts#
Different parts of the system can have different models for the same concept.
E-commerce system:
┌─────────────────────┐ ┌─────────────────────┐
│ Sales Context │ │ Shipping Context │
│ │ │ │
│ Customer: │ │ Customer: │
│ - name │ │ - name │
│ - email │ │ - shippingAddress │
│ - paymentMethod │ │ - deliveryPrefs │
│ - orderHistory │ │ │
└─────────────────────┘ └─────────────────────┘
Same concept (Customer), different properties for different contexts.
Building Blocks#
Entities#
1// Entities have identity that persists across changes
2class Order {
3 constructor(
4 private readonly id: OrderId, // Identity
5 private customerId: CustomerId,
6 private items: OrderItem[],
7 private status: OrderStatus,
8 ) {}
9
10 // Two orders with same data but different IDs are different orders
11 equals(other: Order): boolean {
12 return this.id.equals(other.id);
13 }
14
15 // Behavior belongs to the entity
16 addItem(product: Product, quantity: number): void {
17 if (this.status !== OrderStatus.Draft) {
18 throw new Error('Cannot add items to submitted order');
19 }
20 this.items.push(new OrderItem(product.id, quantity, product.price));
21 }
22
23 submit(): void {
24 if (this.items.length === 0) {
25 throw new Error('Cannot submit empty order');
26 }
27 this.status = OrderStatus.Submitted;
28 }
29}Value Objects#
1// Value objects are defined by their attributes, not identity
2class Money {
3 constructor(
4 private readonly amount: number,
5 private readonly currency: string,
6 ) {
7 if (amount < 0) throw new Error('Amount cannot be negative');
8 }
9
10 // Equality by value
11 equals(other: Money): boolean {
12 return this.amount === other.amount && this.currency === other.currency;
13 }
14
15 // Immutable operations return new instances
16 add(other: Money): Money {
17 if (this.currency !== other.currency) {
18 throw new Error('Cannot add different currencies');
19 }
20 return new Money(this.amount + other.amount, this.currency);
21 }
22
23 multiply(factor: number): Money {
24 return new Money(this.amount * factor, this.currency);
25 }
26}
27
28class Address {
29 constructor(
30 private readonly street: string,
31 private readonly city: string,
32 private readonly zipCode: string,
33 private readonly country: string,
34 ) {
35 this.validate();
36 }
37
38 private validate(): void {
39 if (!this.street || !this.city || !this.zipCode || !this.country) {
40 throw new Error('All address fields are required');
41 }
42 }
43
44 equals(other: Address): boolean {
45 return (
46 this.street === other.street &&
47 this.city === other.city &&
48 this.zipCode === other.zipCode &&
49 this.country === other.country
50 );
51 }
52}Aggregates#
1// Aggregates are clusters of entities/values treated as a unit
2// The root entity controls access and enforces invariants
3
4class Order { // Aggregate Root
5 private readonly id: OrderId;
6 private items: OrderItem[] = [];
7 private status: OrderStatus = OrderStatus.Draft;
8
9 // All access goes through the root
10 addItem(productId: ProductId, quantity: number, price: Money): void {
11 this.ensureDraft();
12 const existing = this.items.find(i => i.productId.equals(productId));
13 if (existing) {
14 existing.increaseQuantity(quantity);
15 } else {
16 this.items.push(new OrderItem(productId, quantity, price));
17 }
18 }
19
20 removeItem(productId: ProductId): void {
21 this.ensureDraft();
22 this.items = this.items.filter(i => !i.productId.equals(productId));
23 }
24
25 // Invariants enforced by the aggregate
26 submit(): void {
27 if (this.items.length === 0) {
28 throw new Error('Order must have at least one item');
29 }
30 if (this.total().amount > 10000) {
31 throw new Error('Orders over $10,000 require approval');
32 }
33 this.status = OrderStatus.Submitted;
34 }
35
36 private ensureDraft(): void {
37 if (this.status !== OrderStatus.Draft) {
38 throw new Error('Order is not in draft status');
39 }
40 }
41
42 total(): Money {
43 return this.items.reduce(
44 (sum, item) => sum.add(item.subtotal()),
45 new Money(0, 'USD')
46 );
47 }
48}
49
50class OrderItem { // Entity within aggregate
51 constructor(
52 readonly productId: ProductId,
53 private quantity: number,
54 private unitPrice: Money,
55 ) {}
56
57 increaseQuantity(amount: number): void {
58 this.quantity += amount;
59 }
60
61 subtotal(): Money {
62 return this.unitPrice.multiply(this.quantity);
63 }
64}Domain Events#
1// Events represent something that happened in the domain
2interface DomainEvent {
3 occurredAt: Date;
4 aggregateId: string;
5}
6
7class OrderSubmitted implements DomainEvent {
8 readonly occurredAt = new Date();
9
10 constructor(
11 readonly aggregateId: string,
12 readonly customerId: string,
13 readonly orderTotal: Money,
14 readonly itemCount: number,
15 ) {}
16}
17
18class OrderCancelled implements DomainEvent {
19 readonly occurredAt = new Date();
20
21 constructor(
22 readonly aggregateId: string,
23 readonly reason: string,
24 ) {}
25}
26
27// Aggregate raises events
28class Order {
29 private events: DomainEvent[] = [];
30
31 submit(): void {
32 // ... validation ...
33 this.status = OrderStatus.Submitted;
34 this.events.push(new OrderSubmitted(
35 this.id.value,
36 this.customerId.value,
37 this.total(),
38 this.items.length,
39 ));
40 }
41
42 pullEvents(): DomainEvent[] {
43 const events = [...this.events];
44 this.events = [];
45 return events;
46 }
47}
48
49// Event handlers react to events
50class OrderSubmittedHandler {
51 async handle(event: OrderSubmitted): Promise<void> {
52 await this.emailService.sendOrderConfirmation(event.aggregateId);
53 await this.inventoryService.reserveItems(event.aggregateId);
54 await this.analyticsService.trackOrder(event);
55 }
56}Repositories#
1// Repositories abstract persistence
2interface OrderRepository {
3 findById(id: OrderId): Promise<Order | null>;
4 save(order: Order): Promise<void>;
5 nextId(): OrderId;
6}
7
8class PostgresOrderRepository implements OrderRepository {
9 async findById(id: OrderId): Promise<Order | null> {
10 const row = await this.db.query(
11 'SELECT * FROM orders WHERE id = $1',
12 [id.value]
13 );
14 if (!row) return null;
15 return this.hydrate(row);
16 }
17
18 async save(order: Order): Promise<void> {
19 // Save aggregate
20 await this.db.query(
21 'INSERT INTO orders (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ...',
22 this.dehydrate(order)
23 );
24
25 // Publish events
26 const events = order.pullEvents();
27 for (const event of events) {
28 await this.eventBus.publish(event);
29 }
30 }
31}Domain Services#
1// Domain services contain logic that doesn't belong to a single entity
2class PricingService {
3 calculateOrderPrice(
4 items: OrderItem[],
5 customer: Customer,
6 coupon?: Coupon,
7 ): OrderPrice {
8 let subtotal = items.reduce(
9 (sum, item) => sum.add(item.subtotal()),
10 Money.zero()
11 );
12
13 // Apply customer tier discount
14 const tierDiscount = this.getTierDiscount(customer.tier);
15 subtotal = subtotal.multiply(1 - tierDiscount);
16
17 // Apply coupon
18 if (coupon && coupon.isValid()) {
19 subtotal = coupon.apply(subtotal);
20 }
21
22 // Calculate tax
23 const tax = this.taxCalculator.calculate(subtotal, customer.address);
24
25 return new OrderPrice(subtotal, tax);
26 }
27}
28
29class TransferService {
30 transfer(from: Account, to: Account, amount: Money): void {
31 // Logic spans multiple aggregates
32 if (!from.canWithdraw(amount)) {
33 throw new InsufficientFundsError();
34 }
35
36 from.withdraw(amount);
37 to.deposit(amount);
38 }
39}Application Layer#
1// Application services orchestrate domain objects
2class SubmitOrderUseCase {
3 constructor(
4 private orderRepository: OrderRepository,
5 private customerRepository: CustomerRepository,
6 private pricingService: PricingService,
7 ) {}
8
9 async execute(command: SubmitOrderCommand): Promise<OrderId> {
10 // Load aggregates
11 const order = await this.orderRepository.findById(command.orderId);
12 if (!order) throw new OrderNotFoundError();
13
14 const customer = await this.customerRepository.findById(order.customerId);
15 if (!customer) throw new CustomerNotFoundError();
16
17 // Use domain service
18 const price = this.pricingService.calculateOrderPrice(
19 order.items,
20 customer,
21 );
22
23 // Execute domain logic
24 order.submit(price);
25
26 // Persist
27 await this.orderRepository.save(order);
28
29 return order.id;
30 }
31}Strategic Design#
Context Mapping#
Relationships between bounded contexts:
┌──────────────┐ ┌──────────────┐
│ Sales │─────▶│ Shipping │
│ (upstream) │ │ (downstream) │
└──────────────┘ └──────────────┘
Customer/ Conformist
Supplier
┌──────────────┐ ┌──────────────┐
│ Catalog │◀ ─ ─▶│ Search │
└──────────────┘ └──────────────┘
Shared Kernel
┌──────────────┐ ┌──────────────┐
│ Orders │─ ─ ─▶│ Legacy │
└──────────────┘ │ System │
Anti-corruption └──────────────┘
Layer
Anti-Corruption Layer#
1// Translate between contexts/systems
2class LegacyPaymentAdapter {
3 constructor(private legacyClient: LegacyPaymentClient) {}
4
5 async processPayment(payment: Payment): Promise<PaymentResult> {
6 // Translate to legacy format
7 const legacyRequest = {
8 amt: payment.amount.cents,
9 curr: payment.amount.currency.toLowerCase(),
10 card_num: payment.card.number,
11 exp: `${payment.card.expMonth}/${payment.card.expYear}`,
12 };
13
14 // Call legacy system
15 const legacyResponse = await this.legacyClient.charge(legacyRequest);
16
17 // Translate back to domain
18 return new PaymentResult(
19 legacyResponse.status === 'OK' ? PaymentStatus.Succeeded : PaymentStatus.Failed,
20 legacyResponse.transaction_id,
21 );
22 }
23}Conclusion#
DDD is about aligning software with business reality. Start with ubiquitous language and bounded contexts. Use tactical patterns (entities, value objects, aggregates) to model complex domains.
DDD adds complexity—use it where the domain is genuinely complex. For simple CRUD applications, simpler approaches work better.