🧭 Analogy
Think of a shipping container with one sealed door. You can’t reach in and rearrange individual boxes — you hand instructions to the dock crew at the door, and they guarantee nothing inside ever ends up in an invalid arrangement. The aggregate root is that single door; the container is the consistency boundary.
Entities: identity matters
An entity requires an explicit identification field and is mutable (though its identity field stays immutable). It describes its properties using value objects — an explicit identity lets the model distinguish two namesakes that happen to share every other attribute. Crucially, an entity is not implemented independently — only inside an aggregate.
Aggregates: the consistency boundary
An aggregate is an entity whose purpose is to protect the consistency of its data. It is simultaneously a consistency-enforcement boundary and a transactional boundary: a hierarchy of entities and value objects bound by business logic and invariants — rules that must hold at all times.
graph TD Root["Aggregate Root: Ticket<br/>(single public interface)"] --> M["List of Message (inside)"] Root --> Cmd["Commands: AddMessage, RequestEscalation"] Root -. "by ID" .-> Cust["Customer (other aggregate)"] Root -. "by ID" .-> Agent["AssignedAgent (other aggregate)"] Cmd --> Inv["Validate input + enforce invariants"] Inv --> Ev["Append domain event"]
- Aggregate root — The single entity designated as the public interface. All external interaction goes through the root; from outside the aggregate is read-only.
- Commands — State-modifying methods on the root, implemented as a plain method or a parameter object passed to an
Executemethod. The root validates input and enforces every invariant in one place, so logic can’t leak and duplicate in the application layer. - Transactional boundary — All changes commit atomically, one aggregate instance per transaction. Needing to modify several aggregates in one transaction means the boundaries are wrong.
Keep them small, reference by ID
Design aggregate boundaries from data-consistency requirements: include only the data that must be strongly consistent. Anything that can be eventually consistent belongs in a different aggregate, referenced by ID. The heuristic: would working on this data while it’s slightly stale lead to an invalid state? If not, it doesn’t belong inside.
graph TD subgraph Agg["Ticket aggregate — transaction boundary"] R["Root: Ticket"] --> Msgs["Messages (strongly consistent)"] R --> Status["Status, priority (invariants)"] end R -. "reference by ID<br/>(eventually consistent)" .-> Cust["Customer aggregate"] R -. "reference by ID" .-> Agent["Agent aggregate"]
Ticket {
id, version
messages: [ ... ] // inside — strongly consistent
customerId, agentId // references to OTHER aggregates, by ID
}
Invariants reduce degrees of freedom
Goldratt’s degrees of freedom are the data points needed to describe a system’s state — more degrees, more complexity. Encapsulating invariants (e.g., deriving values instead of storing them independently) reduces degrees of freedom, so a class that looks more complex can actually be less complex. That is the essence of how aggregates tackle complexity.
Protect concurrency optimistically
Guard each aggregate with optimistic concurrency: a _version field and an UPDATE ... WHERE id=@id AND version=@expected — on mismatch, throw a concurrency exception and retry. The application layer that loads → executes a command → saves is itself essentially a transaction script, so concurrency management is essential.
Don't use aggregates for CRUD
The domain model (value objects, entities, aggregates) suits complex logic — usually a core subdomain. Forcing aggregates onto simple supporting-subdomain CRUD is over-engineering; see the business logic patterns for when a transaction script or active record is the right tool.
See also
- Value objects — the immutable building blocks of an aggregate.
- Domain events and services — how aggregates communicate and cross-aggregate logic lives.
- Event sourcing — persisting an aggregate as a stream of events.
When to use it — and when not
✅ Reach for it when
- For complex business logic in a core subdomain — intricate state transitions, rules, and invariants.
- When several pieces of data must change together and stay strongly consistent.
- When you need a single place to enforce invariants and prevent state corruption.
⛔ Think twice when
- For simple CRUD over supporting subdomains — a transaction script or active record is enough.
- Don't let an aggregate span data that can be eventually consistent — reference it by ID instead.
Related topics
Immutable types identified by the composition of their values — data plus behavior that cure primitive obsession.
ddd-tacticalDomain Events and Domain ServicesPast-tense messages that announce what happened, and stateless objects that host logic spanning multiple aggregates.
ddd-tacticalEvent SourcingPersist an aggregate as an append-only stream of domain events — the source of truth — from which any number of state projections are built.
Check your understanding
Score: 0 / 41. What is the transactional rule for aggregates?
An aggregate is a transactional boundary; all its changes commit atomically and only one instance is modified per transaction. Needing multi-aggregate transactions signals wrong boundaries.
2. How should external code change an aggregate's state?
The aggregate root is the single public interface; external code may only read the aggregate, and all mutations go through commands on the root that validate input and enforce invariants in one place.
3. What distinguishes an entity from a value object?
An entity needs an explicit identification field (which stays immutable) and is mutable; it is implemented only inside an aggregate, describing its properties with value objects.
4. How should one aggregate reference another?
Keep aggregates small — include only strongly-consistent data — and reference other aggregates by ID, since data that can be eventually consistent belongs in a different aggregate.
Comments
Sign in with GitHub to join the discussion.