Back to Blog
DDDArchitectureDomain ModelingDesign

Domain-Driven Design Basics for Developers

Apply DDD to build better software. From bounded contexts to aggregates to domain events.

B
Bootspring Team
Engineering
February 28, 2023
7 min read

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.

Share this article

Help spread the word about Bootspring