Software Design Reference Core Designer · Protecting the Domain Core

Lesson 0011 · Core Designer · Module 3

Modeling Domain Invariants Directly

An invariant is a rule the domain cannot break. If your callers must remember to respect it, you don't have an invariant — you have a hope.

Mission tie-in: "make invalid states impossible" is the Core Designer's third pillar. It's also the highest-value thing to state to an AI before generation (lesson 0010).

Knowledge: the rule lives where the data lives

Take the rule "you cannot withdraw more than your balance." Two ways to build it:

# way one: everyone remembers
def transfer(from_acct: Account, to_acct: Account, amount: Money) -> None:
    if amount > from_acct.balance:          # caller-enforced
        raise InsufficientFunds()
    from_acct.balance -= amount
    to_acct.balance += amount
# way two: the domain refuses
class Account:
    def __init__(self, balance: Money) -> None:
        self._balance = balance             # private: no caller can assign it

    @property
    def balance(self) -> Money:
        return self._balance                # readable, never writable

    def withdraw(self, amount: Money) -> None:
        if amount > self._balance:
            raise InsufficientFunds()       # the object itself refuses
        self._balance -= amount

    def deposit(self, amount: Money) -> None:
        self._balance += amount

def transfer(from_acct: Account, to_acct: Account, amount: Money) -> None:
    from_acct.withdraw(amount)              # no caller judgment needed
    to_acct.deposit(amount)

In the first version, the rule lives in one caller — the next caller who forgets the check creates a negative balance, silently. In the second, the rule lives in the object that owns the state; it cannot be violated by any caller, present or future, human or AI.

The privacy is not decoration. Leave balance as a public attribute and acct.balance -= amount still walks straight past withdraw — an invariant is only as strong as the widest door into the state it guards. One leading underscore and a read-only property close that door.

That's the design move: find every rule your callers "know" and move it into the object that owns the data it protects. Then invalid states become unrepresentable, and the set of bugs the system can have shrinks.

Notice what this version still does not guarantee. If deposit raises, the money has already left the first account and never arrives at the second: "no money is created or destroyed" is an invariant that spans two objects, and no single object can enforce it. Rules that cross objects need a bigger guard — an aggregate root or a transaction. That is exactly the problem lesson 0012 picks up.

Same rule, more places: "order can't be confirmed twice", "email must be valid before sending", "a line item's quantity is a positive integer". Each one is a candidate for the move inward.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
assert amount > 0 atop three functionsThe rule holds only where someone remembered to write it.Move it into the constructor of the value it guards.
# must be called after validate()An ordering rule kept in a comment nobody has to read.Return a validated type; make the wrong order unspellable.
if order.status == "draft" at six call sitesCaller number seven will skip the check.order.edit() refuses; delete the six guards.
a public mutable attributeAny caller can bypass every method you wrote.Make it private with a read-only property.

Skill: who enforces the rule?

A rule checked by each caller before acting is:

The invariant "cannot confirm twice" belongs in:

A negative balance becomes possible. The cheapest place to have caught it was:

Practice on your own code

Find a rule your codebase "knows" but doesn't enforce: a check repeated at call sites, a comment saying "must be called after X", an assert at the top of functions. Move it into the object that owns the state it protects.

Reveal: a worked move
# every caller remembered: "status must be DRAFT to edit"
if order.status == "draft":            # repeated in 6 places
    order.edit(lines)

# the order object now refuses:
class Order:
    def edit(self, lines: list[Line]) -> None:
        if self.status != Status.DRAFT:
            raise OrderLockedError()
        self.lines = lines

Your win

You can find the "everyone remembers" rules in your code and move them into the objects that own the state — making whole classes of bugs unrepresentable.

Read and watch deeper

Show your agent-teacher a "must be called after X" comment and design the refusal together.