Domain-Driven Design (DDD)
Overview
Domain-Driven Design is an approach to software development that centers the architecture and codebase around the business domain. Created by Eric Evans in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software, DDD provides patterns for modeling complex systems so that the code mirrors the language and structure of the business it serves.
DDD is not a framework or a technology choice. It is a set of principles for how to think about software structure when the primary source of complexity is the business domain itself, not the infrastructure.
When DDD Applies (and When It Does Not)
DDD is valuable when:
- The domain is complex and requires deep understanding (insurance underwriting, supply chain logistics, financial trading)
- Business rules are the primary source of complexity
- You have access to domain experts who can collaborate with developers
- The system will evolve over years and the domain model needs to stay aligned with the business
DDD is overkill when:
- The application is primarily CRUD with thin business logic
- The domain is well-understood and stable (a blog, a static site generator)
- The team is small and the entire domain fits in one person's head
Bounded Contexts
Concept
A bounded context is the most important strategic pattern in DDD. It defines an explicit boundary within which a domain model exists. Inside the boundary, every term has a precise, unambiguous meaning. Outside the boundary, the same term might mean something completely different.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Sales Context │ │ Shipping Context│ │ Billing Context│
│ │ │ │ │ │
│ Customer: │ │ Customer: │ │ Customer: │
│ - name │ │ - address │ │ - payment method│
│ - preferences │ │ - delivery zone │ │ - credit limit │
│ - purchase hist │ │ - contact phone │ │ - billing addr │
└─────────────────┘ └─────────────────┘ └─────────────────┘
"Customer" means something different in each context. In Sales, a customer has preferences and purchase history. In Shipping, a customer has an address and delivery zone. In Billing, a customer has payment methods and credit limits.
The God Object Anti-Pattern
Without bounded contexts, teams inevitably create one massive Customer struct/class that tries to serve every part of the system:
// The God Object -- DO NOT DO THIS
STRUCTURE Customer:
id ← CustomerId
name ← string
email ← string
preferences ← list of Preference // Sales needs this
purchase_history ← list of Purchase // Sales needs this
shipping_address ← Address // Shipping needs this
delivery_zone ← DeliveryZone // Shipping needs this
contact_phone ← string // Shipping needs this
payment_method ← PaymentMethod // Billing needs this
credit_limit ← Money // Billing needs this
billing_address ← Address // Billing needs this
tax_id ← optional string // Billing needs this
loyalty_points ← integer // Marketing needs this
segment ← CustomerSegment // Marketing needs this
This object grows to dozens of fields. Every change risks breaking unrelated features. Loading a Customer for shipping pulls in billing data it does not need. The struct becomes a coordination bottleneck across teams.
Correct Approach: Separate Models Per Context
// Sales context
MODULE sales:
STRUCTURE Customer:
id ← CustomerId
name ← string
preferences ← list of Preference
purchase_history ← list of PurchaseId
// Shipping context
MODULE shipping:
STRUCTURE Recipient:
customer_id ← CustomerId // Reference to sales context
address ← Address
delivery_zone ← DeliveryZone
contact_phone ← string
// Billing context
MODULE billing:
STRUCTURE BillingAccount:
customer_id ← CustomerId // Reference to sales context
payment_method ← PaymentMethod
credit_limit ← Money
billing_address ← Address
Each context has its own model with only the data it needs. They share a CustomerId to correlate across contexts, but each model is independently owned and evolved.
Context Mapping
Bounded contexts do not exist in isolation. They interact. DDD defines several relationship patterns:
- Shared Kernel: Two contexts share a small, jointly-owned model. Both teams must agree on changes. Use sparingly.
- Customer-Supplier: One context (supplier) provides data to another (customer). The supplier team may or may not accommodate the customer's needs.
- Anti-Corruption Layer (ACL): A translation layer that prevents one context's model from leaking into another. Critical when integrating with legacy systems or third-party APIs.
- Published Language: A well-documented, shared language (often a schema or API contract) that contexts use to communicate.
Real-World Example: Anti-Corruption Layer
// External payment provider returns this
STRUCTURE StripeCharge:
id ← string
amount ← integer // Stripe uses cents
currency ← string // Stripe uses ISO strings
status ← string // "succeeded", "failed", etc.
// Our domain model
STRUCTURE Payment:
id ← PaymentId
amount ← Money
status ← PaymentStatus
// Anti-corruption layer: translates Stripe's model into ours
PROCEDURE CONVERT_STRIPE_CHARGE_TO_PAYMENT(charge):
id ← PaymentId.FROM_EXTERNAL(charge.id)
amount ← Money.FROM_CENTS(charge.amount, PARSE_CURRENCY(charge.currency))
MATCH charge.status:
"succeeded" → status ← Completed
"failed" → status ← Failed
"pending" → status ← Processing
other → RETURN Error(UnknownPaymentStatus, other)
RETURN Payment { id, amount, status }
The ACL ensures that if Stripe changes their API (renames fields, changes status strings), only the translation layer changes. The domain model remains stable.
Aggregates
Concept
An aggregate is a cluster of domain objects that are treated as a single unit for the purpose of data changes. Every aggregate has a root entity (the aggregate root), and all external access to the aggregate goes through the root.
The aggregate root enforces invariants: business rules that must always be true. For example, "an order cannot have a negative total" or "an order with status Shipped cannot be modified."
Rules for Aggregates
- External references only to the root: Other parts of the system hold references to the aggregate root (by ID), never to internal entities.
- Transactional boundary: An aggregate is saved/loaded as a unit. One database transaction per aggregate.
- Consistency within, eventual consistency between: Within an aggregate, all invariants are immediately enforced. Between aggregates, eventual consistency is acceptable.
- Keep them small: Large aggregates create contention (multiple users trying to modify the same aggregate simultaneously). Prefer smaller aggregates with references between them.
Rust Example
STRUCTURE Order:
id ← OrderId
customer_id ← CustomerId
items ← list of OrderItem
status ← OrderStatus
total ← Money
created_at ← DateTime
// OrderItem is internal to the aggregate -- no external access
STRUCTURE OrderItem:
product_id ← ProductId
quantity ← integer
unit_price ← Money
line_total ← Money
PROCEDURE NEW_ORDER(customer_id):
RETURN Order {
id ← GENERATE_ORDER_ID(),
customer_id ← customer_id,
items ← empty list,
status ← Draft,
total ← Money.ZERO(USD),
created_at ← NOW()
}
// All mutations go through the aggregate root
PROCEDURE ADD_ITEM(order, product_id, quantity, unit_price):
// Invariant: cannot modify submitted orders
IF order.status ≠ Draft THEN
RETURN Error(CannotModifySubmittedOrder)
// Invariant: quantity must be positive
IF quantity = 0 THEN
RETURN Error(InvalidQuantity)
line_total ← unit_price * quantity
APPEND TO order.items: OrderItem { product_id, quantity, unit_price, line_total }
RECALCULATE_TOTAL(order)
RETURN Ok
PROCEDURE SUBMIT(order):
// Invariant: cannot submit empty order
IF order.items IS empty THEN
RETURN Error(EmptyOrder)
// Invariant: cannot submit twice
IF order.status ≠ Draft THEN
RETURN Error(AlreadySubmitted)
order.status ← Submitted
// Return domain events for other contexts to react to
RETURN [OrderPlaced { order_id, customer_id, total }]
PROCEDURE RECALCULATE_TOTAL(order):
order.total ← SUM OF item.line_total FOR EACH item IN order.items
Common Mistake: Over-Sized Aggregates
A frequent error is making aggregates too large. For example, putting all OrderItem inventory checks inside the Order aggregate. This means modifying any item locks the entire order for the transaction.
Instead, keep aggregates small and reference other aggregates by ID:
// WRONG: Product is part of Order aggregate
STRUCTURE Order:
items ← list of Product // Product is a full entity with its own lifecycle
// RIGHT: Order references products by ID
STRUCTURE Order:
items ← list of OrderItem // OrderItem holds ProductId, not Product
Entities vs. Value Objects
Entities
Entities have identity. Two entities with identical field values but different IDs are different objects. An entity's identity persists across time and state changes.
STRUCTURE User:
id ← UserId // Identity -- this is what makes it unique
name ← string
email ← string
// These are DIFFERENT users, even with the same name and email
alice1 ← User { id ← UserId(1), name ← "Alice", email ← "alice@test.com" }
alice2 ← User { id ← UserId(2), name ← "Alice", email ← "alice@test.com" }
ASSERT alice1.id ≠ alice2.id // Different entities
Value Objects
Value objects have no identity. They are defined entirely by their attributes. Two value objects with the same attributes are interchangeable.
STRUCTURE Money (value object, immutable, comparable by value):
amount_cents ← integer
currency ← Currency
PROCEDURE NEW_MONEY(amount_cents, currency):
// Value objects validate on creation
IF amount_cents < 0 THEN
RETURN Error(NegativeAmount)
RETURN Money { amount_cents, currency }
// Value objects are immutable -- operations return new instances
PROCEDURE ADD(self, other):
IF self.currency ≠ other.currency THEN
RETURN Error(CurrencyMismatch)
RETURN NEW_MONEY(self.amount_cents + other.amount_cents, self.currency)
Design Heuristics
- If you need to track the thing over time and distinguish between instances, it is an entity.
- If you care only about the attributes and could swap one instance for another with the same values, it is a value object.
- Prefer value objects when possible. They are simpler, immutable, and eliminate a class of bugs (identity confusion, aliasing problems).
- Common value objects: Money, EmailAddress, PhoneNumber, DateRange, Coordinates, Color.
- Common entities: User, Order, Product, Account, Invoice.
Domain Events
Concept
Domain events represent something significant that happened in the domain. They are named in past tense and describe facts that cannot be changed.
ENUMERATION OrderEvent:
OrderPlaced:
order_id ← OrderId
customer_id ← CustomerId
total ← Money
placed_at ← DateTime
OrderShipped:
order_id ← OrderId
tracking_number ← string
shipped_at ← DateTime
OrderCancelled:
order_id ← OrderId
reason ← CancellationReason
cancelled_at ← DateTime
Why Domain Events Matter
Domain events decouple parts of the system. When an order is placed:
- The Inventory context reacts by reserving stock
- The Email context sends a confirmation
- The Analytics context records the sale
- The Loyalty context awards points
The Order context knows none of this. It publishes an event and moves on. New reactions can be added without modifying the Order context.
Implementation Pattern in Rust
// Aggregate collects events during operations
PROCEDURE SUBMIT(order):
// ... validation ...
order.status ← Submitted
RETURN [OrderPlaced {
order_id ← order.id,
customer_id ← order.customer_id,
total ← order.total,
placed_at ← NOW()
}]
// Application service publishes events after persisting
PROCEDURE SUBMIT_ORDER(service, order_id):
order ← AWAIT service.repo.FIND_BY_ID(order_id)
IF order IS NOT found THEN RETURN Error(NotFound)
events ← SUBMIT(order)
IF events IS error THEN RETURN error
AWAIT service.repo.SAVE(order)
// Publish after successful persistence
FOR EACH event IN events:
AWAIT service.event_publisher.PUBLISH(event)
RETURN Ok
Pitfalls with Domain Events
- Dual-write problem: Saving to the database and publishing to a message broker are two separate operations. If the publish fails after the save, the event is lost. Solutions include the Outbox pattern (save events to a database table, then relay them) or event sourcing.
- Event ordering: In distributed systems, events may arrive out of order. Consumers must handle this gracefully.
- Event versioning: As the domain evolves, event schemas change. Old events in the log need to remain readable. Use schema evolution strategies (adding optional fields, never removing fields).
Real-World DDD Adoption
Nubank (Brazilian fintech)
Nubank built Latin America's largest digital bank using DDD with Clojure. Their bounded contexts map to financial products: credit cards, savings accounts, personal loans, insurance. Each context has its own data store and domain model. Cross-context communication happens through events. This architecture allowed them to scale from one product (credit cards) to dozens while keeping teams autonomous.
Shopify
Shopify's modular monolith is organized around bounded contexts: Orders, Products, Inventory, Payments, Shipping. Each context has clear ownership and interfaces. When a developer changes the Inventory context, they know the boundary and can verify they have not leaked implementation details to other contexts.
UK Government (HMRC)
Her Majesty's Revenue and Customs rebuilt their tax platform using DDD. Tax domains (income tax, VAT, corporate tax) became bounded contexts. The ubiquitous language of each tax domain was developed with tax policy experts, ensuring the code used the same terminology as the legislation it implemented.
Further Reading
- Domain-Driven Design -- Eric Evans (2003) -- The foundational text
- Implementing Domain-Driven Design -- Vaughn Vernon (2013) -- Practical patterns and examples
- Domain Modeling Made Functional -- Scott Wlaschin (2018) -- DDD with functional programming (many concepts transfer to Rust)
- Learning Domain-Driven Design -- Vlad Khononov (2021) -- A modern, accessible introduction