Impossible State
also called Illegal State, Unrepresentable State, Representable-But-Invalid State
A value combination a type or schema permits but the domain forbids - the gap that forces every writer to defend a rule the structure itself does not hold.
An orders table carries seven boolean columns: paid, shipped, cancelled, refunded, disputed, archived,
on_hold. Support keeps finding rows that are both cancelled and shipped. The nightly refund job skips
orders marked refunded but not paid. Every write that produced those rows was a single-column UPDATE
that passed review.
Seven independent booleans describe 2^7 = 128 representable combinations, and the business recognises about 10 of 128. The other hundred-odd are not states. They are rows the structure allows and the domain forbids, and nothing in the system is responsible for the difference.
That gap is an impossible state, and the cost of having one is paid by every writer, forever: each has to re-derive the full rule set from memory, and the first one that forgets produces a row that every later reader interprets differently.
Why it matters
The failure is silent by construction. No individual write is invalid, so there is no exception, no non-2xx response and nothing to alert on. The symptom arrives days later as a job that skips rows, a support ticket, or a finance reconciliation that is out by a few hundred units.
The second cost is interpretive. Once contradictory rows exist, every reader needs a rule for what to do
with them, and different teams invent different rules. A refund service that treats refunded && !paid
as "already refunded" and a report that treats it as "data error" are both defensible and they disagree
about money.
The third cost is that the rules cannot be found. They live in service code, in review comments and in one engineer's head, so adding an eighth flag is a change whose blast radius nobody can state.
Implementation patterns
- One state column with a named vocabulary, plus an explicit transition map. Illegal combinations stop being reachable because they are no longer representable.
- Keep facts as facts.
paid_at,shipped_atandrefunded_atrecord what happened; they are not the state. A state plus event timestamps replaces the flags without losing history. - Make the transition the write:
UPDATE orders SET status = 'shipped' WHERE id = ? AND status = 'paid'. Zero rows affected is a visible failure rather than a corrupt row, and it handles the concurrent-transition case for free. - Push the survivors into the store: a
CHECKconstraint on the vocabulary, a partial unique index for "at most one open dispute per order", a foreign key where a reference must exist. - In the type system where you have one: sum types or sealed hierarchies with per-state payloads, so a field that only exists for cancelled orders cannot be read in any other state.
- Migrate by deriving. Compute the new status from the existing flags, run the derivation against production data first, and treat the rows that derive to nothing as a work list - they are the rows the invariant was already violated on.
Industry example
The clearest everyday case is an order or payment lifecycle in a large marketplace, where cancellation, refund, dispute and fulfilment are owned by different teams and each owns one flag. Nobody designs the hundred illegal combinations; they accumulate, one reasonable column at a time, as each team adds the boolean its own feature needs.
The pattern is old enough to have a canonical write-up on both sides: the State pattern in the 1994 design-patterns catalogue, and the phrase the typed-functional community uses for the goal - make illegal states unrepresentable - which is the better statement because it names the property rather than the structure.
Failure scenarios
- Contradictory rows read differently by two services, producing a money discrepancy with no error anywhere in the logs.
- A batch job silently filtering out rows it cannot interpret, so the work is dropped rather than failed, and the only evidence is a count nobody is watching.
- A new flag multiplying the illegal space. Eight booleans is 256 combinations; the rule set grows faster than anyone's ability to hold it.
- Analytics divergence, where the warehouse applies a different interpretation from the application and two dashboards disagree about the same day.
- A migration that strands rows, because the derivation has no state to map
cancelled && shippedonto - the moment the existing corruption becomes visible.
Trade-offs
| Choose | Gains | Pays |
|---|---|---|
| Explicit state plus transition map | illegal combinations unreachable - one place to read the rules - compare-and-set writes | a migration - a vocabulary to agree - more ceremony for genuinely independent attributes |
| Independent booleans | trivial to add one - no coordination between teams | every writer defends the invariant - silent corruption - combinatorial growth |
| State in code only, flags in storage | no schema change | the database accepts what the code rejects - repair tooling and imports bypass the rule |
When not to use it
When no pair of flags is illegal. is_gift, marketing_opt_in and needs_manual_review are
attributes, not states; forcing them into one enum produces values like paid_on_hold_disputed and is the
mirror-image mistake. The diagnostic takes a minute: list the pairs and ask whether any pair must never
co-occur.
When the entity has two states and no rules. A boolean is fine and the state machine is overhead.
When the states are genuinely owned by separate contexts. If fulfilment and dispute are different bounded contexts with their own lifecycles, the answer is two state machines in two places, not one shared column that both teams write to.
Interview question
Q: A table has six boolean status columns and support regularly finds rows that contradict each other. You cannot take downtime and three teams write to the table. How do you get to a model where the contradictions are impossible, and what do you do with the rows that already exist?
What a strong answer covers: counting representable against legal combinations to size the problem · deriving the new status from the flags and running the derivation against production data before changing anything · writing both representations during the transition, with the new status as the source of truth only after the derivation has been clean for a full cycle · treating undecidable rows as a work list with a business owner, not as a technical defect · compare-and-set transitions so the three writers cannot race · and the constraint in the database, because imports and repair scripts do not go through the service.
Quick check
Quiz: Seven boolean lifecycle flags, roughly ten legal states. How many representable combinations are illegal, and what is the first symptom? — 128 representable against about 10 legal, so over a hundred are illegal; the first symptom is a job or report silently skipping rows it cannot interpret, because every individual write was valid.
Flashcard: Why is a contradictory status row harder to detect than a failed write? — Nothing errors. Each single-column update is legal, so the only evidence is a downstream count, a skipped job or a reconciliation difference days later.