Back to Blog
DDDDomain-Driven DesignArchitectureBest Practices

Domain-Driven Design: An Introduction for Developers

Build software that models your business domain. From ubiquitous language to aggregates to bounded contexts.

B
Bootspring Team
Engineering
January 2, 2025
7 min read

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.

Share this article

Help spread the word about Bootspring