Software Design Reference System Architect · State, Invariants, and Components

Lesson 0055 · System Architect · Module 2

Protect Invariants at the Consistency Boundary

An invariant is a rule the system cannot break — an order without line items, a completed task edited, a refund beyond the payment. Where it lives and what changes with it decide your components and your transactions.

Reading ≈ 12 minPractice ≈ 30 min

Mission tie-in: code that makes invalid states impossible is this course's promise. Here you decide where that impossibility is enforced — one method, one component, or one transaction.

Knowledge: name the rule, then place it

Lesson 0011 established the code-level rule: make invalid states unrepresentable. A hard invariant is a state condition that must hold after every committed change. Keep it distinct from an authorization check (who may act), a precondition (what must be true before one operation), and a cross-boundary reconciliation target that may be temporarily false:

Invariants · plain statements
RuleKindWhat it forbids or controls
A submitted order has at least one line item.Hard invariantAn empty submitted order.
A completed task cannot be edited.Hard invariantA persisted late edit.
A refund cannot exceed the captured payment.Hard invariant when owned togetherAn over-refund.
Only the assignee or manager may complete a task.Authorization preconditionAn unauthorized command.

Stating a rule is the easy part. The design work is placing it, and placement runs on four questions:

Larman's operation contracts are the formal version of this table: each operation names its actor, its preconditions (what must hold), and its postconditions (what changed). The rule column of your state machine from lesson 0054 is a precondition; the resulting state is a postcondition.

Then comes the system question: how many facts must change together? The answer is the consistency boundary. Consider the return with required evidence:

Two consistency boundaries · same domain
InvariantBoundaryWhy
“A return cannot be refused while evidence is pending.”Return + evidence, one componentThe refusal rule reads the evidence; they change together.
“Every accepted return eventually receives the matching refund.”Return and payments, two componentsThis is a reconciliation target with a time bound, not an always-true invariant.
“The customer's address must be current.”Customer owns itNot a return rule at all — consume the fact.
The principle behind the principle

The consistency boundary is the transaction, not the entity. An invariant is a predicate over a set of facts; it can only be guaranteed if every fact it reads and writes moves inside one atomic, isolated unit. So the direction of reasoning runs: the rule names its facts → the facts determine the transaction → the transaction determines the component. Designers who start from the object model and then ask “what should be in a transaction?” have the arrow backwards, and end up with rules that are true in the tests and false under concurrency.

The corollary is worth saying plainly: any rule whose facts span two transactions is not an invariant but a target with a repair loop. Calling it an invariant anyway is how a system acquires bugs that appear only under load — two requests that each satisfy the rule commit interleaved and jointly break it.

Where does the principle bend? Two places. Advice to keep consistency boundaries small is a contention heuristic, not a correctness one: inside a single relational database, a boundary spanning four tables costs you nothing until measured lock waits say otherwise, so let the rule set the size and let contention shrink it. And the reverse — a boundary that has become a bottleneck — is not fixed by quietly widening or dropping the transaction. It is fixed by renegotiating the rule with the business, because the rule is what you are about to weaken.

Two heuristics find the boundary. The invariant test: is there a rule that reads two facts and must never observe them inconsistent? Those facts belong in one consistency boundary — typically one component and one transaction. The change test: do these facts always change together under one operation? If every write touches both, they belong together.

Beyond one component — especially across a network — exact atomicity may be unavailable or too costly, and the rule must be restated honestly. It is no longer an always-true invariant; it becomes a reconciliation condition with detection time, retry behavior, compensation, and an owner. For example: “within five minutes of acceptance, either the matching refund exists or an alert is owned.” This preserves precision instead of calling temporary inconsistency an invariant (lesson 0032).

The mistake to avoid is declaring everything one consistency boundary "so it stays consistent." That trades a fast boundary for a global one, and the global boundary becomes a bottleneck that eventually gets broken under load — without redesigning the rules. Invariants are the map; transactions are the territory, not the other way around.

# A guard as behavior: the only way into the state passes the rule.
from dataclasses import dataclass, field


@dataclass
class ReturnRequest:
    return_id: str
    required_evidence: frozenset[str]
    _decision: str | None = field(default=None, init=False, repr=False)
    _evidence: set[str] = field(default_factory=set, init=False, repr=False)

    @property
    def decision(self) -> str | None:
        return self._decision

    @property
    def evidence(self) -> frozenset[str]:
        return frozenset(self._evidence)

    def attach_evidence(self, item: str) -> None:
        if self._decision is not None:
            raise ValueError("decision already made; evidence closed")
        self._evidence.add(item)

    def refuse(self, reason: str) -> None:
        if self._decision is not None:
            raise ValueError("already decided")
        if not self.required_evidence <= self._evidence:
            raise ValueError("cannot refuse while evidence is pending")
        self._decision = f"refused: {reason}"

    # The public API exposes read-only views; supported changes pass
    # through guarded methods. Python's underscore is a convention,
    # so tests and review must also protect this module boundary.

Worked: an invariant that cannot fit in one transaction

“A refund must never exceed the captured payment” is the returns system's most expensive rule to get wrong, and it is not enforceable the way the empty-order rule is. The captured amount lives in the payment platform. The returns service cannot open a transaction there.

The architect's first move was not to draw components. It was to write the rule's fact list — captured amount, refunds already issued, the refund being requested — and then ask, for each fact, who owns it and when is our copy true? The captured amount is owned by payments and changes without telling us (a partial capture, a chargeback). Refunds already issued are owned by payments too. Only the request is ours. That is the whole problem in one line: we own none of the facts the rule reads.

Three designs were on the table.

Refund invariant · three designs, one chosen
DesignWhat it buysWhy it was rejected or accepted
Distributed transaction across the payment vendorA real atomic guarantee.Rejected: the vendor's API has no prepare phase, and a two-phase round trip alone would consume most of the 800 ms p95 decision budget.
Copy the captured amount into the return at acceptanceA local check, one query, no coupling.Rejected: the copy is stale by construction. Two concurrent refunds each read “captured 80, refunded 0” and both commit 60.
Payments issues a reservation; returns holds itThe fact we check becomes a fact we own.Accepted: the reservation is ours for its lifetime, so the invariant is local and atomic again.

The chosen design is worth understanding as a general move rather than a payments trick. The rule was unenforceable because we owned none of its facts, so the design changed which fact the rule reads: payments authorises an amount against the capture and hands back a reservation with an identity and an expiry; the returns service's rule becomes “the sum of refunds issued against this reservation must not exceed it”. Every fact in that sentence is now inside one consistency boundary, and the boundary is atomic because a single row's version guards it.

# The boundary is the transaction: load, decide, and commit as one unit.
from dataclasses import dataclass, field


class InvariantViolated(Exception):
    pass


@dataclass
class RefundReservation:
    """One consistency boundary: the reservation and the refunds against it."""

    return_id: str
    reserved_cents: int              # authorised by payments; ours until it expires
    version: int = 0                 # optimistic lock — the atomicity you can afford
    issued: list[int] = field(default_factory=list)

    def issue(self, amount_cents: int) -> None:
        if amount_cents <= 0:
            raise InvariantViolated("a refund must be positive")
        if sum(self.issued) + amount_cents > self.reserved_cents:
            raise InvariantViolated("refund would exceed the reservation")
        self.issued.append(amount_cents)


def refund(store, return_id: str, amount_cents: int) -> None:
    """The rule is checked and committed inside one transaction, or not at all."""
    with store.transaction():
        reservation = store.load_for_update(return_id)   # SELECT ... FOR UPDATE
        reservation.issue(amount_cents)                  # the guard, on fresh state
        store.save(reservation)                          # fails if version moved


# Two concurrent refunds of 60 against a reservation of 80: the second one
# reloads, sees 60 already issued, and is refused rather than silently paid.

What remains across the seam is honest and small: the reservation might expire, or payments might reverse a capture after we refunded. That is a reconciliation condition, and it is written the way lesson 0049 writes them — within five minutes of a settlement, either the refund matches a live reservation or an alert is owned by the on-call engineer. At 3,000 returns a day the reconciliation job finds a handful of mismatches a week, each with a name attached, instead of an over-refund class of bug nobody discovers until finance closes the quarter.

That is the consequence worth internalizing: a rule enforced inside a boundary costs one guard and one test, forever. A rule “enforced” by everyone remembering costs a little more with every new caller, every new integration, and every engineer who joins after the incident report was archived.

Field notes · placement questions
QuestionIf yesIf no
One rule reads both facts?Same consistency boundary.Separate; converge later.
One operation always writes both?Candidate for one boundary.Separation may be cheaper.
Is a moment of inconsistency observable?Think twice.Eventual is fine.
Does the rule span a seam?Define a reconciliation condition and SLO.A local guard may suffice.

Terms like aggregate name one way to package a consistency boundary inside an object model. It is a useful technique when you have it; it is not required to design well. The questions above — where the rule lives, who can enforce it, what changes with it — produce the same boundaries without the vocabulary.

What people get wrong after reading this
“Consistency boundaries should always be small.”
Small is a contention heuristic, not a correctness rule. Inside one relational database, a boundary spanning four tables costs nothing measurable until lock waits say otherwise. Let the invariant set the size; let evidence of contention shrink it.
“Eventual consistency means the rule still holds, just a bit later.”
It means the rule does not hold, and something must notice and repair. If you cannot name the detection window, the repair, and the owner, you have not chosen eventual consistency — you have chosen an undetected violation.
“A database constraint is enough.”
Constraints are excellent backstops and poor spokespeople: they express set-level shapes, not conditional business rules, and they fail with a driver error rather than a domain refusal your caller can act on. Use both — the guard for meaning, the constraint for the write that bypassed it.
“One big transaction is the safe choice.”
It converts a correctness question into a throughput failure. At 40 requests a second the deadlocks and lock waits arrive instead of the bug, and the usual response under pressure is to remove the transaction entirely — which is how a system ends up with neither guarantee nor performance.

When this is the wrong move. Not every rule deserves an atomic boundary. Where a violation is cheap to detect and cheap to repair — a counter that drifts, a search index that lags, a denormalized display total — a nightly reconciliation costs far less than the coordination an atomic guarantee would need, and buys the same business outcome. Read-mostly reference data and reporting stores need neither. The test is a price comparison, not a principle: multiply the violation's cost by its expected frequency and compare it against the latency, coupling, and operational burden of enforcing it atomically. If the comparison is not close, you have your answer; if it is close, prefer the one you can change later.

Skill: place the rule

An invariant is best enforced by…

The consistency boundary should contain…

A condition spans two services with no shared transaction. The design should…

Practice: name and place your rules

Take one entity from your state machine, or use the supplied return request. Classify each rule as a hard invariant, authorization/precondition, or reconciliation condition. For hard invariants, answer where the rule lives, who can enforce it, how callers could bypass it, and what failure means. Group facts that require atomic change. For one cross-boundary condition, write its detection deadline, retry or compensation, escalation, and owner.

In the lab and the capstone: the companion lab's phase-5 checkpoint contains at least one rule that is checked in a caller rather than at the boundary that owns the facts. Find it by asking which state can be reached without passing a guard, then move the rule and add the test that fails on the old path. The worked capstone states each rule, its boundary, and its repair loop in the state and contracts artifact.

Reveal: the repair test

For every cross-boundary condition, write: “When X observes [gap], [system] detects it within [time], and [recovery] restores [required condition] or escalates to [owner].” If you cannot fill the blanks, you have not designed the guarantee — you have only asserted it.

Teach it back
  1. Explain to a senior engineer, in two sentences, why the transaction rather than the entity determines the consistency boundary.
  2. Take one rule you rely on this week. List the facts it reads, name their owners, and say whether all of them can be read and written in one atomic unit — and what you would change if not.
  3. Predict the damage: if a cross-service rule is documented as an invariant with no detection or repair, name what goes wrong, when someone notices, and who notices first.

Your win

You can state rules plainly, place each where it can be enforced, keep the guarantees that must be atomic inside one consistency boundary, and design explicit repair for the rules that must tolerate a moment of inconsistency.

Read and watch deeper

Ask your agent-teacher to attack your guards with a generated sequence of legal-looking calls that violate a rule — then make it justify why each attempt is or is not a real risk.