Lesson 0012 · Core Designer · Module 3
Value Objects and Aggregates
Two containers protect the domain: value objects make data trustworthy, aggregates make change trustworthy. This lesson builds both on one example.
Mission tie-in: "use value objects and aggregates to protect integrity" — the mechanism behind lesson 0011's "make invalid states impossible."
Knowledge: the two protectors
Value object: an immutable thing whose identity is its value. Two instances with equal fields are interchangeable. Money is the classic:
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("amount cannot be negative")
if not self.currency.isalpha() or len(self.currency) != 3:
raise ValueError("currency must be an ISO code")
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise CurrencyMismatch()
return Money(self.amount + other.amount, self.currency)
Before: amount and currency drifted through the codebase as two unconnected numbers, each function re-validating or forgetting. After: you cannot construct a broken Money, and adding dollars to euros refuses itself. The value object killed a whole family of bugs — and it's shareable and testable because it's immutable.
Aggregate: a cluster of objects that must change together, with one root guarding the cluster's invariants. The order and its lines:
class Order:
def __init__(self, order_id: str, customer: CustomerId, currency: str) -> None:
self.order_id = order_id
self.customer = customer
self._currency = currency
self._lines: list[OrderLine] = []
self._status = Status.PENDING
def add_line(self, product: ProductId, qty: int, price: Money) -> None:
if self._status != Status.PENDING: # invariant: only drafts change
raise OrderLockedError()
if price.currency != self._currency: # invariant: one currency per order
raise CurrencyMismatch()
self._lines.append(OrderLine(product, qty, price))
def total(self) -> Money:
total = Money(Decimal("0"), self._currency)
for line in self._lines:
total = total + line.total() # Money refuses a mismatch anyway
return total
Order is the root: every change to the cluster goes through it, and it enforces the invariants from lesson 0011. Lines are edited only via the root — no one reaches into _lines to bend a rule.
Watch the two layers cooperate. Money refuses to add euros to dollars — a rule about one value. Order refuses a line in the wrong currency at the moment it is added — a rule about the cluster, caught at the door rather than three steps later inside total(). The value object makes each datum trustworthy; the aggregate makes each transition trustworthy. Neither can do the other's job.
The two containers work by two different mechanisms, and confusing them is why people apply them in the wrong places.
A value object converts a validity rule from something checked at every use into something proved by the type. Once a Money exists, every function downstream receives a guarantee it never has to re-verify, and the number of places that can produce an invalid one drops to a single constructor. Immutability is what makes the proof durable rather than momentary: a mutable "already validated" object can be invalidated a line after the check, which is the same time-of-check/time-of-use gap that makes caller-side validation unreliable in the first place.
An aggregate is not a tree of objects; it is a consistency boundary. Drawing it is a decision about which facts must be true at the same instant and which may become true a second later. That is why its real constraints are operational: the aggregate is the unit of locking, the unit of write conflict, and usually the unit of loading. Make it bigger and you buy stronger guarantees with contention and load cost; make it smaller and you buy throughput with rules that must now be enforced by a process rather than by a method.
So the boundary conditions follow directly. Value objects stop paying where construction cost dominates the work (millions of rows, vectorized numeric code) or where the thing actually has a lifecycle you track. Aggregates stop paying when the cluster is only ever read together — that is a query, not an aggregate — or when the boundary grows until one popular entity serialises the whole system.
How a senior designer works the case
The returns-resolution initiative: customers open returns against existing orders, and one rule matters to finance — a customer must never be refunded more than they paid. A modeller's instinct is to put the orders and the returns inside one Customer aggregate, where that rule is a single method away from being an enforced invariant.
What they look at first. Not the class diagram — the write pattern. How many writes per second hit one customer, how many orders does a heavy customer have, and which of these facts have to agree at commit time versus within a few seconds? For a retailer's returns flow, a given customer writes rarely, but the eligibility check reads the order, and the customer with 400 orders is exactly the one who returns things.
The question they ask. "What breaks, in business terms, if these two facts are stale relative to each other for two seconds?" If the honest answer is "a return is accepted against an order that was cancelled a second ago, and we reject it on review", that is a recoverable business event, not a corruption — so the two facts do not need one transaction.
The alternative they reject. The Customer-as-root design. It makes the refund-total rule enforceable in one place, and it costs: every return on any order locks the customer, a heavy customer loads hundreds of orders to add one line, and two teams now write to the same aggregate. Worse, on inspection the rule was never an invariant — the fraud guardrail it was protecting is a policy with thresholds a risk team tunes monthly. Enforcing policy as an invariant means a business tuning decision becomes a domain deployment.
@dataclass(frozen=True)
class OrderId:
value: str
class Return: # its own aggregate root
def __init__(self, return_id: str, order_id: OrderId, currency: str) -> None:
self.return_id = return_id
self.order_id = order_id # reference by identity, not object
self._currency = currency
self._lines: list[ReturnLine] = []
self._state = ReturnState.REQUESTED
def add_line(self, sku: str, qty: int, refund: Money) -> None:
if self._state is not ReturnState.REQUESTED:
raise ReturnLockedError(self.return_id)
if refund.currency != self._currency:
raise CurrencyMismatch()
self._lines.append(ReturnLine(sku, qty, refund))
def reject(self, reason: str) -> None: # the compensating transition
self._state = ReturnState.REJECTED
The consequence over time. Two aggregates mean two teams can hold two locks, ship on two schedules, and reason about two state machines. The price is explicit and paid once: eligibility is checked against a snapshot at creation, the order system stays authoritative, and the race — a return raised against an order that changed underneath it — surfaces as a reject() transition you can test, count and show a customer. Compare that to the contention failure of the big aggregate, which shows up as timeouts at peak, six months later, in code nobody wants to reshape by then.
| You see this | What it costs | The move |
|---|---|---|
def book(start: datetime, end: datetime) | Two values that must agree, re-validated at every call site. | One DateRange value object, validated once. |
| comparing two frozen dataclasses by id | Identity matters here — it is an entity in a value object’s clothes. | Give it an identifier and compare on that. |
| order.lines.append(...) from outside | The root’s invariants bypassed through a public list. | order.add_line(); keep _lines private. |
| float for money | Rounding drift nobody in the domain ever agreed to. | Decimal inside a Money value object. |
- "A value object is a frozen dataclass."
- Frozen is a means. The properties that matter are validity at construction, equality by value, and being safe to share. A frozen dataclass holding a
listis not immutable — callers mutate the list through the "frozen" object, and the guarantee you thought you had is gone. - "An aggregate is the parent in a parent/child relationship."
- It is a transaction boundary. Two clusters that are drawn as one tree but never change in the same transaction are two aggregates; one cluster spread over three tables that must commit together is one aggregate. The ERD is not the answer.
- "Primitive obsession is bad, so wrap every primitive."
- Wrapping buys a rule, a unit, or a name that prevents a mix-up (metres and feet, cents and euros). Where there is no rule and no confusable neighbour, a
stris astr, and the wrapper adds an import, a constructor and a layer of unwrapping at every boundary. - "Aggregates should mirror the database schema."
- Then the schema owns your model, and every normalization decision made for storage reasons becomes a domain decision. Design the boundary from the consistency requirement first; let the mapping be the adapter's problem (lesson 0013).
When this is the wrong move
- Bulk and analytics paths. Reconciling ten million rows through
Moneyconstructors buys validation you already have from the source system and costs an object per row. Validate at the ingest boundary; work in primitives inside the loop. - Screens that read across boundaries. A dashboard needing customer, orders, returns and shipping is not evidence that they belong in one aggregate. It is evidence you need a read model. Growing the aggregate to serve a query is the single most common way aggregates become contention points.
- CRUD with no rules. A settings form with twelve independent fields and no invariants gains nothing from a root that guards nothing. The ceremony is real; the protection is imaginary.
- Facts you do not own. If another system is authoritative for a value, your "aggregate" is holding a replica. Model it as a cached snapshot with an age, not as state you can enforce rules on (lesson 0020).
Skill: which container, and when?
An interval with start and end times is best modeled as:
Adding dollars to euros should fail because:
Order lines change only through the root because:
Practice on your own code
Find a pair of values that travel together as primitives (a price and currency, a start and end, a unit and rate) — promote them to a value object. Then find a cluster that must change together and give it a root that refuses invalid edits.
In the lab: the phase-1 checkpoint ships a returns service that passes an amount and a currency code as two separate arguments through four layers, and a return whose line list is appended to from outside. You promote the pair into one value object and decide, with the contention argument above, where the aggregate boundary actually belongs.
Reveal: a worked promotion
# before: temperature and unit passed separately, revalidated everywhere
def display(temp: float, unit: str) -> str: ...
# after: Temperature can't be malformed, and unit conversions live inside it
@dataclass(frozen=True)
class Temperature:
value: float
unit: Unit
def in_celsius(self) -> float: ...
- In two sentences, explain to a junior why aggregate size is a concurrency decision rather than a modelling preference.
- Find one pair of primitives that travel together in your codebase this week. Name the specific bug their separation makes possible, and how often that code changes.
- Assume your largest aggregate keeps absorbing one more "related" thing per quarter. Describe the failure you will be paged for in a year, and which metric shows it first.
Your win
You can spot primitive pairs (value objects waiting to happen) and unguarded clusters (aggregates waiting to happen) — and you know which identity test decides the container.
Read and watch deeper
- Domain-Driven Design, Evans — ch. 5 "A Model Expressed in Software" (entities vs value objects) and ch. 6 (aggregates and invariants).
- Implementing Domain-Driven Design, Vernon — ch. 6 "Value Objects" and ch. 10 "Aggregates": the practical rules of thumb.
- Architecture Patterns with Python, Percival & Gregory — ch. 1–2: value objects and aggregates in working Python.
- Vernon, "Effective Aggregate Design" (three essays, dddcommunity.org) — the primary source for the rules used above: model true invariants inside a boundary, keep aggregates small, reference other aggregates by identity, and update one aggregate per transaction with eventual consistency between them.
- Watch: ArjanCodes YouTube — search "value objects" or "aggregate".
- Reference: Glossary — value object, entity, aggregate.
Bring a borderline case — entity or value object? — to your agent-teacher and argue both sides.