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.
| You see this | What it costs | The move |
|---|---|---|
| assert amount > 0 atop three functions | The 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 sites | Caller number seven will skip the check. | order.edit() refuses; delete the six guards. |
| a public mutable attribute | Any 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
- Domain-Driven Design, Evans — ch. 6 "The Life Cycle of a Domain Object": invariants as the reason aggregates exist.
- Architecture Patterns with Python, Percival & Gregory — ch. 1 "Domain Modeling": invariants modeled as the domain's own behavior.
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": designing so errors can't occur, not handling them well.
- Watch: ArjanCodes YouTube — search "domain model" for modeling examples.
- Next: lesson 0012 — value objects and aggregates, the carriers of invariants.
- Reference: Design Principles — make invalid states unrepresentable; glossary — domain invariant.
Show your agent-teacher a "must be called after X" comment and design the refusal together.