InvariantsGENERALLANGUAGE-SPECIFICCONTESTED

Enforcing Invariants

Types, runtime guards, database constraints and tests are four different mechanisms with four different coverages. Using all four is defence in depth, and it means four places to keep in sync — which is a trade, not a free win.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Should I enforce this rule in one place or in every place I can, and what does the second option actually cost?

The requirement

"An order cannot be shipped before it has been paid." A reviewer suggests enforcing it in the state machine, in a check constraint, in the shipping API contract and in a test — all four, because it is important.

The obvious build

Enforce it everywhere. Belt and braces. A rule this important should be impossible to violate, so put a guard at every layer and sleep well.

Why it breaks

Four enforcement points is four definitions of "paid", and they will drift. The first partial capture arrives and the state machine says paid, the constraint says paid, and the API contract — written earlier, by someone else — says payment_status = 'captured' and rejects (Duplicate Knowledge).

How it breaks as requirements change
  • Four enforcement points is four definitions of "paid", and they will drift. The first partial capture arrives and the state machine says paid, the constraint says paid, and the API contract — written earlier, by someone else — says payment_status = 'captured' and rejects (Duplicate Knowledge).
  • Drift is not the only cost. Every change to the rule now costs four coordinated edits across two languages and a migration, so the rule becomes expensive to change, and expensive-to-change rules acquire workarounds (Change Amplification).
  • The layers also fail in different directions. A type refuses to compile, a guard throws, a constraint rejects a transaction and a test fails in CI — four error experiences, and the one users see is whichever fires first, which is usually the least informative.
  • Meanwhile the single-point-of-truth position has its own failure: one enforcement point is one thing to bypass, and the whole previous lesson was about how routinely it is bypassed (Invariant Leaks).
  • The honest position is that neither slogan wins. Defence in depth buys coverage and costs synchronisation, and the right number of layers depends on how many writers you do not control and how expensive a violation is.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The orders table is written by the order service, the fulfilment integration and a nightly reconciliation job.
  • Payment status arrives asynchronously from a provider webhook, so "paid" is not always known at the moment shipping is requested.
  • The fulfilment partner's integration is a batch file, not an API call, so a rejection is discovered hours later.
Invariants
  • No order is dispatched to the fulfilment partner while its payment is not captured.
  • The four enforcement points, if there are four, agree about what "paid" means at every boundary case — including a partial capture and a capture that later reverses.
  • A violation is detectable after the fact, because a batch integration means prevention is not always available.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Exactly one enforcement point is the *authority* — the definition every other point is derived from or defers to. Multiple layers are fine; multiple authorities are not.
  • Each additional layer owns a stated purpose: earlier failure, better errors, or coverage of a path the authority does not reach. A layer with no stated purpose is a layer that will drift unnoticed.
  • Somebody owns the boundary-case definition — what "paid" means for a partial capture — and that definition lives in one place that the other layers reference (Ubiquitous Language).
Boundaries
  • The authority sits at the narrowest point all writers share. Everything above it is a boundary for ergonomics, not for correctness (Where Invariants Live).
  • A layer that cannot see enough context to evaluate the rule is not an enforcement point at all — a check constraint cannot know whether a payment was later reversed, and pretending it can produces a rule that is subtly wrong in exactly the cases that matter.
  • For a batch integration the boundary shifts from prevention to detection, and that changes the design rather than weakening it (Designing for Failure).

Four mechanisms, scored on what they actually buy

These are not four points on one axis. They differ in when they fire, what they cover, what they cost to change and what they can express, and a design usually wants more than one — the question is which, and derived from what.

The caveat below the matrix is the important text on this page; the scores are a way of getting you to read it.

"An order cannot ship before payment", by enforcement mechanism
OptionSimplicityFlexibilityPerformanceTestabilityOperationalMigration costNote
Types — `PaidOrder` is a distinct type from `Order`Shipping takes a PaidOrder, so an unpaid one cannot be passed. Zero runtime cost and the strongest guarantee available inside your own code. Covers nothing that arrives by deserialization or lives in the database, and expressing it well needs a language that makes the conversion a parse rather than a cast.
A runtime guard in the state machineReadable, gives a good error, easy to extend with the partial-capture case. Covers only callers that go through it, and is not safe against two concurrent requests unless the write is conditional.
A database constraintCovers every writer including the reconciliation job and future code. Cannot express "unless the payment was later reversed" without a trigger, produces an error users must never see, and adding or changing it on a large table is a migration with a lock.
TestsCheap, expressive, and the only mechanism that can assert a *property* over generated histories. Enforces nothing at runtime, nothing about existing data, and nothing about the batch file the partner sends back.
All four, hand-writtenMaximum coverage and four definitions of "paid". The score that matters is migration: a change to the rule is now a coordinated release across two languages and a schema.
All four, derived from one predicateThe recommendation, and it is not free: the shared predicate becomes a high-fan-in module that four subsystems depend on, and the database layer can only be derived by generating the constraint, which most teams will not do.

caveat These scores compare mechanisms in the abstract and hide the only variable that decides real cases: how many writers you do not control. With one writer and a strong type system, the top two rows are sufficient and everything below is ceremony. With an admin tool issuing SQL, a batch partner and a decade of migration scripts, the third row is the only one that is a guarantee at all and its low flexibility score is the price of that. The matrix also cannot express failure *direction*: a type failing at compile time and a constraint failing at 3am in production are both "enforcement", and they are not remotely the same event.

Derived layers versus copied layers

This is the whole practical content of the lesson, and it is smaller than the argument around it. Four checks are dangerous when they are four statements of the rule and fine when they are four uses of one statement.

The second version is not obviously better on first reading, which is why teams keep writing the first. Its advantage appears only at the moment the rule changes.

The same four layers, stated four times and stated once
Four independent definitions
// domain/order.ts
if (order.paymentStatus !== 'captured') throw new NotPaid()

// api/ship.ts  — written six months earlier
if (!['captured', 'settled'].includes(body.payment_status))
  return res.status(422).json({ error: 'unpaid' })

-- migration 0142
ALTER TABLE orders ADD CONSTRAINT ship_requires_payment
  CHECK (shipped_at IS NULL OR payment_status = 'captured');

// test/ship.test.ts
expect(() => ship(order({ paymentStatus: 'pending' }))).toThrow()

// Then: partial capture ships. Is 90% captured "paid"?
// Four places answer, and two of them already disagree
// about 'settled'.
One definition, four uses
// domain/payment-state.ts — the authority. Pure, no deps.
export function isPayableForShipping(p: PaymentState): boolean {
  if (p.reversedAt) return false
  return p.capturedMinor >= p.requiredMinor * 0.9
}

// domain/order.ts
if (!isPayableForShipping(order.payment)) throw new NotPaid()

// api/ship.ts — same function, different error shape
if (!isPayableForShipping(order.payment)) return unpaid422(res)

// test/ship.test.ts — tests the authority as a property,
// and each layer only for what IT adds (the 422, the throw).

-- migration: the constraint stays deliberately WEAKER —
-- it cannot see reversals, so it enforces the part it can.
CHECK (shipped_at IS NULL OR payment_status <> 'pending')
-- comment on constraint: "coarse net for writers that bypass
-- the domain; the authority is isPayableForShipping()."

The change that arrives — partial captures count, reversals do not — is one edit in the second version and four in the first, two of which nobody will remember to make. The second version also does something the first cannot: it lets the database layer be deliberately *weaker* than the authority and says so, which is the honest way to use a mechanism that cannot see enough context. What it costs is a shared pure module that four subsystems now depend on, and a discipline of routing every check through it that nothing but review enforces.

When prevention is not available

SIMPLIFIEDThe reconciliation is shown as a single pass for clarity. A real one needs a cursor so it is resumable, a bound on how many orders it holds in one run so a systemic payment outage does not page a human four thousand times, and a decision about whether holding a shipment is itself reversible — all of which are design questions this snippet skips to keep the shape visible.

The batch integration is the case that breaks the whole framing: the fulfilment partner receives a file and acts on it hours later, so there is no moment at which a guard can refuse. Prevention is genuinely impossible and pretending otherwise produces a design that is wrong in a way nobody notices.

What replaces it is detection with a defined remediation, and the code below is the shape of that. It is worth being explicit that this is a weaker promise, because a design that quietly downgrades from "never" to "usually within an hour" and does not say so is misleading its own operators.

Detection as a first-class part of the design, not a fallback
1// The invariant we can actually hold:
2// "No order stays dispatched-and-unpaid for more than 15 minutes
3// without a human being told."
4// Note that this is NOT the invariant we wanted. Say so.
5
6export async function reconcileDispatched(now: Instant) {
7 const suspect = await orders.dispatchedButNotPayable({
8 olderThan: now.minus(Duration.minutes(15)),
9 })
10
11 for (const o of suspect) {
12 // Remediation is part of the design, not of the runbook.
13 await holdShipment(o.id, 'payment_not_confirmed')
14 await alert.page('order.dispatched_unpaid', { orderId: o.id })
15 }
16
17 // The metric matters more than the loop: a reconciliation that
18 // has never fired is indistinguishable from one that is broken.
19 metrics.gauge('orders.dispatched_unpaid', suspect.length)
20 metrics.counter('orders.reconcile_runs').inc()
21}

The two metrics at the bottom are the part that is usually missing, and they are what make this a design rather than a hope. A detection mechanism with no signal that it ran is the classic case of a mitigation with its own silent failure mode — and the run counter, not the violation gauge, is the one that catches it (Designing for Failure).

How to build it

Most important first.

  • Pick the authority first, on coverage grounds, and write down which paths it covers (Where Invariants Live).
  • Add each further layer only with a named reason, and record the reason next to the code. "Also checked in the API so clients get a 422 rather than a 500" is a reason; "defence in depth" is not.
  • Derive rather than duplicate wherever the mechanism allows it. One isPayable(order) function used by the guard, the API validator and the test is one definition with three call sites; three hand-written conditions are three definitions (DRY: Knowledge, Not Lines).
  • Prefer mechanisms that make the illegal state unrepresentable over ones that detect it, because an unconstructable value needs no synchronisation with anything (Making Illegal States Unrepresentable).
  • Where prevention is impossible — the batch file, the asynchronous capture — design detection deliberately: a reconciliation with an alert and a defined remediation, not a hope (Debuggability by Design).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • One authority plus derived layers: changing what "paid" means — say, a partial capture over 90% now counts — costs one edit to the shared predicate, plus a migration if the constraint encodes it. Roughly a day, and the layers cannot disagree because they do not each hold a copy.
  • Four hand-written layers: the same change costs four edits in two languages, a migration, a deployment order that must not leave the constraint stricter than the code, and a period during which the API and the domain disagree. Roughly a week, and the failure mode is silent partial rollout (Expand and Contract).
  • One layer only: the change is a single edit — cheapest of all — and the coverage was never there, so the cost shows up as an incident instead of as engineering time. That is the trade this lesson is about: the cheapest design to change is not the cheapest design to own.
  • What derived layers cost on every future change: the shared predicate is on the critical path of four subsystems, so it can never be changed casually, and every one of its callers has to be considered even when the change was aimed at one.
What the recommended approach costs
  • Deriving every layer from a shared predicate is the recommendation here, and it creates exactly the kind of high-fan-in shared module this domain warns about elsewhere. That tension is real and is not resolved by a slogan.
  • A single strong authority in the database gives up the ability to be temporarily invalid, which legitimate operations sometimes need.
  • Detection instead of prevention is honest where prevention is impossible and is also a decision to let a bad state exist for a while, which some domains cannot accept at any price.

What can go wrong

Failure modes
  • Layers drift and the system behaves differently depending on the door used, which produces bug reports that reproduce for one team and not another.
  • A layer is removed during an unrelated change because nobody knew why it was there, and the coverage gap is silent.
  • Defence in depth becomes an excuse not to decide: four weak checks feel safer than one strong one and are not, because none of them is on every path.
  • The reconciliation that backs a detection-based design is written, deployed and never alerted on, so a violation is discovered by a customer. The mitigation failing quietly is the standard way detection designs go wrong.
  • The shared predicate becomes the coupling point: isPayable acquires parameters for each caller's special case, and the single definition becomes a switch on who is asking (Boolean Parameters).
Dependencies, and their direction
  • Each layer creates a dependency on its mechanism: the type on the language, the constraint on the database engine, the contract on the API version (Volatile Dependencies).
  • Derived layers depend on the shared predicate, which becomes a high-fan-in module and must therefore be pure and dependency-free (Fan-in and Fan-out).
  • Detection depends on both representations being independently readable, which is why an event log or a ledger makes reconciliation possible and an in-place update does not.
Misreads
  • "Defence in depth is always good." It is a security principle about independent controls against an adversary. Applied to business rules it also multiplies definitions, and definitions that drift are a source of bugs rather than of safety (Security Engineering owns the version where it is unambiguously right, because there the layers face an adversary rather than a definition).
  • "Tests enforce invariants." Tests enforce them against the code under test at build time. They say nothing about a migration run at 2am or about data that already exists (Characterization Tests).
  • "Types make it impossible, so nothing else is needed." Types are bypassed by every deserialization boundary and by every row already in the database. They are the strongest mechanism inside the code you own and cover nothing outside it.
  • "So use exactly one enforcement point." That is the opposite over-correction. One point is right when it is genuinely on every path; where it is not, additional layers are buying coverage that matters, and the discipline is to name what each one covers rather than to minimise the count (Where Invariants Live).
Smells this explains
  • duplicate-knowledge

Testing it, and how it ages

What to test, and at which boundary
  • Test the authority hard, with properties and boundary cases: partial capture, reversal after ship, capture arriving after dispatch (Property-Based Testing).
  • Test each derived layer only for the thing it adds — that the API returns a 422, that the type will not compile, that the constraint rejects raw SQL — rather than re-testing the rule four times (What a Unit Is).
  • Write one test that asserts the layers agree on the boundary cases. If that test is hard to write, the layers are already independent definitions.
  • Treat tests as a fifth enforcement mechanism honestly: they enforce the rule against code that exists at CI time and against nothing else. A test is not a guarantee about production data (Refactoring Without Tests).
How this design ages
  • Layer count tends to grow monotonically, because adding a check is easy and removing one requires proving it is redundant. Left alone, a five-year-old rule has six enforcement points and two definitions.
  • The pressure that eventually forces a cleanup is a rule change that has to touch all of them, and the cleanup is much easier if each layer recorded its reason.
  • The design ages well when the authority is the lowest layer and everything above is derived, because that is the arrangement where adding a writer is safe and changing the rule is a single edit.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat every additional independent statement of a rule is another thing to keep in sync follows from there being more than one copy; it holds regardless of the mechanisms available, which only change how tempting each copy is.
  • LANGUAGE-SPECIFICWhere sum types and exhaustive matching exist, the "type" layer genuinely removes cases rather than checking them, so fewer layers are needed and the type is not a copy of the rule but the rule itself. Without them, the same design is a runtime guard plus a test, and the argument for a database constraint is correspondingly stronger.
  • CONTESTEDThe strongest opposing view: redundant enforcement is cheap insurance and the drift concern is overstated, because in practice the layers are exercised by the same tests and a divergence shows up immediately — whereas a single enforcement point is one review away from being bypassed, and the cost of a violated financial invariant dwarfs the cost of maintaining four checks. That argument is strong wherever a violation is catastrophic and irreversible, which is exactly where regulators and payment systems demand redundancy. Its weakness is empirical rather than theoretical: the layers are usually *not* exercised by the same tests, because each was added at a different time by someone solving a different problem.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Concurrencyinvariants
Domains that do not exist yet
  • Testing & Reliability Engineering — how much confidence a test layer actually provides compared with a runtime guard, and how to decide when a property test has covered enough of the state space to count as enforcement.