The Architecture Reference

Ddd tactical · Domain-Driven Design · Intermediate

Entities and Aggregates

Entities have identity; aggregates are the consistency and transactional boundary mutated only through their root and committed atomically.

Ddd tactical Intermediate ⏱ 4 min read Complete

🧭 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 Execute method. 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

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.

Check your understanding

Score: 0 / 4

1. 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.