Software Design Reference Core Designer · Protecting the Domain Core

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.

Reading ≈ 9 minPractice ≈ 30 min

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 principle behind the principle

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.

† Identity check: a value object is interchangeable (two "100 EUR"s are the same thing); an entity is tracked across time (order #42 is order #42). If you find yourself comparing objects by fields where identity matters, you've made an entity into a value object — or vice versa.
Field notes · what it looks like in real code
You see thisWhat it costsThe 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 idIdentity matters here — it is an entity in a value object’s clothes.Give it an identifier and compare on that.
order.lines.append(...) from outsideThe root’s invariants bypassed through a public list.order.add_line(); keep _lines private.
float for moneyRounding drift nobody in the domain ever agreed to.Decimal inside a Money value object.
What people get wrong after reading this
"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 list is 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 str is a str, 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

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: ...
Teach it back
  1. In two sentences, explain to a junior why aggregate size is a concurrency decision rather than a modelling preference.
  2. 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.
  3. 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

Bring a borderline case — entity or value object? — to your agent-teacher and argue both sides.