Domain-Driven Design (DDD): Taming Complex Systems with Bounded Contexts and Aggregates

Domain-Driven Design (DDD): Taming Complex Systems with Bounded Contexts and Aggregates
Table of Contents
- The Domain Is Not Your Database
- Strategic DDD: The Big Picture View
- Ubiquitous Language: One Term, One Meaning, Per Context
- Bounded Contexts: The Most Important Concept in DDD
- Context Maps: How Bounded Contexts Relate
- Event Storming: Discovering the Domain Model
- Tactical DDD: Inside the Bounded Context
- Aggregates: Consistency Boundaries
- Entities vs Value Objects
- Domain Events: Cross-Context Communication
- DDD → Microservice Boundary Mapping
- Frequently Asked Questions
- Key Takeaway
The Domain Is Not Your Database
The most common mistake in software design is modeling the code around the database schema rather than the business domain. When a developer asks "What tables do we need?", they're beginning from infrastructure. When a domain expert asks "What happens when a customer places an order?", they're beginning from the domain.
DDD insists: understand the business first, model it in code, then decide how to persist it.
Database-First thinking: Domain-First thinking (DDD):
├─ tables/ ├─ orders/
│ ├─ users │ ├─ Order.java (Aggregate)
│ ├─ orders │ ├─ OrderItem.java (Entity)
│ ├─ order_items │ ├─ Money.java (Value Object)
│ ├─ products │ ├─ OrderPlaced.java (Domain Event)
│ └─ inventory │ └─ OrderRepository.java (Repository)The domain-first model immediately communicates business intent — Order.place(), Order.cancel(), Order.ship(). The database model communicates storage details.
Strategic DDD: The Big Picture View
DDD at the strategic level answers: how do we divide a large, complex domain into manageable pieces, and how do those pieces relate?
Strategic DDD tools:
- Ubiquitous Language: A shared vocabulary between developers and domain experts
- Bounded Contexts: Explicit boundaries around coherent sub-domains
- Context Maps: Diagrams of how bounded contexts interact
Ubiquitous Language: One Term, One Meaning, Per Context
The Ubiquitous Language is a rigorous, shared vocabulary between domain experts and developers — used in conversations, documentation, and code identically:
⌠What often happens:
Business Expert says: "We need to process a fulfillment"
Developer understands: "I'll add a dispatch() method to OrderProcessor"
Code becomes: OrderProcessor → processDispatch() → FulfillmentHandler
The developer used 3 different terms for the same concept.
The business expert reads the code and doesn't recognize their own domain.
✅ Ubiquitous Language:
Both parties agree: the term is "Fulfillment"
Code becomes: Fulfillment → submit() → FulfillmentRepository.save()
A domain expert can read this code and recognize the business process.Practical implementation:
- Build a glossary document with domain experts defining every term
- If a developer introduces a new code term not in the glossary, it must match a corresponding business concept or be added to the glossary
- Rename classes/methods when business terminology changes — the code must stay in sync
Bounded Contexts: The Most Important Concept in DDD
A Bounded Context is an explicit boundary within which a specific domain model applies. The same word means different things in different contexts:
| Concept | Sales Context | Billing Context | Shipping Context |
|---|---|---|---|
| "Customer" | A potential buyer (Lead → Customer) | A billing account with payment methods | A delivery address + contact name |
| "Product" | Something with a price and description | A line item with a SKU and unit price | A physical item with weight and dimensions |
| "Order" | An intent to purchase | A billable invoice with line items | A fulfillment task with a shipping label |
Each context has its own model of "Customer" — they share only a customerId key, not the full object. Context A never directly imports the domain classes from Context B.
Context Maps: How Bounded Contexts Relate
A Context Map documents the relationships between bounded contexts:
| Relationship | Description | Example |
|---|---|---|
| Conformist | Downstream adopts upstream's model completely | Order service adopts Payment service's contract |
| Customer-Supplier | Upstream serves downstream's needs | Billing serves Sales's invoice needs |
| Partnership | Both contexts evolve together with mutual agreement | Order and Inventory maintain joint API |
| Shared Kernel | Two contexts share a subset of the domain model | Shared Money value object |
| Anti-Corruption Layer (ACL) | Translate between incompatible models | Legacy CRM → modern Customer model via ACL |
| Open Host Service | Publish a well-defined API for multiple consumers | Product catalogue serves 10 downstream services |
Event Storming: Discovering the Domain Model
Event Storming (Alberto Brandolini) is a collaborative workshop for discovering a domain model. Developers and domain experts stand at a large whiteboard with colored sticky notes:
Orange: Domain Events ("Order Placed", "Payment Failed")
Blue: Commands ("Place Order", "Cancel Order")
Yellow: Aggregates ("Order", "Cart", "Customer")
Pink: External Systems ("Payment Gateway", "Shipping API")
Purple: Policies ("When Payment Fails → Cancel Order")Workshop flow:
- Domain experts write all Domain Events (things that happen in the business)
- Developers add Commands that cause each event
- Identify Aggregates that handle each command
- Find Policies (business rules connecting events to commands)
- Mark External System boundaries
After 4-6 hours, the team has a visual model of the entire domain — discovering bounded context boundaries, aggregate boundaries, and cross-context integrations.
Tactical DDD: Inside the Bounded Context
Tactical DDD provides the building blocks for implementing a bounded context.
Aggregates: Consistency Boundaries
An Aggregate is a cluster of domain objects treated as a single unit for data changes. Every change within an aggregate is atomic:
public class Order { // Aggregate Root — controls ALL access to the aggregate
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines; // Entity within aggregate
private OrderStatus status;
// Commands (intent to change):
public void addItem(Product product, int quantity) {
if (status != OrderStatus.DRAFT)
throw new OrderNotModifiableException();
lines.add(new OrderLine(product.id(), product.price(), quantity));
}
public void place() {
if (lines.isEmpty()) throw new EmptyOrderException();
this.status = OrderStatus.PLACED;
// Raise a domain event for cross-context communication:
registerDomainEvent(new OrderPlaced(id, customerId, lines, totalAmount()));
}
// Queries (no side effects):
public Money totalAmount() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.ZERO, Money::add);
}
}Aggregate design rules:
- Reference other aggregates by ID only — never by object reference
- Enforce all invariants (business rules) within the aggregate boundary
- Keep aggregates small — one that takes 30 seconds to load has too many children
- Never modify two aggregates in one transaction — use Domain Events for cross-aggregate coordination
Entities vs Value Objects
| Aspect | Entity | Value Object |
|---|---|---|
| Identity | Has a unique ID (orderId, userId) | Defined by its attribute values |
| Mutability | Mutable — state changes over time | Immutable — "changed" by replacing |
| Equality | Same ID = same entity | Same values = same value object |
| Examples | Order, Customer, Product | Money, Address, DateRange, EmailAddress |
// Entity: identity is its ID
public class Order {
private final OrderId id; // defines identity
private Money totalAmount; // changes over lifetime
@Override
public boolean equals(Object o) {
// Two orders are equal if they have the same ID:
return o instanceof Order other && this.id.equals(other.id);
}
}
// Value Object: identity comes from values — IMMUTABLE
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) throw new NegativeAmountException();
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) throw new CurrencyMismatchException();
return new Money(this.amount.add(other.amount), this.currency); // New instance!
}
// Records provide equals/hashCode based on values automatically
}Domain Events: Cross-Context Communication
When one bounded context needs to react to something that happened in another, use Domain Events — not direct calls to the other context's repositories or services:
// OrderPlaced domain event (raised by Order aggregate):
public record OrderPlaced(
OrderId orderId,
CustomerId customerId,
List<OrderLine> lines,
Money totalAmount,
Instant occurredAt
) implements DomainEvent {}
// Billing context subscribes — builds its invoice without coupling to Order internals:
@DomainEventHandler
public class BillingOrderHandler {
public void on(OrderPlaced event) {
Invoice invoice = Invoice.createFrom(
event.orderId(),
event.customerId(),
event.lines(),
event.totalAmount()
);
invoiceRepository.save(invoice);
}
}
// Shipping context subscribes independently:
@DomainEventHandler
public class ShippingOrderHandler {
public void on(OrderPlaced event) {
ShipmentRequest.createFor(event.orderId(), event.customerId())
.withItems(event.lines())
.save();
}
}Frequently Asked Questions
Is DDD too complex for small teams? Strategic DDD (Ubiquitous Language, identifying bounded contexts) pays off at almost any team size — it improves communication and prevents the most common form of long-term maintainability collapse. Tactical DDD (Aggregates, Value Objects, Domain Events pattern) adds code ceremony that may not be justified for a 3-person team building a simple CRUD app. Apply tactical DDD where the domain is genuinely complex and the business rules are the primary investment.
How many microservices should map to one bounded context? Ideally, one microservice per bounded context. This is the strongest alignment — one team, one bounded context, one deployable service. The anti-pattern is splitting a single bounded context across multiple services (creating a distributed monolith) or merging multiple bounded contexts into one service (recreating a big ball of mud at a service level).
Key Takeaway
DDD is the intellectual foundation that makes complex software stay understandable over time. Bounded Contexts are the answer to "the User object has 200 fields because everyone's feature is adding to it." Aggregates are the answer to "we have inconsistent data because anyone can update any table." Domain Events are the answer to "we can't add a new feature without touching half the codebase." Together, these patterns give a large team the vocabulary and structure to build complex software that remains maintainable for decades.
Read next: Monolith vs. Microservices in 2026 →
Part of the Software Architecture Hub — comprehensive guides from architectural foundations to advanced distributed systems patterns.
