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.
An invariant is a claim about every reachable state, so its enforcement cost is set by how many paths can reach that state. Caller-side checking makes the rule a conjunction of n independent promises, and n grows with the codebase while the rule stays fixed: every new endpoint, job, migration script and AI-generated helper adds another promise that can be broken alone. Moving the check inside the object that owns the data collapses n to one, because the only way to reach the state is through the door you control. That is the whole mechanism — it is a counting argument, not a style preference.
Which tells you exactly when it stops working. The move only pays if one object can see everything the rule needs. "No two customers share an email" is not enforceable inside a Customer: the rule ranges over a set the object cannot see, so it belongs to whatever owns the set — a repository query in a transaction, or a unique index. "Refunds over €500 need approval" looks like an invariant but is policy: it changes on a business calendar, and freezing it in a constructor turns a pricing decision into a domain redeploy. And an invariant must hold for every state the object can be in, including partially built ones — if your object has a legitimate half-finished phase, the rule belongs to the transition, not the type.
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.
How a senior designer works the case
A refund service on a six-person payments team. A customer is refunded twice for the same order; finance finds it four days later during reconciliation. The ticket says "add a check in the refund endpoint."
What they look at first. Not the endpoint. They grep for every write to the number the rule constrains — grep -rn "refunded" --include='*.py' — and count the sites:
# three paths write the same field; one of them checks the rule
def refund_endpoint(order, amount, payments):
if amount <= order.refundable_amount(): # checked here
payments.refund(order.order_id, amount)
order.refunded += amount
def retry_failed_refunds(rows, orders, payments):
for row in rows: # not checked here
order = orders.get(row.order_id)
payments.refund(order.order_id, row.amount)
order.refunded += row.amount
def csv_import(row, order, payments):
payments.refund(order.order_id, row.amount) # or here
order.refunded += row.amount
The question they ask. Not "where did it break?" but "can I enumerate every path that changes this number, and will I still be able to in a year?" Three paths today; the partner-returns webhook on next quarter's roadmap makes four. If the answer needs a grep, the rule is being observed, not enforced.
The alternative they reject. The obvious refactor is a shared validate_refund(order, amount) helper called from all three sites. It removes the duplication and it is still a hope: the fourth caller — written by someone who never read this file, or generated by an assistant that saw two of the three call sites — has no reason to call it. A helper reduces typing; only a boundary creates enforcement. The test to apply is blunt: can a competent newcomer break this rule without noticing?
class Order:
def authorize_refund(self, amount: Money) -> Refund:
if amount.currency != self._total.currency:
raise CurrencyMismatch()
if amount > self._total - self._refunded:
raise RefundExceedsOrder(self.order_id, amount)
self._refunded += amount # the only writer of this field
return Refund(self.order_id, amount)
The consequence over time. The caller-side fix closes one path, and the next incident arrives through a different one — same defect, new stack trace, another on-call night plus a finance reconciliation. The boundary fix closes the class: after it, over-refunding requires a new mutation path, and a new mutation path is visible in review as an assignment to _refunded outside Order. That is the difference worth paying for — not fewer bugs this week, but a bug family that cannot regrow silently as the team and the call sites multiply.
| 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. |
- "Every attribute should be private with a read-only property."
- Privacy is a cost, paid to protect a rule. A field with no rule attached gains nothing from a property except a line of ceremony and a slower reader. Make private the state that some invariant depends on; leave the rest plain.
- "An invariant is just validation moved into the constructor."
- Validation answers "is this input acceptable?" once, at the edge. An invariant answers "is this object ever allowed to be in this state?" for the object's whole life — which means every mutating method must re-establish it, not only
__init__. - "The database constraint already enforces it, so the domain need not."
- A constraint fires at commit, after the work is done, and reports in the storage layer's vocabulary — an
IntegrityErroryour caller must decode. Keep it (it is your last line of defence against paths you don't own) but let the domain refuse first, in domain language, before side effects have been produced. - "If I raise an exception, the invariant is enforced."
- Only if the raise sits on the sole path to the state. An exception inside a method that a public mutable attribute lets callers bypass enforces nothing at all.
When this is the wrong move
Push a rule inward and you also push a decision inward. Do not do it when:
- The type is a message, not a model. DTOs, event payloads and API request objects should carry whatever the sender actually sent, including the invalid parts — you cannot report a good error about a value your parser refused to construct. Validate at the seam between the message and the domain, in one place, and keep the message type dumb.
- The rule is policy. Thresholds, limits and eligibility windows that a product manager changes are configuration with an owner and an audit trail, not constants welded into a constructor. The invariant is "a limit exists and is respected"; the number is data.
- The object cannot see the data. Uniqueness, "no more than fifty in the queue today", "the sum across all lines" — set-level rules belong to the thing that owns the set. Faking them inside a single object usually means the object secretly reaches for a repository, which is worse coupling than the check it replaced.
- You are reading, not changing. Read models, analytics rows and imported history are frequently invalid by the current rules, because they were created under older ones. Force them through today's constructors and yesterday's data becomes unloadable.
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.
In the lab: the phase-1 checkpoint gives you the returns-resolution service with its refund rule checked in one HTTP handler and ignored by the retry job and the bulk importer. Your task is to find every writer of the state, move the rule into the object that owns it, and delete the guards that are now unreachable.
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
- In two sentences, tell a junior why moving a check into an object is different from extracting it into a shared helper both callers use.
- Find one rule in code you touched this week that lives in callers. Count its write paths, and say what the next forgotten one would cost — in incident time, not in style points.
- Your team doubles and half the current call sites are rewritten. Predict which of your hopeful conventions breaks first, and what the failure will look like to the person on call.
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.
- Object-Oriented Software Construction (2nd ed.), Meyer — ch. 11 "Design by Contract": the primary source for class invariants, preconditions and postconditions, and the argument that a contract is enforcement rather than documentation.
- 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.