Domain-Driven Design: Bounded Contexts and Aggregate Rules
In most projects DDD is reduced to naming entities and repositories; its real value is strategic design. How to draw bounded contexts in an enterprise system where 'customer' means four different things, the aggregate rules, value objects and domain events, with Spring Boot examples.
In most enterprise projects, "we apply DDD" means giving classes the suffixes Entity, Repository and Service. The real value of Domain-Driven Design starts long before that: noticing that in the same company the word "customer" means four different things to sales, accounting, logistics and support, and splitting the software along those boundaries. Tactical DDD without that strategic decision ends in elaborate naming.
This article covers strategic design (ubiquitous language, bounded contexts, the context map), aggregate design rules, value objects and domain events with Spring Boot examples, then the common mistakes and when DDD isn't needed.
Strategic design: boundaries first
Ubiquitous language: one glossary, everywhere
If the business says "shipment", the developer says "dispatch" and the database says "delivery", there are three models and every meeting runs through translation. DDD's first rule is one glossary from class names to meeting language. If a concept has two words, either one is wrong or there are two concepts — the second case is usually a sign of a boundary.
Bounded context: what does "customer" mean where?
Sales context Customer = prospect/active buyer; credit limit, price list, sales rep
Accounting context Customer = ledger account; tax id, open balance, payment terms, collection history
Logistics context Customer = delivery point; address, opening hours, vehicle constraints
Support context Customer = contract holder; SLA, open tickets, contact preferencesMerging the four contexts into one Customer class produces an eighty-field entity that everyone changes and nobody understands. Each context keeps its own Customer model; the only shared thing is the identity (CustomerId). A bounded context is the boundary within which a model is valid, and it materializes in the codebase as a module or a service.
The context map: relationships across boundaries
Relationship | Meaning | Example |
|---|---|---|
Upstream / downstream | One publishes its model, the other consumes it | Sales (upstream) publishes the order event; accounting (downstream) consumes it |
Published language | The upstream's stable, documented contract | The schema of the |
Anti-corruption layer | The downstream translates the upstream model into its own language | The ERP's |
Conformist | The downstream accepts the upstream model as is | A small reporting module using the sales model unchanged |
Shared kernel | Two contexts share a small model | Common value objects such as |
In systems written without a context map, every context becomes a "shared kernel": everyone uses every table. The map's purpose is to make visible which dependency is deliberate.
Tactical design: aggregate rules
An aggregate groups the objects that must stay consistent together under one root; the outside reaches only the root. Four rules decide the design in practice:
Protect real invariants. Rules like "the order total equals the sum of its lines" and "an approved order cannot be changed" live inside the aggregate; the outside cannot break them.
Keep it small. An aggregate is what gets loaded and written in one transaction; customer + all orders + all invoices cannot be one aggregate.
Reference other aggregates by id.
Orderholds aCustomerIdvalue, not aCustomerobject.One transaction, one aggregate. If two aggregates must change at once, either the boundary is wrong or the second change happens later through a domain event (eventual consistency).
public class Order { // aggregate root
private final OrderNumber number;
private final CustomerId customer; // rule 3: reference by id
private final List<OrderLine> lines = new ArrayList<>();
private Status status = Status.DRAFT;
private final List<Object> events = new ArrayList<>();
public void addLine(ProductCode product, Quantity quantity, Money unitPrice) {
if (status != Status.DRAFT) throw new OrderNotModifiable(number); // rule 1: invariant
lines.stream().filter(l -> l.product().equals(product)).findFirst()
.ifPresentOrElse(l -> l.increase(quantity), () -> lines.add(new OrderLine(product, quantity, unitPrice)));
}
public void approve(CreditDecision credit) {
if (lines.isEmpty()) throw new EmptyOrderCannotBeApproved(number);
if (!credit.allows(total())) throw new CreditLimitExceeded(number, total());
status = Status.APPROVED;
events.add(new OrderApproved(number, customer, total())); // for other contexts
}
public Money total() { return lines.stream().map(OrderLine::amount).reduce(Money.ZERO, Money::plus); }
public List<Object> pullEvents() { var e = List.copyOf(events); events.clear(); return e; }
}OrderLine is an entity, but it isn't reachable from outside the aggregate; the repository exists only for Order. The credit decision is handed to the aggregate from outside as a value — the aggregate doesn't query the customer context's database.
Value objects: escaping primitive types
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.scale() > 2) throw new IllegalArgumentException("cent precision");
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new CurrencyMismatch(currency, other.currency);
return new Money(amount.add(other.amount), currency);
}
}
public record OrderNumber(String value) { public OrderNumber { Objects.requireNonNull(value); } }Instead of BigDecimal total and String orderNumber, value objects put the rule inside the type: two currencies can't be added, an order number can't be null. The compiler prevents the wrong value from reaching the wrong place, and tests focus on the boundary rather than the rule.
Domain events: the bridge between contexts
The OrderApproved event is what the sales context tells the world; accounting derives the ledger entry and logistics the shipment plan from it. The event is the upstream's published language: its name comes from the business vocabulary, it is in the past tense and carries only the necessary data. To publish it reliably, use the outbox pattern or Spring Modulith's publication registry.
Common mistakes
The god aggregate:
List<Order>insideCustomer,List<Invoice>inside the order. Loading one customer brings a thousand rows; two users touching the same customer collide on locks. Fix: reference by id, separate aggregates.The anemic model: Entities are only getters/setters, rules live in services. The invariant the aggregate should protect gets checked separately (and inconsistently) by every service.
A repository for every entity: If
OrderLineRepositoryexists, lines can be changed from outside the aggregate; repositories only for roots.Piercing the context boundary with JPA relationships: An entity in the sales module with a
@ManyToOneto an accounting entity. The boundary stays on paper.DDD everywhere: Writing aggregates and value objects for parameter tables, definitions and simple CRUD screens is waste.
From bounded context to code
Contexts materialize in the codebase as modules: Spring Modulith modules in a modular monolith, separate services in a distributed system. The module boundary is the bounded-context boundary; inside the module, hexagonal architecture protects the dependency direction; toward external models such as an ERP, an anti-corruption layer is built. The three are layers of a single architectural discipline.
When DDD isn't needed
DDD's cost is modelling time and more classes; pay it only for the core domain: the process that differentiates the company (a pricing engine, order-to-production planning, risk assessment). Supporting subdomains (reporting, notifications) are solved with a simple layered structure, generic subdomains (authentication, e-invoicing, accounting) with off-the-shelf components. Giving every module the same care without classifying the subdomain spends time meant for the core on the periphery.
Where to start: event storming
Discover bounded contexts and aggregates not at a desk but in an event storming workshop with the business: domain events (orange), commands (blue), actors and aggregates (yellow) go on a wall in time order; where events cluster are context candidates, where the same word changes meaning are boundaries. A half-day workshop prevents months of a wrong model.
Frequently asked questions
Can the aggregate and the JPA entity be the same class?
In simple contexts, yes, as long as the aggregate rules are followed (reference by id, access through the root). In a core domain with rich rules, separating the domain model from the persistence model frees the aggregate from JPA constraints.
Is eventual consistency acceptable for the business?
In most business processes it already is: the invoice appears a few seconds after the order is approved, nobody waits for the same millisecond. Where it isn't acceptable (payment and order status), the two concepts belong to the same aggregate.
Can a small team apply DDD?
Yes; strategic design is independent of team size and cheaper in a small team. Applying the tactical patterns only to the core domain fits a small team's budget.
Conclusion
DDD is about boundaries, not class suffixes: writing down what each word means where, splitting the model along those boundaries, letting every aggregate protect its own invariant and connecting contexts through events. Applied to the core domain, this discipline lets the software change as fast as the business does.
To map your enterprise system's bounded contexts in an event storming workshop, see our software design and architecture service.