The Aggregate Root
One door into the boundary. External changes go through the root so the invariant has exactly one place it can be checked — and exactly one place it can be bypassed.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
If a rule spans several objects, how do I make it impossible to change any of them without the rule being checked?
Ops needs a bulk tool that adjusts quantities on many order lines at once. The existing code exposes the line collection, so the tool edits lines directly — and the credit-limit rule is not applied.
Expose the collection. order.lines returns the array, callers add to it, and the repository saves whatever it finds. It is the shortest code and every ORM tutorial does it this way.
A getter that returns the live collection is a public setter with extra steps: any caller can mutate it, and the root never finds out (Exposing Too Much).
- A getter that returns the live collection is a public setter with extra steps: any caller can mutate it, and the root never finds out (Exposing Too Much).
- Each new caller is a new place the rule can be skipped, and the skipping is silent — the code that forgets to check compiles, passes review and works in the happy case.
- The bulk tool is exactly that caller. It was written by a different team, six months later, against the public shape of the object, and it did nothing wrong: the model told it the collection was public.
- When the rule changes, the root is updated and the direct mutators are not, so the system now enforces two versions of the rule depending on which entry point you use.
- Under an ORM with change tracking, even a read-then-mutate for a temporary calculation gets persisted, which produces changes nobody wrote a line of code to make.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The ORM lazy-loads collections and its change tracker will persist any mutation it observes, including ones the model did not sanction.
- A reporting job reads order lines directly with SQL and must keep doing so for performance reasons.
- The bulk tool must handle five thousand lines in one operation within a request timeout.
- No state inside the boundary changes without the root's invariant being evaluated afterwards.
- The root is the only type outside code may hold a reference to; internal objects are reachable only through it.
- The invariant holds at every point where a transaction commits, not merely at the end of every method.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The root owns every mutation inside the boundary and re-evaluates the invariant after each one.
- The root owns handing out data without handing out control — copies, read-only views, or projections rather than the live collection.
- The repository owns loading and saving the whole aggregate through the root, and owes callers no way to reach the inside (The Repository Layer in Backend is the mechanism).
- The reporting job explicitly owns being outside the model. It reads rows and never writes them, and that exemption is written down rather than assumed.
- The root is the boundary's only public surface. Everything else in the aggregate is module-private, and in languages that can enforce that, it should be enforced by the language (Information Hiding).
- The line between "read" and "write" runs through the root: reads may leave the boundary as copies, writes may not enter except through methods.
- SQL is outside every boundary the model draws. That is not a flaw in the model, it is a limit on it, and pretending otherwise is how teams get surprised (Invariant Leaks).
One door, and the window next to it
The difference between the two versions below is one return type. That is genuinely all it takes: a live collection handed out is a mutation path, and a copy is not.
The second version also does something the first cannot — it makes the bulk case a first-class operation. The naive fix for bulk is a loop of single calls, which validates once per item; making the batch a root method validates once per batch and is both correct and an order of magnitude faster.
1class Order {2 private lines: OrderLine[] = []3 private readonly creditLimit: Money4 5 // bad: callers can push, splice and mutate items6 // get lines() { return this.lines }7 8 // the caller gets the data, not the handle9 linesView(): readonly OrderLineView[] {10 return this.lines.map((l) => l.view())11 }12 13 adjustQuantity(lineId: LineId, qty: number) {14 this.lineFor(lineId).setQuantity(qty)15 this.assertInvariants()16 }17 18 // batching is a business operation, so it lives on the root19 adjustMany(changes: ReadonlyMap<LineId, number>) {20 for (const [id, qty] of changes) this.lineFor(id).setQuantity(qty)21 this.assertInvariants() // once, not five thousand times22 }23 24 private assertInvariants() {25 if (this.total().greaterThan(this.creditLimit)) throw new OverCreditLimit(this.id)26 }27}assertInvariants being private and shared is what makes the rule survive the next developer: a new mutating method that forgets to call it is a one-line review comment, whereas a new mutation site scattered across the codebase is invisible. The adjustMany method is not an optimisation bolted on — it is the recognition that "ops adjusts many lines" was always a business operation and had simply never been named.
How the door gets bypassed
Every one of these is something a competent engineer does for a good reason. That is what makes the root fragile: it is not defeated by carelessness, it is defeated by legitimate requirements that the boundary did not anticipate.
The response column is the design answer. Notice that two of the four responses are "accept the hole and defend it elsewhere" — an honest boundary is one that knows where it does not hold.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
A performance fix replaces a loop with a bulk SQL UPDATE | Orders appear over the credit limit with no code path that could have created them. | SQL is outside the model entirely; the root never saw the change. | Accept it and put the invariant in the database as a constraint too, so the SQL path is checked by the store rather than by the model (Database Constraints). |
| An ORM lazy-loads the collection and tracks changes | A read-only calculation that touched a line persists a change nobody intended. | The change tracker persists observed mutations regardless of the root's methods. | Map the collection as a private field, return copies, and detach entities used for calculation (What an ORM Actually Does). |
| A new team writes an admin tool against the public shape | A second, subtly different version of the rule now exists in the tool. | The boundary was a convention with nothing enforcing it. | Make it structural: module-private types plus an import lint rule that fails the build (Internal Module Contracts). |
| A JSON import deserializes straight into the aggregate | Invalid aggregates exist in the database that no method could have produced. | Deserialization constructs objects reflectively and skips every constructor check. | Deserialize into a DTO, then build the aggregate through its normal operations, so import is just another caller (Three Models, Not One). |
Root-only access is not free
The pattern is usually presented as strictly better, and it is not. Funnelling access through one object costs read convenience, allocation and — under an ORM — a running fight with the framework. Those costs are worth paying where a real invariant exists and are pure loss where one does not.
The interesting column is testability, where root-only access wins clearly: an aggregate with one entry point is trivially testable in memory, which is often the benefit teams actually experience even when the invariant was never the motivation.
| Option | Simplicity | Flexibility | Performance | Testability | Operational | Note |
|---|---|---|---|---|---|---|
| Public collections, no root | Shortest code, every caller free. The invariant is enforced by whoever remembers, which over three years is nobody. | |||||
| Root-only access, copies out | One door, one assertion, and an in-memory test that needs no database. Costs allocation on reads and a fight with the ORM. The recommendation where a real invariant spans the objects. | |||||
| Root plus a database constraint | The rule lives twice, deliberately, because the two enforce it against different callers. Highest operational confidence and the highest maintenance burden. |
caveat The scores assume an invariant exists. If no rule spans the objects, the first row is not merely acceptable — it is correct, and the other two are elaborate ways of protecting nothing. The table also cannot express the dominant real-world factor, which is how many teams touch this code: root-only access is worth far more at four teams than at one, because its value is entirely in constraining people who were not part of the original design conversation.
How to build it
Most important first.
- Return copies or read-only views from collection accessors.
readonly OrderLine[]in a typed language, an immutable copy elsewhere — the caller gets the data and not the handle. - Give the root a method per business operation, not per field:
order.adjustQuantity(lineId, qty), notorder.lines. The method name is where the requirement lands (Naming and Domain Language). - Check the invariant at the end of each mutating method, in one private
assertInvariants()the methods share, so adding an operation cannot forget it. - Give the bulk case its own root method —
order.adjustMany(changes)— that applies all changes and checks once. Batching is a business operation, and modelling it as one is what makes it both correct and fast. - Enforce the boundary structurally where you can: module-private types, an import lint rule, or a package boundary. A convention that only lives in review will be broken by someone who was not at that review (Internal Module Contracts).
- Put the invariant in the database too, where it is expressible. A
CHECKconstraint or a trigger catches the SQL path the model cannot see — belt and braces, because the model has an acknowledged hole (Database Constraints).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a rule about order totals costs an edit at every mutation site plus a search for sites you do not know about, and the search has no terminating condition.
- After: one private method on the root. New operations added later inherit the rule because the shared assertion runs at the end of each of them.
- The next change that becomes cheap and surprising: adding an audit entry per change. There is one place changes happen, so the audit hook is one line.
- The next change that stays expensive: anything that must be done in SQL for performance. The reporting job and any bulk
UPDATEremain outside the model's reach, so a rule change has to be applied there separately and manually — which is the permanent cost of an in-memory boundary over a stored one.
- Copying collections on every read costs allocation, and for an aggregate with thousands of children in a hot path that cost is real and measurable.
- A method per operation is more code than a public field, and for state with no invariant it is pure ceremony.
- Duplicating the invariant into a database constraint doubles the places it lives — genuinely against the advice everywhere else in this domain, and justified here only because the two enforce it against different classes of caller.
What can go wrong
- The accessor returns the live collection "temporarily" during a migration and stays that way for three years, so the root is a door standing next to an open window.
- The root has a method per field rather than per operation —
setStatus,setTotal— which is a public setter wearing a method name and enforces nothing. - A bulk operation is implemented as a loop of single-item root calls, which is correct and loads and re-validates the aggregate five thousand times (N+1 as a Design Problem).
- The mitigation fails too: a database constraint added as a backstop rejects a legitimate operation the model permits, and now there are two versions of the rule that must be kept in sync forever (Duplicate Knowledge).
- All external code depends on the root and on nothing inside it — that is a deliberate reduction of the aggregate's surface from many types to one (Do We Need a Package for This?).
- The root depends on its internals, which depend on nothing outside the aggregate. The arrow points strictly inward.
- The ORM depends on being able to see the internals in order to persist them, which is the tension: the persistence mechanism needs exactly the access the model is trying to deny.
- "Every entity is an aggregate root with a repository." That produces a repository per table and no boundaries at all, which is the exact shape the pattern was meant to replace (Aggregates).
- "The root must not expose data, only behaviour." It must not expose *mutable* data. Read models, projections and copies are fine and necessary, and a root that refuses to tell you anything forces every caller to reimplement it (Cost-Aware Interfaces).
- "The boundary is enforced." In most languages and every ORM, it is enforced only against code that respects it. SQL, reflection, deserialization and bulk updates all walk past it, which is why the important invariants also live in the database.
- "Going through the root means one item at a time." No — batch operations belong on the root too. The alternative is a loop that validates five thousand times.
- feature-envy
- god-object
Testing it, and how it ages
- A test that the collection accessor returns something the caller cannot mutate — push to it and assert the aggregate did not change.
- A test per root method that violating the invariant is rejected, and a test that a batch operation is validated once and correctly.
- An architecture test that no module outside the aggregate imports its internal types; this is a lint rule, and it is the only version of the boundary that survives staff turnover (Circular Dependencies tooling usually does this too).
- A test at the SQL boundary asserting the database constraint rejects what the model would reject, so the two versions of the rule are pinned to each other.
- Root methods accumulate as the business adds operations, and the root grows into the largest type in the module. That is expected; what matters is whether the methods are all about the same invariant.
- When methods start appearing that do not touch the invariant —
order.sendReceiptEmail()— the root is absorbing responsibilities that belong outside, and the fix is to move them, not to split the aggregate (Feature Envy). - Eventually a bulk-performance requirement forces a path around the root. The design ages well if that path is explicit and tested, and badly if it is a quiet exception nobody wrote down.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat an invariant needs a single enforcement point, and therefore a single mutation path, follows from the invariant; the enforcement mechanism differs by language but the requirement does not.
- LANGUAGE-SPECIFICRust's ownership and module privacy can make internal state genuinely unreachable, so the boundary is compiler-enforced. Java and C# enforce it against ordinary code but not against reflection or an ORM proxy. TypeScript's
readonlydisappears at runtime, and Python has no privacy at all — in the last two the boundary is a convention plus a lint rule, which changes it from a guarantee into a strong default. - FRAMEWORK-SPECIFICORMs with change tracking — Hibernate, Entity Framework, ActiveRecord — persist mutations they observe regardless of whether the root sanctioned them, so on those stacks the root needs framework-level help (private collection mappings, detached entities) that a hand-written mapper does not need at all.
- CONTESTEDThe strongest opposing view: the real enforcement point is the database, and an in-memory root gives a false sense of safety while adding indirection. Its advocates note that every serious system eventually has a bulk-SQL path, and that a constraint or a stored procedure enforces the rule against every caller including that one. The counter is that most invariants are too conditional to express as constraints — but "most" is doing real work in that sentence and the argument is not settled.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the architecture test that no module imports an aggregate's internals is a test about structure rather than behaviour, and deciding how many of those a codebase should have is a testing-strategy question.