---
title: 'Aggregates and the Root'
source: 'https://academia.sh/en/courses/domain-driven-design/aggregates-and-root'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:19+00:00'
license: 'CC BY-SA 4.0'
---

# Aggregates and the Root

Drawing the consistency boundary: counting, across five paths, how many separate call paths can break the discount-cap invariant, showing that this count drops to zero when access passes through the root, measuring how many aggregates a single transaction touches, and the cost of turning the link between aggregates into an identity reference.

So far, the shipment has been treated on its own. In the domain, it is not on its own: it has
discounts, a contract, and declared items, and a rule stands between them — the sum of the
discount rates cannot exceed the cap. This rule does not belong to the shipment alone, nor to
the discounts alone; it belongs to the whole of the shipment and its discounts.

A whole of this kind is called an **aggregate**, and the single object through which access
to that whole passes is called the **aggregate root**. The concept of an aggregate was
established as a unit of access and writing in the Repository and Unit of Work lesson of the
Design Patterns course. This lesson's question is different: where is the boundary drawn by
the criterion of consistency — which invariant falls inside the boundary, and is the boundary
held.

## Whose Invariant Is It

The criterion is one sentence: an invariant is placed inside the smallest boundary that
contains all the data needed to check its correctness. The data the discount cap needs is a
single shipment's discount list; no other shipment, no other contract needs to be looked at.
The boundary therefore covers the shipment and its discounts, and does not cover the
contract.

The contract has its own invariant: the committed volume cannot be exceeded. That rule's
input is the count of every shipment tied to the contract, so a second boundary is born. The
link between the two boundaries is not an object reference but an **identity reference**: the
shipment carries the contract number, not the contract object.

## The Model That Exposes the Boundary

In the first version, the discount list is directly accessible, and the cap rule lives only
inside a helper function.

```sh
mkdir -p open root
```

```js
// open/shipment.mjs — discounts are exposed; the cap rule lives only in the helper function
export const DISCOUNT_CAP = 0.4;

export const shipment = (shipmentNo, contractNo) =>
  ({ shipmentNo, contractNo, discounts: [] });
export const totalRate = (s) => s.discounts.reduce((t, d) => t + d.rate, 0);

export function addDiscount(s, discount) {
  if (totalRate(s) + discount.rate > DISCOUNT_CAP) throw new RangeError("cap exceeded");
  s.discounts.push(discount);
  return s;
}
```

```js
// open/scenario.mjs — contract discount: two aggregates are written in the same transaction
import { addDiscount } from "./shipment.mjs";

export function applyContractDiscount(s, contract, rate, write) {
  addDiscount(s, { name: "contract", rate });
  contract.usedVolume += 1;
  write("shipment", s.shipmentNo);
  write("contract", contract.contractNo);
}
```

## The Model Where Access Passes Through the Root

In the second version, the discount list is a private field. The list handed out is a copy,
and its items are frozen; adding to it can only go through the root's method.

```js
// root/shipment.mjs — aggregate root: discounts are reachable only through the root
const DISCOUNT_CAP = 0.4;

export class Shipment {
  #discounts = [];
  constructor(shipmentNo, contractNo) {
    this.shipmentNo = shipmentNo;
    this.contractNo = contractNo;
  }
  totalRate() { return this.#discounts.reduce((t, d) => t + d.rate, 0); }
  discounts() { return this.#discounts.map((d) => Object.freeze({ ...d })); }
  addDiscount(discount) {
    if (this.totalRate() + discount.rate > DISCOUNT_CAP) throw new RangeError("cap exceeded");
    this.#discounts.push(Object.freeze({ ...discount }));
    return this;
  }
}
```

```js
// root/scenario.mjs — each transaction writes a single aggregate; the contract is referenced by identity
export function applyContractDiscount(shipment, rate, write) {
  shipment.addDiscount({ name: "contract", rate });
  write("shipment", shipment.shipmentNo);
}

export function processContractUsage(contract, write) {
  contract.usedVolume += 1;
  write("contract", contract.contractNo);
}
```

## Measurement

Two measures are taken. The first is **reachability**: for a shipment carrying a discount at
a 0.30 rate, in how many of five separate call paths can a state exceeding the cap be
produced. The second is **aggregates per transaction**: how many separate aggregates a
scenario writes.

```js
// boundary-measure.mjs — how many paths can break the invariant, how many aggregates does one transaction write
import * as open from "./open/shipment.mjs";
import { Shipment } from "./root/shipment.mjs";
import { applyContractDiscount as openScenario } from "./open/scenario.mjs";
import * as rootScenario from "./root/scenario.mjs";

const CAP = 0.4;
const EXTRA = { name: "volume", rate: 0.5 };
const REPLACEMENT = [{ name: "volume", rate: 0.9 }];

const OPEN_PATHS = {
  "field method": (s) => open.addDiscount(s, EXTRA),
  "push to array": (s) => { s.discounts.push({ ...EXTRA }); },
  "item rate": (s) => { s.discounts[0].rate = 0.5; },
  "replacing the array": (s) => { s.discounts = REPLACEMENT.map((d) => ({ ...d })); },
  "bulk assign": (s) => { Object.assign(s, { discounts: REPLACEMENT.map((d) => ({ ...d })) }); },
};
const ROOT_PATHS = {
  "field method": (s) => s.addDiscount(EXTRA),
  "push to array": (s) => { s.discounts().push({ ...EXTRA }); },
  "item rate": (s) => { s.discounts()[0].rate = 0.5; },
  "replacing the array": (s) => { s.discounts = REPLACEMENT.map((d) => ({ ...d })); },
  "bulk assign": (s) => { Object.assign(s, { discounts: REPLACEMENT.map((d) => ({ ...d })) }); },
};

function scan(label, build, rate, paths) {
  let violations = 0;
  let errors = 0;
  for (const [path, attempt] of Object.entries(paths)) {
    const s = build();
    try { attempt(s); } catch { errors += 1; }
    const broken = rate(s) > CAP;
    if (broken) violations += 1;
    console.log(`  ${path.padEnd(24)} total rate ${rate(s).toFixed(2)}  ` +
      `${broken ? "VIOLATED" : "held"}`);
  }
  console.log(`${label}: ${violations} of 5 paths broke the invariant, ${errors} threw an error`);
}

console.log("open model");
scan("open", () => open.addDiscount(open.shipment("G-2001", "S-77"), { name: "contract", rate: 0.3 }),
  open.totalRate, OPEN_PATHS);
console.log("root model");
scan("root", () => new Shipment("G-2001", "S-77").addDiscount({ name: "contract", rate: 0.3 }),
  (s) => s.totalRate(), ROOT_PATHS);

function measureTransaction(label, run) {
  const written = [];
  run((kind) => written.push(kind));
  const distinct = new Set(written);
  console.log(`${label}: write call ${written.length}, aggregate touched ${distinct.size} ` +
    `[${[...distinct].join(" ")}]`);
}

measureTransaction("open transaction", (write) => {
  const s = open.shipment("G-2002", "S-77");
  openScenario(s, { contractNo: "S-77", usedVolume: 4 }, 0.25, write);
});
measureTransaction("root transaction", (write) => {
  const s = new Shipment("G-2002", "S-77");
  rootScenario.applyContractDiscount(s, 0.25, write);
});
measureTransaction("root second transaction", (write) => {
  rootScenario.processContractUsage({ contractNo: "S-77", usedVolume: 4 }, write);
});
```

```sh
node boundary-measure.mjs
```

```
open model
  field method             total rate 0.30  held
  push to array            total rate 0.80  VIOLATED
  item rate                total rate 0.50  VIOLATED
  replacing the array      total rate 0.90  VIOLATED
  bulk assign              total rate 0.90  VIOLATED
open: 4 of 5 paths broke the invariant, 1 threw an error
root model
  field method             total rate 0.30  held
  push to array            total rate 0.30  held
  item rate                total rate 0.30  held
  replacing the array      total rate 0.30  held
  bulk assign              total rate 0.30  held
root: 0 of 5 paths broke the invariant, 2 threw an error
open transaction: write call 2, aggregate touched 2 [shipment contract]
root transaction: write call 1, aggregate touched 1 [shipment]
root second transaction: write call 1, aggregate touched 1 [contract]
```

In the open model, four of the five paths produce a shipment that exceeds the cap. The one
path where the rule is written — the helper function — throws an error; the remaining four
touch the list directly and never see the rule. In the root model, zero of the same five
attempts break the invariant: two attempts throw an error, two are ineffective because they
work on a copy, and one cannot reach the root's private field. Preserving the invariant stops
being a question of attentiveness and becomes a guarantee the structure provides.

The last three lines give the boundary's width. In the open model, a single transaction
writes two aggregates: the shipment and the contract are finalized together. In the root
model, the same work splits into two transactions, and each one touches a single aggregate.

## The Boundary's Cost

The cost of the one-aggregate-per-transaction rule is plain: the contract's used volume is
not updated the moment a discount is added to the shipment. Consistency between the two
aggregates is not **immediate**; the contract carries the old count until the second
transaction runs.

This is a decision separate from choosing the transaction boundary. Where the transaction
boundary is drawn, and whether two writes are finalized together, was measured in the
Transaction Boundaries lesson of the Data Access Layer and Business Logic course. The
consistency boundary comes before that: it says which rule needs to be held immediately. The
discount cap has to be held immediately, because a shipment that exceeds the cap must not
exist even for an instant. The contract's volume count does not have to be held immediately,
because the domain treats it as a reconciliation question.

The measure therefore runs both ways. Widening the boundary lowers the number of aggregates
per transaction, but grows the data being locked; narrowing it brings writes down to a single
aggregate, but raises the number of rules that are not held immediately. The choice is the
answer to the question of which rule cannot be broken even for an instant.

## The Root's Rules

Three constraints produce the guarantee seen in the measurement. Objects inside the boundary
are not given out by reference from the outside; if they are given out, a copy is given. A
reference to an aggregate outside the boundary is made by identity, not by object. Every
operation that changes what is inside the boundary passes through a method on the root.

The third constraint shows up directly in the measurement above: because `discounts()`
returns a copy, the "push to array" path stays ineffective; because its items are frozen, the
"item rate" path throws an error. Without the copy, both paths would break the invariant.

## When It Does Not Apply

The cost of access passing through the root is that every read wanting to look inside the
boundary has to go through the root, and every read produces a copy. In a whole with no
invariant, this cost goes unpaid for: drawing a boundary around objects that are merely read
together and share no rule produces nothing more than a level of indirection.

The second case concerns the boundary's size. An aggregate grows more expensive as the number
of objects inside it grows: every write loads the whole, every read produces a copy. Putting
every shipment tied to a contract into a single aggregate would hold both the discount cap and
the contract volume immediately, at the cost of preventing two shipments on the same contract
from being processed at the same time. The boundary's correct place is the smallest container
for the set of rules that must be held immediately.

## Summary

- An aggregate is a whole of objects sharing an invariant; the aggregate root is the single
  object through which access to that whole passes.
- The boundary's criterion is consistency: an invariant is placed inside the smallest
  boundary that contains all the data needed to check it. The discount cap covers the
  shipment and its discounts, not the contract.
- In the model that exposes the boundary, 4 of five call paths produced a shipment that
  exceeded the cap; in the model where access passes through the root, 0 did.
- In the open model, one transaction touched 2 aggregates; in the root model, the same work
  split into two transactions, each touching 1 aggregate.
- The cost is consistency that is not immediate: the contract's volume count is not updated
  the moment the shipment is written.
- Three constraints produce the guarantee — no reference is given out to the inside,
  references outside the boundary are made by identity, and every change passes through the
  root.

## Next Step

The root's methods decide using the data inside their own boundary. The library has one more
behavior that does not fit this pattern: choosing the best tariff to apply to a shipment.
That decision requires the shipment, the tariff catalog, and the customer's contract
together; none of the three owns the others. Loading the decision onto the shipment raises
the number of outside names the shipment has to know. The next lesson measures that number,
determines whether a behavior belongs to an object or to a separate domain service using the
count of collaborators, and establishes the measure that separates a domain service from an
application service.
