An orders table has seven boolean columns - paid, shipped, cancelled, refunded, disputed, archived, on_hold. Support keeps finding rows that are both cancelled and shipped, and the nightly refund job skips orders marked refunded but not paid. No single line of code looks wrong. Which explanation is correct?
Show the full answer Hide the answer
What is being tested
Whether you can see that the bug is in the representation, not in any of the writers. This is the most common modelling mistake in young codebases and it produces exactly the symptom in the question: many small correct-looking changes, and a growing set of rows that no reader knows how to interpret.
The mechanism
Seven independent booleans describe 2^7 = 128 representable combinations. Count the states the business actually recognises - created, awaiting payment, paid, shipped, delivered, cancelled, refunded, in dispute, maybe a couple more - and you get about 10 of 128. The other hundred-odd combinations are not states; they are rows the schema allows and the domain forbids.
Nothing enforces the gap. Every writer has to re-derive the whole rule set - "do not set shipped if
cancelled", "refunded implies paid" - and each writer is one UPDATE that sets one column. The rule
exists in people's heads and in review comments, so the first writer that forgets produces a row that
every subsequent reader interprets differently. The refund job's filter is not wrong: the row it
skipped should not exist.
The failure is silent by construction. There is no error, no exception and no alert, because every individual write is legal. It surfaces days later in reconciliation, support tickets or a finance report, which is why it takes so long to attribute.
Why the other options fail
- Races and isolation. A real cause of a different bug. Locking makes concurrent transitions
serial; it does not stop a single serial writer from setting
shipped = trueon a cancelled order. You can produce this bug with one thread. - Duplicated validation and a shared library. The reflex that feels responsible and moves the rule from five places to one place that writers may still bypass. A library is advice; a constraint is a rule. If the invariant matters, it belongs where no code path can route around it.
- Missing NOT NULL and defaults. Worth fixing, and it does not help: the inconsistent rows in the question have perfectly well-defined values in every column. The combination is the problem.
What to do instead
- One
statuscolumn holding a named state, plus an explicit table or map of legal transitions. Illegal combinations stop being reachable because they are no longer representable. - Keep facts as facts.
paid_at,shipped_at,refunded_atare timestamps that record what happened; they are not the state. A state machine plus event timestamps replaces the flags without losing history. - Make the transition the write.
UPDATE orders SET status = 'shipped' WHERE id = ? AND status = 'paid'- a compare-and-set that returns zero rows when the transition is illegal, so a lost update becomes a visible failure rather than a corrupt row. - Put the survivors in the database: a
CHECKon the status vocabulary, and a partial unique index for rules like "at most one open dispute per order".
This is the State pattern from the 1994 design-patterns catalogue, and the phrase the typed-functional community uses for the goal is the clearer one: make illegal states unrepresentable.
When this is the wrong answer
Choose booleans when the flags are genuinely independent. is_gift, marketing_opt_in,
needs_manual_review can each be true or false in any combination, because no pair of them is illegal.
The diagnostic takes one minute: list the pairs and ask whether any pair must never co-occur. If none
must, you have attributes, not states, and a status enum would force you to invent combinatorial state
names - which is the opposite mistake and produces values like paid_on_hold_disputed.
And for an entity with exactly two states and no transition rules, a boolean is fine. The cost of the state machine - a vocabulary, a transition map, a migration - is only worth paying once illegal combinations exist to prevent.