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