StateGENERALLANGUAGE-SPECIFICCONTESTED

Boolean Flag Explosion

Four independent booleans describe sixteen states. Five are legal. The other eleven are not prevented by anything, and the arithmetic is the whole argument.

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.

The question

My object has isPaid, isCancelled, isShipped and isRefunded. How many states does that actually create, and how many of them did anyone design?

The requirement

A report shows twelve orders that are simultaneously cancelled and shipped. Nobody wrote code to produce that combination; it arose from two code paths each setting one flag, in an order nobody anticipated.

The obvious build

Booleans are the simplest possible representation. Each flag answers one question, each is easy to set, easy to query and easy to add. Adding a fifth is a one-line migration.

Why it breaks

The state space is multiplicative and the design conversation is additive. Each new flag doubles the number of representable states while the team discusses it as "one more field" (The Complexity Budget).

How it breaks as requirements change
  • The state space is multiplicative and the design conversation is additive. Each new flag doubles the number of representable states while the team discusses it as "one more field" (The Complexity Budget).
  • Nothing relates the flags to each other, so no code path can be wrong on its own — each one sets its own flag correctly, and the combination is what is illegal.
  • Queries encode the relationships informally. WHERE is_paid AND NOT is_cancelled AND NOT is_refunded appears in nine places with three different sets of conditions, and adding a flag invalidates all of them silently (Duplicate Knowledge).
  • Reading the code, you cannot tell which combinations are impossible and which are merely rare, so defensive branches accumulate for states that cannot occur and are missing for ones that can.
  • The fifth flag arrives — isOnHold — and the space goes from sixteen to thirty-two without a single line of design discussion.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The four flag columns are read by a reporting warehouse and by a partner integration, so they cannot simply be dropped.
  • About four million rows exist, so any migration must be online and reversible.
  • Two of the flags are also set by a legacy batch job the team does not own.
Invariants
  • Every combination of stored values that can be written corresponds to a state someone designed.
  • An order is cancelled or shipped, never both.
  • The number of reachable states is a number the team can state out loud.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • One field owns "where in its life is this order", and the flags that were describing the lifecycle collapse into it (Explicit State).
  • Genuinely independent attributes keep being their own fields. isGift is not a lifecycle state and must not be folded into one (State Machines).
  • The transition owns writing the state; no code path sets a lifecycle fact on its own any more, which is what removes the illegal combinations.
  • The compatibility layer owns computing the old flags from the new state for the warehouse and the partner, so external readers are unaffected (Expand and Contract).
Boundaries
  • The line is between lifecycle and attribute: does this fact change which operations are legal? If yes it is state; if no it is a field.
  • The old flags become a projection at the outbound boundary — derived, read-only, and never written by application code again.
  • The legacy batch job is outside the boundary, which makes it the migration's hardest problem and the reason the flags cannot simply be deleted (The Strangler Pattern).

The arithmetic, written out

Four booleans is not four things. It is two to the fourth: sixteen representable combinations, of which the team designed five. The other eleven were never rejected, never considered and never prevented — they were simply not thought about, and eleven of the sixteen cells in this table are places the system can end up.

The table below is worth ten minutes in a meeting. Almost every team that draws it decides on the spot, because the argument stops being about style and starts being about a count.

  • 16 representable combinations from four flags; 5 designed; 11 undesigned and reachable.
  • A fifth flag makes it 32 and 6, so the undesigned share goes from 69% to 81% — the ratio gets worse with every addition.
  • No individual code path is wrong. Each sets its own flag correctly; the combination is the defect, which is why code review does not catch it.
  • The equivalent state model has 5 states and a transition table with 7 rows, and nothing outside those rows is representable.
isPaidisCancelledisShippedisRefundedMeaningDesigned?
FFFFNew order, nothing has happened.Yes — one of the five.
TFFFPaid, awaiting dispatch.Yes
TFTFPaid and shipped.Yes
FTFFCancelled before payment.Yes
TTFTCancelled after payment, refunded.Yes
FTTFCancelled and shipped. The twelve orders in the report.No — reachable by two paths racing.
FFFTRefunded without ever being paid.No — refund path does not check payment.
TTTTPaid, cancelled, shipped and refunded at once.No — nothing prevents it.
FFTFShipped without payment. Silent revenue loss.No — the expensive one.
TTFFCancelled after payment, no refund issued. Customer is out of pocket.No — and this one generates complaints, not exceptions.
Six further combinations, none of them meaningful.No

The same information, without the eleven ghosts

Collapsing the four flags into one state removes the illegal combinations by construction rather than by vigilance. In a language with sum types it goes further: the data that only makes sense in one state lives inside that state, so trackingNumber cannot be read on an unshipped order and refundId cannot exist without a refund.

Note what stays a boolean. isGift is an independent fact about the order, not a point in its life, and folding it into the state would produce exactly the combinatorial enum this lesson warns about.

Five states, and the data that belongs to each
1// 4 bools = 16 combinations, 11 of them nonsense.
2// This enum has exactly 5 inhabitants, and each carries only
3// the data that makes sense in that state.
4enum OrderState {
5 New,
6 Paid { captured: Money, capture_id: CaptureId },
7 Shipped { captured: Money, capture_id: CaptureId, tracking: TrackingNumber },
8 Cancelled { at: DateTime, reason: CancelReason },
9 Refunded { refund_id: RefundId, amount: Money, at: DateTime },
10}
11
12struct Order {
13 id: OrderId,
14 state: OrderState,
15 is_gift: bool, // genuinely independent: stays a bool
16}
17
18impl Order {
19 // there is no way to ask for a tracking number on an unshipped order
20 fn tracking(&self) -> Option<&TrackingNumber> {
21 match &self.state {
22 OrderState::Shipped { tracking, .. } => Some(tracking),
23 _ => None,
24 }
25 }
26}

The capture_id appearing in Paid and Shipped and nowhere else is the part that carries over to any language: fields that only apply to one state should live with that state. In a language without sum types the same design becomes a discriminated union of object shapes, or an enum plus nullable fields plus a test that the nulls line up — weaker, but the same intent (Optional Values and Absence).

The smell, and when flags are genuinely right

This is a smell rather than a rule, and the exception is important enough that getting it wrong produces something worse than the original. Several booleans are exactly right when the facts really are independent.

The diagnostic question is one sentence long, and it is about the data rather than about the code: is there a combination of these flags that means nothing?

smellLifecycle encoded as parallel booleans

looks like Three or more boolean columns or fields whose names are past participles — isPaid, isShipped, isCancelled, isArchived — set by different code paths, and queries that test several of them together with informally-agreed combinations.

suggests One mutually-exclusive lifecycle has been spread across several independent fields, so the representable state space is far larger than the designed one and no single code path can be identified as wrong when an illegal combination appears.

fix Separate the mutually exclusive facts from the independent ones. Collapse the first group into a state field with a transition table; leave the second group as flags. Then migrate with expand-and-contract, keeping the old columns as a derived projection for external readers (Expand and Contract).

when this is fine Genuinely independent facts should absolutely be separate booleans, and collapsing them is a real error. isGift, isExpedited, isTaxExempt and hasFragileItems can hold in any combination — all sixteen are meaningful, so there is nothing to prevent, and merging them into a status enum would produce sixteen enum values describing one thing each. The test: write out the combinations and look for one that means nothing. If every combination is meaningful, the flags are correct.

How to build it

Most important first.

  • Do the arithmetic first, on a whiteboard, and write the number down. Four flags is sixteen combinations; enumerate them and mark which are legal. That table is usually enough to settle the design argument on its own.
  • Collapse the mutually exclusive flags into one state field with named values, keeping only the genuinely independent ones as booleans.
  • Keep independent attributes independent. The goal is not "one field" — it is that every representable combination is one someone designed (Making Illegal States Unrepresentable).
  • Migrate with expand-and-contract: add the state column, write both for a period, derive the flags for readers, then stop writing the flags once every writer is converted (Expand and Contract).
  • Add a database check constraint for the combinations that must never exist, because the legacy batch job writes rows the application never sees (Database Constraints).
  • Backfill deliberately, and for rows whose flags are already contradictory, record them rather than picking a state silently — those twelve orders are data about a bug, not noise (Data Migration).

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.

Cost of the next change
  • Before: adding a fifth lifecycle flag costs one migration and doubles the state space to thirty-two. The cost is invisible at the moment it is paid and arrives later as combinations nobody handled.
  • After: adding a state costs one enum value and its transitions, and the space grows by one rather than doubling. That is the entire argument in one sentence.
  • The next change that becomes cheap: any query about lifecycle. WHERE state = 'shipped' replaces a four-term predicate that existed in nine slightly different forms, and it can be indexed.
  • The next change that stays expensive: anything involving the partner integration, which still consumes flags. That cost persists for as long as the compatibility projection does, which is usually years (Deprecation).
What the recommended approach costs
  • A single state field is less convenient for ad-hoc queries than flags — "all orders that were ever paid" needs history rather than a column read.
  • The migration is genuinely expensive: two writes for a period, a projection to maintain, a backfill to validate, and an external consumer to deprecate.
  • Collapsing too eagerly produces a combinatorial enum, which is strictly worse than the flags, so the judgement about what is orthogonal has to be right.

What can go wrong

Failure modes
  • The state column is added and the flags keep being written by three of the seven writers, so the system now has two sources of truth that disagree — worse than the original.
  • Everything is collapsed into one enum, including the independent attributes, producing states like paid_gift_expedited and a combinatorial enum instead of combinatorial booleans (Primitive Obsession).
  • The backfill picks a state for contradictory rows using a precedence rule invented by an engineer, so the twelve broken orders become twelve orders asserting something false with full confidence.
  • The mitigation fails too: a check constraint added before all writers are converted rejects the legacy batch job at 3am, and the incident response is to drop the constraint (Deliberate Debt).
Dependencies, and their direction
  • The warehouse and the partner integration depend on the flag columns, which is why this is a migration rather than a refactor (Backward Compatibility as a Constraint).
  • Application code depends on the state field afterwards, which centralises a dependency that was previously spread across nine query predicates.
  • The check constraint depends on the state model, deliberately duplicating a rule to cover the writer the application cannot control.
Misreads
  • "So booleans are bad." Booleans are excellent for independent facts. isGift is a fine boolean. The problem is specifically using several booleans to encode one mutually-exclusive lifecycle (Naming).
  • "Collapse everything into one enum." Only the mutually exclusive things. Independent attributes belong in their own fields, and folding them in produces paid_gift_expedited, which is the explosion with extra steps.
  • "Sixteen states is fine, we only use five." Nothing enforces the five. The other eleven are reachable by any combination of code paths, and the twelve broken orders are the proof.
  • "We can fix it with validation." Validation at each write site is the flag design again — each site is individually correct and the combination is what is wrong. The fix is representational (Making Illegal States Unrepresentable).
Smells this explains
  • primitive-obsession
  • duplicate-knowledge

Testing it, and how it ages

What to test, and at which boundary
  • Enumerate the sixteen combinations and assert which are reachable. Most teams have never done this and are surprised by the answer.
  • A data-quality query over production for each illegal combination, run continuously rather than once — this is what found the twelve orders (Debuggability by Design).
  • During migration, a reconciliation test that the derived flags equal the stored flags for every row, so the projection is proven before the old columns stop being written (Characterization Tests).
  • A test that the check constraint rejects each illegal combination, since it is the only defence against the batch job.
How this design ages
  • Flag counts only ever go up, and each addition is locally reasonable, which is why this problem is created by good engineers one commit at a time.
  • After the collapse, the pressure reappears as enum values with suffixes — shipped_partial, shipped_delayed — which is the same explosion in a different costume. The fix is the same: is this a different state, or an attribute of one state?
  • What eventually forces further change is genuinely orthogonal lifecycles: payment state and fulfilment state are two machines, and forcing them into one enum multiplies rather than adds (State Machines).

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.

  • GENERALThe doubling is arithmetic and holds everywhere. What differs is only how much help the language gives in preventing the illegal combinations from being written.
  • LANGUAGE-SPECIFICA sum type makes the eleven illegal combinations unconstructible: there is no value of the type that means "cancelled and shipped". With four booleans in a struct — or four nullable columns — every combination is writable and the design has to be defended by validation and a database constraint instead. Same design, very different guarantee (Making Illegal States Unrepresentable).
  • CONTESTEDThe strongest opposing view: flags are additive and a state enum is not, so adding a new independent fact to an enum-based model requires either a new field anyway or an enum explosion — and in systems where several lifecycles genuinely overlap, flags model reality more honestly than a single status ever can. That argument is correct for genuinely orthogonal facts, which is exactly why the recommendation here is to collapse only the mutually exclusive ones rather than all of them.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

API Designenum-evolution
Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether a five-inhabitant type can be expressed directly, and how it is laid out in memory and on the wire, is a language and representation question that decides how much of this design the compiler can enforce.