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.

Reading ≈ 9 minPractice ≈ 25 min

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.

The principle behind the principle

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.

† 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.
What people get wrong after reading this
"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 IntegrityError your 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:

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
Teach it back
  1. 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.
  2. 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.
  3. 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

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