Where Invariants Live
Every invariant is enforced somewhere specific — a type, domain logic, a transaction, a database constraint, an API contract — and the design question is which, because each covers a different set of paths at a different price.
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.
I have written the rule down. Which layer is actually responsible for making sure it holds?
The wallet again, stated properly: "a wallet balance is never negative, and the balance always equals the sum of its ledger entries." Five writers, one of which is a migration script, and finance reconcile monthly.
Put it in the domain model. The wallet is a domain object, the rule is a domain rule, so Wallet.debit() should refuse. That is what a domain model is *for*.
It is a good answer that covers one of five paths well and two of them not at all. The domain model protects code that constructs a Wallet; it says nothing about SQL issued by the admin tool or by a migration (Invariant Leaks).
- It is a good answer that covers one of five paths well and two of them not at all. The domain model protects code that constructs a
Wallet; it says nothing about SQL issued by the admin tool or by a migration (Invariant Leaks). - It also cannot, on its own, be correct under concurrency: two processes each holding a valid
Walletobject, each debiting legally, produce an illegal result. The rule held in both objects and failed in the database (Shared-State Coupling). - The opposite over-correction — put everything in the database — fails differently. "An order cannot ship before payment" spans two tables and a time ordering, and expressing it as a constraint means either a trigger nobody can test or a denormalised column that then has its own consistency problem.
- The deeper issue with "it belongs in layer X" is that it answers a question about *paths* with an answer about *architecture*. The two are only related through which writers actually pass through layer X, and that is a fact about your system, not about your style.
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.
- Postgres, shared with two other teams; check constraints and unique indexes are available, and adding one to a large table needs a maintenance window.
- TypeScript, so a type can make a negative amount hard to construct but cannot prevent someone deserializing one from JSON without a check.
- The admin tool is a low-code internal product that issues SQL directly and cannot be made to call a service.
- Debits are on the checkout critical path, with a 150ms budget.
- Balance is never negative, on every path, including paths that do not exist yet.
- Balance equals the sum of the ledger entries whenever anyone can observe both.
- Whatever enforces the rule can be pointed at by name when someone asks "what guarantees this?"
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One place is named as the enforcement point, and that name is written down somewhere a future engineer will find it — ideally in a comment on the constraint or the type.
- Every other check is explicitly a convenience, and says so, so nobody mistakes it for the guarantee.
- Somebody owns keeping the enforcement point on every path: when a sixth writer is added, somebody's job is to notice (Code Ownership).
- The enforcement point *is* a boundary — the narrowest thing every writer must pass through. Finding it is a question about the actual call graph plus the actual SQL, not about the diagram on the wiki.
- Where no such narrow point exists, you have discovered the real problem: the state has no owner, and the fix is an ownership change before it is a code change (State Ownership).
- For the ledger-sum invariant the boundary is a transaction, because the two facts must move together and nothing weaker relates them (Consistency Boundaries).
The five places, and what each one actually covers
Read the "covers" column as the only column that matters, and the rest as its price. An enforcement point that does not cover the migration script does not enforce anything about the migration script, however elegant it is.
Notice that no row covers everything. That is not a gap in the table — it is the reason this is a design decision rather than a lookup.
| Enforcement point | Covers | Does not cover | What it costs |
|---|---|---|---|
A type — NonNegative, Cents, TenantId | Every path in this language that constructs the value through the constructor | Deserialization, raw SQL, anything in another language, anything already stored | Friction at every boundary, and a parse step that must be disciplined (Backend Engineering: parse at the edge) |
Domain logic — Wallet.debit() | Every caller that goes through the domain model, with good errors and readable rules | Direct SQL, other services, jobs written before the model existed, concurrent callers | Nothing at runtime; it is the cheapest and the most porous |
| A transaction | Two or more facts moving together, atomically, for all writers in this database | Facts in another system; anything a writer does outside the transaction | Lock duration on the critical path, and it pins the facts into one storage system (Consistency Boundaries) |
| A database constraint or unique index | Every writer, including tools, jobs, migrations and future code nobody has written | Cross-table orderings, anything requiring application context, other datastores | Migration cost on large tables, poor error messages, and it blocks deliberate temporary invalidity |
| An API contract | Every external client, expressed as something they can read and code against | Internal callers, jobs, the admin tool, direct database access — i.e. most writers | Versioning obligations once published (Versioned Interfaces) |
The wallet, decided
Here is the decision made concretely for the running example, with the reasoning rather than a verdict. The answer is a combination, and the important part is that each element is chosen for the paths it covers rather than for where it belongs stylistically.
The thing to take from this is the method: list the writers, find the narrowest common point, use the strongest mechanism available there, and treat everything above it as ergonomics.
Which writers exist, and what is the narrowest thing all of them pass through?
when You want debits and credits to be hard to get wrong inside the code you own.
cost Covers arithmetic mistakes and nothing else — a balance loaded from a row bypasses the constructor entirely. Worth having; not the guarantee. Cheap, and it makes the other layers' code shorter (Units in Names and Types).
when You want a readable rule, good errors, and a place for the business logic to grow exceptions later.
cost Covers the three writers that use the domain model. Does not cover the admin tool or the migration, and is not safe under concurrency by itself. This is the ergonomics layer, and calling it the guarantee is the mistake this lesson exists to prevent.
when The debit is on a hot path and you cannot afford a lock, and you need the check and the write to be one operation.
cost Covers concurrency properly with no read-check-write window, at the cost of an unusual-looking call and a zero-rows-affected case every caller must handle. Does not cover a writer that does a plain UPDATE.
when There are writers you do not control — an admin tool issuing SQL, migrations, a reporting pipeline with write access.
cost Covers every writer, forever, including ones not yet written. Costs a maintenance window to add on a large table, produces an error message no user should see, and blocks a bulk load that would have been temporarily invalid. This is the guarantee.
when The related invariant — balance equals the ledger sum — spans two facts and cannot be a single constraint.
cost Covers detection, not prevention, and that is a genuinely different promise. It is the right answer for invariants that cannot be enforced synchronously, and the wrong answer when they can (Debuggability by Design).
What moving the enforcement point changes
The comparison below prices the arrival of a sixth writer, because that is the change this decision is really about. Both designs are competent; they differ in what happens when someone adds a path nobody reviewed.
A data team ships a pipeline that corrects historical balances from a reconciled ledger, writing directly to the wallets table because that is what their tooling does.
Nothing fails. No test breaks, no review flags it, and the invariant is now false for an unknown number of rows. It is discovered six weeks later during finance reconciliation, and the remediation is a data investigation rather than a code fix — nobody can say which negative balances were legitimate corrections and which were bugs.
The pipeline's first run against staging fails with a constraint violation naming the rule. The data team either fixes their query or comes and asks why the rule exists, and both outcomes are good. Cost: one afternoon, before any bad data exists.
NOT VALID / VALIDATE two-step to avoid a long lock. The error surfaced to users is a database exception that has to be translated, so a mapping layer exists that would not otherwise. And the finance team's legitimate mid-migration state — where balances are briefly inconsistent while a backfill runs — is now impossible, so that backfill needs a different design. Enforcing low genuinely takes options away; that is what makes it a guarantee.How to build it
Most important first.
- List every writer of the state, by inspection of the code *and* of database grants. The second list is usually longer than the first, and the difference is where the incidents come from.
- Find the narrowest thing all of them pass through. For the wallet that is the table, which is why the constraint is the guarantee and the domain model is not (Enforcing Invariants).
- Choose the strongest mechanism available at that point: a constraint beats a convention, a conditional update beats a read-check-write, an unconstructable illegal value beats a runtime check (Making Illegal States Unrepresentable).
- Add higher-level checks deliberately, for error quality and early failure, and label them as conveniences so nobody hardens a design on top of them.
- Write down, next to the enforcement point, which invariant it holds and what would bypass it. That sentence is what stops the constraint being dropped during the next migration (Architecture Decision Records).
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.
- Constraint plus conditional update in storage: adding a sixth writer costs nothing — the new writer either satisfies the constraint or gets an error on its first test run, which is the cheapest possible way to learn. Changing the rule to allow an approved overdraft costs a migration on a large table plus a code change, so roughly a day plus a maintenance window.
- Domain-model-only enforcement: adding a sixth writer costs a code review that has to notice the writer exists, and it fails silently if it does not. Changing the rule costs one edit — genuinely cheaper — but only applies to the paths that were already correct, so the change is cheap and the guarantee stays weak.
- The asymmetry worth internalising: enforcement *low* makes adding writers cheap and changing the rule expensive; enforcement *high* makes changing the rule cheap and adding writers dangerous. Which one you optimise for should follow from which happens more often in your system, and for business software it is almost always new writers.
- What the low enforcement costs on every change: the invariant is now expressed in a place with a slow, gated change process, and a rule change becomes a deployment sequencing problem rather than an edit (Designing the Migration).
- Storage-level enforcement is the strongest and the least flexible: it covers everything and it makes bulk loads, backfills and temporary inconsistency during migrations genuinely harder.
- Type-level enforcement is the cheapest at runtime and the most porous at boundaries, because every deserialization is a hole unless it is a parse (Backend Engineering covers the parse-at-the-edge discipline).
- Naming a single enforcement point makes the design legible and creates a single point of failure for the design's own understanding: if that comment is lost, so is the reasoning.
What can go wrong
- The chosen point is correct and invisible: the constraint exists, nobody knows why, and it is dropped during a performance investigation.
- The rule is placed in the domain model for aesthetic reasons and the team believes it is guaranteed. This is the most common failure in codebases that take design seriously — the belief is stronger precisely because the design is thoughtful.
- The rule is placed in the database and the application cannot produce a decent error, so a validation check is added on top, and eighteen months later the two disagree about the boundary case (Duplicate Knowledge).
- The transaction chosen to hold two facts together grows until it holds nine, at which point it is a lock on half the system and the mitigation has become the bottleneck (Consistency Boundaries).
- Enforcing in the database couples the invariant to that database's feature set and to its migration process — a constraint is now a thing that must be created, and created in the right order relative to code (Expand and Contract).
- Enforcing in a type couples it to the language's expressiveness and to every serialization boundary, since a value arriving from JSON has not been through the constructor (Backend Engineering has the parse-at-the-edge technique).
- Enforcing in a transaction couples the two pieces of state into one storage system, which is a real architectural commitment and the thing that most often forces a modular monolith to stay one (The Modular Monolith).
- "The domain model should own all business rules." A defensible position with a real cost: it is only a guarantee for callers that go through the domain model, and the writers that do not are exactly the ones nobody is thinking about (The Anemic Domain Model has the other half of this argument).
- "Database constraints are for data integrity, not business rules." The distinction is not as clean as it sounds. "Balance is never negative" is both, and refusing to use the mechanism that covers every path because of a category is how the rule ends up unenforced.
- "Just put it everywhere." That is defence in depth, it is sometimes right, and it costs four places to keep in sync. It is a decision, not a default (Enforcing Invariants).
- "If the API validates it, it is enforced." An API contract constrains what a client may send. It is silent about the job, the tool and the migration, which do not use the API (Error Boundaries).
Testing it, and how it ages
- Test the guarantee at the level it is made: if the constraint is the guarantee, the test writes bad data directly to the table and asserts rejection (Where a Test Must Be Real).
- Test the domain-level check separately, as a check on error quality rather than on correctness, and name the test so it is obvious which of the two it is.
- Test the concurrent case explicitly for anything that is read-check-write. Two overlapping debits is a four-line test and it is the one that finds the real bug (Concurrency by Design).
- Add a test that fails if the constraint is missing from the schema, so a future migration that drops it fails CI rather than production.
- Enforcement tends to migrate downward over a system's life: a rule starts in a handler, moves to a service, then to the domain, and finally to the database, and each move is triggered by the discovery of a writer that bypassed the previous point.
- That trajectory is worth short-cutting deliberately for invariants where a violation is expensive, because each step is discovered by an incident.
- It reverses only when the invariant gains exceptions. A rule with per-account carve-outs cannot live in a check constraint, and its move back up into a policy object is a signal that it was never really an invariant (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 an invariant is only guaranteed at points every writer passes through is a reachability argument, so it holds anywhere; what changes is the menu of enforcement mechanisms your language and datastore actually offer.
- LANGUAGE-SPECIFICIn a language with a strong type system and a parse-at-the-edge discipline, a large fraction of invariants can be made unrepresentable and the "which layer" question shrinks. In one where any value can be constructed from JSON without passing a constructor, the same design needs a runtime guard plus a test, and the type is documentation rather than enforcement.
- CONTESTEDThe strongest opposing view — held seriously by domain-driven design practitioners — is that pushing invariants into the database scatters the domain across two languages and two change processes, leaves the model anaemic, and makes the rules unreadable to anyone reasoning about the business; the right answer is to fix the ownership so that the domain model genuinely is the only writer, rather than to give up and defend at the storage layer. That is correct where the ownership can actually be fixed, and it is a real and achievable goal for a greenfield system with one team. It is much weaker where an admin tool, a reporting pipeline and a decade of migration scripts already have write access and cannot be reformed, which describes most systems this lesson will be read in.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — when the writers are in different services with different datastores, none of these five points covers all of them, and the honest answer becomes detection and compensation rather than enforcement.