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.

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.

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.

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

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