The problem: a model where anything can change anything
An Order entity has public setters and a list of OrderLine objects. A pricing service adds lines, a discount job edits totals, a support tool changes status. After a month, orders exist with a total that does not match their lines, a shipped order that is also cancelled, and a negative quantity. No single place enforces the rules, so every caller must remember them, and eventually one forgets.
Domain-driven design (DDD) gives vocabulary and structure for this: a shared language, contexts with clear borders, and aggregates that guard consistency. This article focuses on the tactical heart, the aggregate, while placing it inside the strategic picture.
Ubiquitous language and bounded contexts
The ubiquitous language is the vocabulary that developers and domain experts share, and it appears in code: if the business says "an order is confirmed", the code has order.confirm(), not setStatus(2). Terms that mean different things to different people are a signal.
A bounded context is the area where one model and one language apply consistently. "Product" in a catalogue context has descriptions and images; in an inventory context it is a SKU with quantity; in billing it is a line item with a tax code. These are separate models that happen to share a word. Forcing one Product class everywhere creates a bloated type that every team fears to change. Contexts talk through explicit translation at their borders (an API, an event, an anti-corruption layer). Bounded contexts are the natural candidates for service boundaries, as discussed in the microservices boundaries article.
What an aggregate is
An aggregate is a cluster of objects treated as one unit for changes and consistency. It has:
- an aggregate root, the only entry point; outside code holds references to the root only;
- invariants, rules that must always be true inside the boundary (for instance "an order total equals the sum of its lines" and "a confirmed order cannot be empty");
- a transactional boundary: one aggregate is loaded, changed and saved in one transaction.
Order (root)
├── OrderLine (qty > 0, price >= 0)
├── OrderLine
└── ShippingAddress (value object)
invariants: total = sum(lines); at least 1 line to confirm;
no changes after SHIPPEDProtecting invariants in code
The root exposes behaviour, not setters, and refuses invalid transitions:
public class Order { private final OrderId id; private final List<OrderLine> lines = new ArrayList<>(); private OrderStatus status = OrderStatus.DRAFT; public void addLine(ProductId product, int qty, Money price) { requireDraft(); if (qty <= 0) throw new DomainException("qty must be positive"); lines.add(new OrderLine(product, qty, price)); } public void confirm() { requireDraft(); if (lines.isEmpty()) throw new DomainException("empty order"); status = OrderStatus.CONFIRMED; events.add(new OrderConfirmed(id, total())); } public Money total() { return lines.stream().map(OrderLine::subtotal) .reduce(Money.ZERO, Money::plus); } public List<OrderLine> lines() { return List.copyOf(lines); }}Total is derived from lines, so it cannot drift. The collection is exposed read-only. Value objects such as Money (a Java record with validation in its constructor) make illegal states harder to express.
Keep aggregates small, reference others by id
Large aggregates cause contention and slow loads: if Customer contains every Order ever placed, loading a customer loads their history and two concurrent order placements conflict on one object. Design rule: include in an aggregate only what must be consistent together within a single transaction. Everything else is a separate aggregate.
Aggregates refer to each other by identity, not by object reference:
public class Order { private final CustomerId customerId; // not a Customer object private final List<OrderLine> lines; // inside the boundary}This keeps boundaries explicit, prevents accidental navigation and lazy-loading surprises, and makes it possible to put aggregates in different services or stores later.
Consistency across aggregates: one per transaction
A transaction modifies one aggregate. When a business rule spans several (confirming an order should reserve stock), do not enlarge the aggregate or the transaction. Let the first aggregate record a domain event and let other parts react, with eventual consistency.
@Transactionalpublic void confirm(OrderId id) { Order order = orders.findById(id).orElseThrow(); order.confirm(); // records OrderConfirmed orders.save(order); order.pullEvents().forEach(publisher::publish);}Events are named in the past tense (OrderConfirmed) and carry what handlers need. Inside one service, an in-process handler may reserve stock in a second transaction; across services, publish the event reliably, using the transactional outbox described in the delivery semantics article. The rules in the second aggregate may reject the change, so design a compensation, such as cancelling the order with a reason.
Concurrency on a single aggregate
Because the aggregate is the unit of consistency, it is also the unit of locking. Two users editing the same order should not silently overwrite one another. Use optimistic locking with a @Version column so the second save fails and can be retried or reported. Pessimistic locks are rarely needed at aggregate granularity if aggregates are small. See the isolation levels article for what the database provides underneath.
Common mistakes
- Anemic model: entities with getters and setters, rules scattered across services.
- One giant aggregate "because everything is related".
- Object references between aggregates, leading to accidental loading of large graphs.
- Modifying several aggregates in one transaction as a habit, which hides a missing boundary.
- Exposing mutable internal collections.
- Using database entities as the aggregate without checking whether persistence concerns leak into the rules.
- Choosing aggregates from the table diagram instead of from invariants and use cases.
- Treating DDD as a mandatory folder structure instead of a modelling discipline.
Trade-offs and when not to use it
Aggregates pay off where rules are rich and change: pricing, booking, policy, state machines. For simple data management with few invariants, a straightforward CRUD model with validation is easier and perfectly honest. Tactical DDD also needs time with domain experts; without that the vocabulary is guessed. Apply it to the core domain, and keep generic parts, such as user profile storage, plain.
Cost shows up in mapping between domain and persistence models, in cross-aggregate queries that need dedicated read models, and in teaching the team. The benefit is rules that live in one place and are testable without infrastructure.
Repositories and factories
A repository loads and stores an aggregate as a whole; one repository per aggregate type is enough, with no separate repositories for inner objects. Its interface speaks the domain language (findById, save), and query complexity is delegated to read models. When creation logic is non-trivial, a factory method (Order.create(...)) ensures initial invariants hold from the first moment, because a half-built object never exists.
A small but important note
Do not draw an aggregate boundary once and forget it. When a new requirement arrives, ask which invariant the rule protects and which objects must be in the same transaction to protect it. If the answer changes, the boundary may need to change too.
Checklist
- Terms in code match terms the business uses.
- Each bounded context has its own model, with explicit translation at borders.
- Invariants are written down and enforced by the aggregate root.
- No public setters that bypass the rules.
- Aggregates are small and reference each other by id.
- One aggregate changes per transaction; the rest through events.
- Optimistic locking guards concurrent changes.
- Domain events are named in the past tense and published reliably.
Where to go next
- Clean architecture, pragmatically: keeping the domain independent of frameworks.
- Microservices boundaries: from bounded contexts to services.
- Solution architect roadmap: domain thinking at system scale.