Invariants
A balance that cannot go negative, a username that is unique, an order that cannot ship before payment, a tenant that cannot see another tenant. These are not features — they are the properties everything else is built on top of.
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.
What is the difference between a rule my code checks and a property my system guarantees?
"Customers can spend from their wallet balance." Nobody writes down "and the balance must never go negative", because it is too obvious to say — which is precisely why it ends up enforced nowhere in particular.
Check it where the money is spent: if (wallet.balance < amount) throw new InsufficientFunds(). It is the obvious place, it reads clearly, and it is exactly where the rule belongs.
It is right and it is not enough, which is the hardest case to argue about. The check is correct on the path it is on; the invariant is a claim about *every* path, and there are four.
- It is right and it is not enough, which is the hardest case to argue about. The check is correct on the path it is on; the invariant is a claim about *every* path, and there are four.
- The subscription job debits directly because it was written by a different team in a different quarter, and it has its own check — copied, and now three months out of date on the fee rules (Duplicate Knowledge).
- Two concurrent debits both read a balance of 100 and both pass a check for 60. Neither did anything wrong; the invariant broke between the read and the write, which is a property no amount of checking in application code fixes on its own (Shared-State Coupling).
- A support engineer issues a manual adjustment through an admin tool that writes the balance column directly, because that tool predates the wallet service by two years (Invariant Leaks).
- A data migration sets balances in bulk during a schema change. It is a script, it runs once, and it is the single most common way a supposedly-guaranteed invariant is violated in practice.
- The system now has a check in four places and a guarantee in none, and the distinction between those two is what this module exists to make visible.
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.
- The wallet is debited by the checkout flow, by a subscription renewal job, by a support tool and by a partner API.
- Refunds credit the wallet asynchronously, from a webhook that can arrive twice.
- Finance reconcile the wallet ledger against the payment provider monthly, and a discrepancy is an incident.
- A wallet balance is never negative.
- The balance equals the sum of the ledger entries, at every moment an observer can look.
- A username identifies exactly one account.
- An order is never shipped before it is paid.
- A tenant's data is never returned to another tenant.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One thing owns the invariant. Not one thing checks it — one thing is *responsible* for it holding, and can be pointed at when it does not.
- That owner must sit on every path that can change the state, which is a much stronger requirement than being on the path you were thinking about.
- Everything else is responsible for going through the owner, and that obligation has to be enforced by something other than everyone remembering (Enforcing Invariants).
- An invariant defines a boundary: the set of things it constrains must be modified together, by something that can see all of them. That is what makes "balance equals the sum of the ledger" a boundary and not just an assertion (Consistency Boundaries).
- The boundary is drawn by the invariant, not the other way around. This inverts how boundaries are usually chosen — by layer, by folder, by team — and it is the single most useful reordering in this domain.
- An invariant spanning two boundaries is a design smell with a specific meaning: either the boundaries are wrong, or the invariant is not actually an invariant and has to be restated as something eventually true (Backend Engineering covers what you get instead; here the point is that the boundary is wrong or the claim is).
Four invariants, and what each one is really a claim about
The value of writing an invariant as a "never" sentence is that it forces you to say what it constrains and over what scope. Do that for four ordinary examples and they turn out to be four different kinds of claim, needing four different enforcement mechanisms.
That variety is the point. There is no single right place for invariants, and any advice that gives you one — "put it in the domain model", "put it in the database" — has stopped looking at the specific claim being made.
- A balance is never negative. A claim about one row, checked against a value in the same row. Cheap to enforce in storage, and the concurrency question is the whole difficulty (Shared-State Coupling).
- A username identifies exactly one account. A claim across all rows of a table. Only enforceable where all rows are visible at once, which in practice means a unique index — application code cannot do this correctly under concurrency, ever (Concurrency & Parallelism shows the same shape as a race).
- An order is never shipped before it is paid. A claim about the *ordering of two state changes* in different aggregates. Not expressible as a constraint on a row; needs a state machine and a guard (State Machines).
- A tenant's data is never returned to another tenant. A claim about every read, not every write — which makes it the odd one out and the reason it is so often missed (Trust Boundaries).
- The balance equals the sum of the ledger. A claim relating two representations of the same fact, which is a consistency boundary and forces them into one transaction, or forces you to accept a window and reconcile (Consistency Boundaries).
How a guaranteed property stops being guaranteed
None of these is exotic. Each has happened to a system whose team would have told you, sincerely, that the invariant was enforced — and the sincerity is the interesting part, because the belief was formed by reading the code on the path they had in mind.
Read the cause column as the diagnosis. In four of the five rows the check was correct and the coverage was not.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A second writer appears | Balance goes negative through the renewal job | The check was on a path, not on the state | Move enforcement to something every writer passes through (Where Invariants Live). |
| Two concurrent debits | Both pass the check, the second overdraws | Read-check-write with no atomicity; the state changed between the read and the write | A conditional update, a lock, or a constraint the database evaluates at write time (Concurrency by Design). |
| An admin tool older than the rule | A support adjustment sets a negative balance | An ownership gap — the tool was never told it had to go through the owner | Make the table unwritable except through the owner, or put the rule where the tool also passes (Invariant Leaks). |
| A data migration | Bulk update leaves 40 accounts negative | Scripts bypass application code by design, and often disable constraints deliberately | Re-assert the invariant as the last step of the migration and fail the deploy if it does not hold (Data Migration). |
| A refund webhook delivered twice | Balance is right, ledger has a duplicate, they disagree | A second invariant — balance equals the ledger sum — with no enforcement at all | Idempotency on the ledger write, keyed by the provider's event id (Idempotency by Design). |
| A new currency | A negative EUR balance offset by a positive USD one passes the check | The invariant was stated over a number, not over a money value | Put the unit in the type so the two are not comparable (Units in Names and Types). |
From a sentence to something the system actually holds
The gap this module is about is the gap between the first block below and the second. Both are written by competent engineers; the first is what an invariant looks like when it is a belief, and the second is what it looks like when it is a property.
Notice how little of the difference is cleverness. It is almost entirely a question of which paths the mechanism sits on.
1// ── A belief ────────────────────────────────────────────────2async function debit(walletId: string, amount: number) {3 const w = await db.wallets.find(walletId)4 if (w.balance < amount) throw new InsufficientFunds() // correct!5 await db.wallets.update(walletId, { balance: w.balance - amount })6}7// Correct on this path. Silent about: the renewal job, the admin8// tool, the migration, and two requests arriving together.9 10// ── A property ──────────────────────────────────────────────11-- migration: the rule, where every writer passes through it12ALTER TABLE wallets ADD CONSTRAINT balance_non_negative13 CHECK (balance >= 0);14 15-- and the atomicity, in one statement rather than three steps16UPDATE wallets SET balance = balance - $217 WHERE id = $1 AND balance >= $218RETURNING balance;19-- 0 rows affected == insufficient funds. No read-check-write gap.20 21// The application check stays — for the error message. It is now22// a convenience, and the code should say so.The RETURNING line is doing the work that the application-level if only appeared to do: the decision and the write are one operation, so there is no window between them. The constraint is the belt — it covers the admin tool and the migration, which the conditional update does not. Neither alone is the answer, and choosing between them is Where Invariants Live.
How to build it
Most important first.
- Write the invariants down as sentences containing "never" or "always", before anything else. If you cannot phrase it that way, it is a validation rule or a preference, not an invariant (Requirements Before Design).
- For each one, name the state it constrains and every path that can change that state. The paths are where the surprises are — the job, the admin tool, the migration.
- Choose the enforcement point that covers all of those paths, which is usually further down than feels natural (Where Invariants Live).
- Make violation as close to unrepresentable as your language and storage allow: a type that cannot hold a negative amount, a unique index, a check constraint (Making Illegal States Unrepresentable).
- Then, and only then, add checks higher up for error messages and user experience — knowing they are conveniences, not guarantees, and labelling them as such.
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.
- With the invariant owned in one place: adding a fifth writer — a new partner integration — costs one call through the owner and nothing else. Changing the rule ("balance may go negative up to an approved overdraft") costs one edit and one test, and every path picks it up.
- With the invariant checked in four places: adding a fifth writer costs a copied check that is correct today and drifts. Changing the rule costs finding all five, deciding whether each was meant to be the same rule, and having no way to be sure. Typically a week, and the residual uncertainty never resolves.
- The change that stays expensive under both: turning "never negative" into "never negative except for these accounts under these conditions". That converts a global invariant into a per-account policy, which changes what kind of thing the rule is — and no enforcement location makes that cheap (Requirements Are a Snapshot).
- What the single-owner design costs on every future change: every write path goes through one module, so two teams touching wallets in the same sprint conflict, and the module's test suite has to run for every change to any writer.
- Routing every writer through one owner is a coordination cost and a performance cost, and for a system with one writer it is pure overhead.
- Enforcing in the database buys coverage of every path and costs portability, migration flexibility and the ability to load data in a temporarily-invalid state.
- Stating invariants formally makes them arguable, which is mostly good and occasionally produces long meetings about whether something is really "never".
What can go wrong
- The invariant is enforced in the service and the service is bypassed. This is the dominant failure and it has its own lesson (Invariant Leaks).
- The invariant is enforced under single-threaded assumptions and breaks under concurrency — a read-check-write with no locking or conditional update (Backend Engineering has the mechanisms; here the point is that a check and a write are two operations).
- The invariant is enforced in four places and one of them is subtly different, so the system behaves differently depending on which door you came in.
- The invariant is stated but never asserted, so it drifts silently and is discovered by finance during reconciliation rather than by the software.
- And the mitigation's own failure: a database constraint added to enforce the rule rejects a legitimate write during a migration, taking down a service to protect a property that a bulk load was allowed to violate temporarily by design.
- The invariant owner is depended upon by every writer, which makes it a high-fan-in module and constrains what it may itself depend on (Fan-in and Fan-out).
- Enforcement in the database means a dependency on that database's features — a check constraint is not portable, and a design that leans on one has chosen a coupling (Volatile Dependencies).
- Enforcement in types means a dependency on the language's expressiveness, which is why the same design is stronger in one language than another (Choosing the Model).
- "Invariants are just validation." Validation rejects bad input at one door. An invariant is a property that must hold no matter which door was used, including the ones with no validation on them at all (Backend Engineering owns the edge check; this is the property behind it).
- "If I check it in the service, it is guaranteed." Only if the service is the only writer, and it almost never is. The job, the admin tool and the migration are the three that get forgotten, in that order.
- "Put every invariant in the database." Some cannot be expressed there — "an order cannot ship before payment" spans two aggregates and a time ordering — and some would cost more in write contention than the rule is worth (Enforcing Invariants).
- "This is the same as concurrency invariants." Related but different. Concurrency & Parallelism means what must hold between acquiring and releasing a lock, under interleaving. This means what must hold about the business state, on every path, forever — and it needs the concurrency answer as one of its mechanisms, not as a substitute (Concurrency by Design).
- duplicate-knowledge
- shotgun-surgery
Testing it, and how it ages
- Test the invariant as a property, not as an example: for any sequence of debits and credits, the balance is non-negative and equals the ledger sum (Property-Based Testing).
- Test each path independently — checkout, job, admin tool, partner API — because the invariant is a claim about all of them and a test of one proves nothing about the others.
- Test the enforcement mechanism by trying to violate it at the lowest level: write directly to the table in a test and assert the database rejects it. A constraint you have never seen fire is a comment.
- Add a reconciliation check that runs in production and alerts, because an invariant that is only asserted in tests is only guaranteed in tests (Debuggability by Design).
- Invariants are the most stable part of a system. "A balance is never negative" outlives the language, the framework and usually the company; the code enforcing it will be rewritten several times.
- That stability is the argument for organising the design around them: they are the thing worth building boundaries out of, because a boundary drawn around a stable property does not need to move (Stable Boundaries).
- What does change is their *scope*. Invariants get exceptions — overdrafts, grandfathered accounts, regulatory carve-outs — and an invariant with exceptions is on its way to becoming a policy, which needs a different home (Domain Services).
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 a property must hold on every path, not merely on the path you were considering, is a statement about reachability rather than about any language or architecture; what varies is which mechanisms are available to enforce it.
- DOMAIN-SPECIFICHow many genuine invariants a system has varies enormously. Financial, medical and access-control systems are dense with them; a content site or an analytics dashboard has very few, and applying this module's machinery there produces ceremony around properties nobody would notice being violated.
- CONTESTEDThe strongest opposing view: many things teams call invariants are business preferences that the business would happily relax under pressure — and designing rigid enforcement around them produces systems that cannot absorb the exception when it inevitably arrives, forcing support engineers into direct database edits that break far more than a soft rule would have. That is a real and common failure, and the honest response is that it is an argument for being ruthless about which properties are genuinely invariant, not for enforcing none of them rigidly.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — asserting an invariant continuously in production rather than only in tests, which is where reconciliation jobs and consistency checkers belong.