States That Must Be Unrepresentable
Paid twice, shipped before paid, refunded with no payment: some combinations of state must not merely be rejected at runtime but be impossible to write down. Finding them is a discovery move — ask which combinations of fields would be a lie — and where to enforce each is a decision, not a doctrine.
The situation, the reflex, and why it stalls
Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.
Which combinations of state in the store must never exist, how do you find them, and where — type, constraint, transition — should each one be made impossible?
An order in production has two payment records, both succeeded. Another is marked shipped and its payment is null. Neither should be possible; both are in the database; and the code that produced them is not wrong in any single line — it is the combinations that are wrong, and nothing was looking at combinations.
Add a validation check in the handler that caused the bad row. If the shipping handler let an unpaid order through, put an if (!order.paid) throw in the shipping handler. It fixes the case in front of you, it is a small diff, and it feels like the specific, proportionate response.
The check lives in one handler, and the state can be reached from others: the admin tool, the import script, the retry job. Each of them gets its own check when it produces its own bad row, and the invariant is now expressed in five places that drift.
- The check lives in one handler, and the state can be reached from others: the admin tool, the import script, the retry job. Each of them gets its own check when it produces its own bad row, and the invariant is now expressed in five places that drift.
- The check runs after the fact. An order that is both shipped and unpaid can still be written by anything that does not call the handler, because the representation permits it. The reflex guards the door and leaves the window open.
- The question "what other combinations are impossible?" is not asked, because each bad row was treated as a bug rather than as a member of a class. The next impossible state arrives as a surprise of the same shape.
- The double payment is fixed with a check on the payment handler, and the provider's duplicate confirmation walks straight past it a week later because it does not go through the handler; it goes through the webhook.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- Find illegal states by asking which combinations of fields would be a lie. Not "what can go wrong?" — that produces failures — but "which pairs of facts about this entity contradict each other?". Shipped and unpaid contradict. Two succeeded payments for one order contradict. Refunded with no payment contradicts. A status of PAID and a payment whose outcome is UNKNOWN contradict. The list is the invariants of the representation.
- For each, ask where it can be made impossible rather than merely checked. Some are shape: if an order can hold at most one successful payment, a unique constraint on (order_id) where status = succeeded makes the second write fail no matter who writes it. Some are type: if "shipped" requires a payment, a representation where the shipped state carries the payment reference cannot express shipped-without-payment. Some are only enforceable by the transition function, because they are about sequences, not shapes.
- Prefer the enforcement that no code path can bypass. A database constraint stops the import script; a type stops the compiler; a check in one handler stops that handler. The right level is the lowest one at which the invariant can be stated — and the honest answer for some invariants is that they cannot be made unrepresentable and must be checked, tested, and reconciled (Invariants as Tests).
- Then write a test that tries to produce each illegal state by the most direct route available — a raw insert, a second webhook, a concurrent request — and confirm the state cannot be reached. A test that goes through the handler tests the handler; the illegal state lives in the representation.
Three contradictions, three levels
Each row is a combination of facts that cannot both be true, found by the pairwise question. The enforcement column is the lowest level that can state the invariant — and it is different in every row, which is why the answer is a decision rather than a slogan.
| Contradiction | Kind | Lowest enforcing level | Direct-write test |
|---|---|---|---|
| Two succeeded payments for one order | shape — a count | partial unique index on payments | insert a second succeeded payment row; expect rejection |
| SHIPPED with no succeeded payment | sequence — depends on history | transition function; constraint only as defence | create PENDING order, call transition(Shipped); expect rejection |
| Status PAID with payment outcome UNKNOWN | representation — a field's type | non-nullable payment reference in the PAID state; else the transition function | construct PAID without a succeeded payment id; expect compile or constraint error |
| Order PAID here, no charge at the provider | cross-boundary | reconciliation job | cannot be prevented; job must find and repair it |
Checking versus representing
The pair is the double-payment case. The worse version is the reflex — correct for the handler and blind to every other writer. The better version puts the invariant where every writer meets it, and turns the webhook's duplicate into the no-op the machine wanted.
The payment handler loads the order, sees `order.paid` is already true, and returns early. The webhook handler, written later by someone else, does not load the order first and inserts a second succeeded payment.
A partial unique index: one succeeded payment per order. The handler and the webhook both insert; the second insert fails; both catch the specific violation and treat it as "already recorded". The order table's PAID status carries the id of the one payment that exists.
The invariant is stated once, at a level no write path can skip, and duplicate delivery from outside is handled by the same line that handles a bug inside. The handler check protected one door; the index protects the room (Enforcing Invariants).
1CREATE UNIQUE INDEX one_success_per_order2 ON payments (order_id)3 WHERE outcome = 'succeeded';4 5-- direct-write test: this must fail6INSERT INTO payments (order_id, outcome) VALUES (42, 'succeeded');7INSERT INTO payments (order_id, outcome) VALUES (42, 'succeeded');The test does not go through any handler. It attacks the representation, which is where the illegal state would have lived.
Choosing the level
The decision the move produces for each contradiction. The options are levels; the criteria are what kind of invariant it is and which write paths exist. None of them is the default.
At which level should this contradiction be made impossible?
when The contradiction is about the shape of one value — a PAID state that must carry a payment id. Natural in languages whose types can express it; contorted elsewhere.
cost Every consumer of the type sees the lifecycle; a new state ripples through them. Nothing outside the process — a script, a migration — is stopped.
when The contradiction is about counts, uniqueness or a relationship between columns, and writes come from more than one program.
cost The violation surfaces as a database error and must be translated for the user; the emergency operator will want to bypass it.
when The contradiction is about sequence — what must already have happened — and cannot be stated in one row.
cost Protects only writes that call it; must be the only writer, which is a discipline plus a grep.
when The contradiction spans a boundary you do not control — your record against the provider's.
cost The illegal state exists for a while; the job must find it, repair it, and alert when it cannot.
How to do it
Most important first.
- List the entity's fields and ask, pair by pair, which combinations contradict. Write each as a sentence: "an order cannot be shipped and unpaid".
- For each sentence, name the lowest level that can enforce it: type, database constraint, single transition function, or reconciliation job (Where Invariants Live).
- Prefer representation over checking where the representation is natural; do not contort a type to forbid a state that a constraint forbids in one line.
- Route every write that could produce the state through the enforcing level, and grep for writes that bypass it — scripts, admin tools, migrations.
- Write the test that attacks the representation directly, not the handler (Making Illegal States Unrepresentable).
Worked on a concrete problem
The move has to produce something. This is what it produced.
- The double payment. Sentence: "an order has at most one successful payment". Lowest level: a partial unique index on payments (order_id) where outcome = succeeded. Now the webhook, the handler, the retry job and a manual insert all fail identically on the second success, and the webhook handler treats that failure as "already recorded" — which is the idempotent no-op the external-systems lesson asked for, delivered by the representation rather than by remembering to check.
- Shipped before paid. Sentence: "an order in SHIPPED has a successful payment". This is a sequence invariant, not a shape one — the payment is a separate row — so it lives in the transition function: the row (PAID, Shipped, warehouse) → SHIPPED exists and (PENDING, Shipped, …) does not. A check constraint could be added for defence in depth, but it would need the payment in the same row, and the honest enforcement point is the machine. The test inserts an order in PENDING and calls the transition directly.
- Status PAID with payment UNKNOWN. Sentence: "an order is PAID only when a payment has outcome succeeded". The representation can carry it: PAID holds the id of the succeeded payment, and the type of that field is not nullable in the PAID state. In a language whose types can express it, shipped-without-payment is a compile error; in one that cannot, the transition function and a constraint share the job, and the lesson is that the level depends on the tools (Result Types).
How you know it worked
What now exists that did not before, and what question you can now ask.
- A written list of contradictory combinations exists for each entity, and each has a named enforcement level.
- At least one illegal state is prevented by the database or the type system, and a test that writes it directly fails.
- The webhook's duplicate delivery is handled by the same constraint that stops a manual double insert, and there is no separate "have I seen this?" check that could be forgotten.
- You can say which invariants are unrepresentable, which are enforced by the transition function, and which are only reconciled — and why each is at its level.
The questions you can now ask
The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.
- ?Which pairs of facts about this entity contradict each other, and have I written each contradiction as a sentence?
- ?What is the lowest level at which this contradiction can be made impossible, and does every write path go through it?
- ?Which of these invariants cannot be made unrepresentable, and what finds and repairs them?
- ?Does my test attack the representation directly, or only the handler I already trust?
What can go wrong
- Every invariant is forced into the type system. The order becomes a tower of discriminated unions that encodes the whole lifecycle, and a new state is a refactor of every consumer. Types are one level; constraints and the transition function are others.
- The invariant is enforced at the lowest level and the higher levels do not know. A constraint violation surfaces to the customer as a database error instead of as "this order is already paid".
- Invariants are found only from bugs, one at a time. The pairwise question — which fields contradict — is skipped, so the class is never enumerated.
- Reconciliation is treated as failure. Some invariants — order state against the provider's record — cannot be made unrepresentable, and a job that finds and repairs disagreements is the correct enforcement, not an admission of defeat.
- A constraint that stops every write path also stops the emergency fix at midnight, and the operator who needs to bypass it will drop it. Strictness has an operational cost that must be planned for.
- A representation that cannot express an illegal state also cannot express the in-between state a real workflow sometimes needs — payment succeeded, order not yet marked — and a model that forbids the in-between forces it into a queue or a transaction that spans two systems.
- Enumerating contradictions is a bounded task with a quadratic feel; on an entity with many fields the list is long and most pairs are trivially compatible. The move earns its cost on the entities that carry money and status.
- "Make illegal states unrepresentable" means "do everything in the type system." The slogan is about representation, and a database schema is a representation. The strongest form — enforce at the lowest level that can state the invariant — is falsifiable: pick the level, write the direct-write test, see whether it fails.
- "A transition function makes constraints unnecessary." The function guards the writes that call it. The import script does not call it. Both levels exist because different writes reach different levels.
- "An invariant that needs reconciliation is not an invariant." It is one that spans a boundary you do not control. Order-paid against provider-charged cannot be a constraint, and the reconciliation job is where that invariant lives (External Systems Fail).
Where this applies
Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.
- GENERAL"Which facts contradict?" finds representation invariants in any entity — a message delivered but never sent, a file available but never scanned, a job succeeded with no output.
- CONTESTEDThe strongest opposing view: pushing invariants into types and constraints makes the system rigid, and the in-between states real workflows need — succeeded at the provider, not yet recorded here — become impossible to model, so teams end up fighting their own representation. Practitioners holding this view keep the representation permissive, enforce in one well-tested service layer, and rely on reconciliation. They are right when the domain has many legitimate in-between states and the write paths are genuinely all routed through one layer.
- ILLUSTRATIVEThe three illegal states and their enforcement levels are chosen to show one of each kind; a real store's list is longer and the levels depend on its database and language.
Where the depth lives
This domain asks the question and hands the answer off by name.