StateGENERALLANGUAGE-SPECIFICCONTESTED

Explicit State

Name the states a thing can be in instead of inferring them from combinations of fields. The inference is a rule, and an unwritten rule is enforced by memory.

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

The lifecycle of this object is currently derived from four timestamps and two flags. What do I gain by naming the states instead?

The requirement

Support asks a simple question — "what state is this order in?" — and three engineers give three answers, because the answer is computed differently in the admin UI, the API and the nightly report.

The obvious build

The fields already carry the information. paidAt, shippedAt, cancelledAt and refundedAt tell you everything, and a small helper computes a label when one is needed. Adding a status column would just duplicate what is already there.

Why it breaks

The helper gets written three times, because the second and third authors did not know the first existed, and the three disagree about the order in which they check the fields (Duplicate Knowledge).

How it breaks as requirements change
  • The helper gets written three times, because the second and third authors did not know the first existed, and the three disagree about the order in which they check the fields (Duplicate Knowledge).
  • The precedence rule — is an order that is both shipped and refunded "shipped" or "refunded"? — is real business knowledge that lives only inside a chain of if statements, where nobody can find or review it.
  • Some field combinations are impossible and nothing says so. cancelledAt set with shippedAt set is either a bug or a legitimate late cancellation, and no reader can tell which (Boolean Flag Explosion).
  • A new state — "awaiting fraud review" — has no natural encoding, so it arrives as a fifth timestamp and every existing derivation must be updated to account for it, in three places, correctly.
  • The mobile client has its own copy of the derivation, written months ago, and it is now the fourth answer.
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 existing columns are used by a reporting warehouse and cannot be dropped, only supplemented.
  • There are about two hundred thousand existing rows whose state has to be derived once, during a migration, from data that is occasionally inconsistent.
  • The mobile client caches order objects and cannot be updated in lockstep with the server.
Invariants
  • Every order is in exactly one state at any moment.
  • Two parts of the system asked "what state is this?" give the same answer.
  • A state that exists in the data is a state the code knows the name of.

Who owns what, and where the seams fall

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

Responsibilities
  • One type owns the set of states and their names, and it is the only place the vocabulary is defined (State Machines).
  • One place owns the transition — the function that moves an order from one state to the next and records the timestamp — so state and its evidence cannot drift apart.
  • The timestamps become evidence about how the order reached its state, not the definition of it. That is a demotion, and it is the point.
  • Reporting owns deriving whatever it needs from the stored state, rather than everyone deriving state from what reporting happens to store (State Ownership).
Boundaries
  • The boundary is around the transition function: everything that changes state goes through it, so that the invariant "state and timestamps agree" holds by construction (Enforcing Invariants).
  • The wire format is a separate boundary. A state name crossing to a client that cannot be updated is a contract, and adding a state to it is a breaking change (Enum Evolution: The New Value That Broke Old Clients in API Design has the mechanics).
  • The line between state and data: state is what determines which operations are legal; everything else is attributes (Making Illegal States Unrepresentable).

The derivation is a rule with no home

Look at the first version below not as bad code but as a rule nobody wrote down. The order of the checks encodes a business decision — refunded beats shipped, cancelled beats paid — and that decision was made by whoever typed the function first.

The second version does not add information. It moves the decision to the moment it is actually made, which is when the transition happens and someone can still ask the business whether it is right.

"What state is this order in?"
Derived — the precedence rule is invisible
function statusOf(o: OrderRow): string {
  if (o.refundedAt) return 'refunded'
  if (o.cancelledAt) return 'cancelled'
  if (o.deliveredAt) return 'delivered'
  if (o.shippedAt) return 'shipped'
  if (o.paidAt) return 'paid'
  return 'new'
}

// the API has its own copy that checks cancelledAt first
// the report has a SQL CASE expression with a fourth order
// the mobile app has a fourth, written last year
Stored — the decision happens once, at the transition
type OrderState =
  | 'new' | 'paid' | 'shipped' | 'delivered'
  | 'cancelled' | 'refunded'

// one writer, and it sets both together
function transition(o: Order, to: OrderState, at: Date) {
  assertLegal(o.state, to)
  o.state = to
  o.history.push({ to, at })
}

// "what state is this?" is now a field read, everywhere

The precedence question — what is an order that was shipped and then refunded? — is a business question, and in the first version four different people answered it privately. In the second it is answered once, at the transition, by code someone reviewed. That is also why the second version can be queried and indexed while the first can only be recomputed.

The states, said out loud

Writing the lifecycle down takes ten minutes and is almost always the moment a real disagreement surfaces. Here it is the two transitions out of shipped: whether an order can be cancelled after dispatch turned out to depend on the carrier, which nobody had encoded anywhere.

The forbidden list matters as much as the transitions. Those four lines are business rules that previously existed only as a comment, or as the absence of a code path, which is not the same as a rule.

Order lifecycle, first draft
newpaidshippeddelivered ·cancelled ·refunded ·
FromOnToGuardEffect
newpayment capturedpaidstock reservablereserve stock, record paidAt
newcustomer or ops cancelscancelledrelease any pending authorization
paidcarrier acceptsshippedevery line pickedstore tracking number, notify customer
paidcancel before dispatchrefundedrefund the capture, release the reservation
shippedcarrier confirmsdeliveredstart the returns window
shippedcarrier reports lossrefundedcarrier claim acceptedrefund and close the claim
must be impossible
  • new → shippedDispatching an unpaid order gives away stock with no capture behind it; the loss is silent because nothing errors.
  • delivered → cancelledCancelling a delivered order releases a stock reservation that was already consumed, so inventory counts drift upward by one per occurrence.
  • cancelled → paidA cancelled order has released its reservation; paying it captures money against stock that may have been sold to someone else.
  • refunded → refundedA second refund on the same capture double-pays the customer. The guard is not the state alone — it is the capture id, which is why refund must be keyed as well as guarded (Idempotency by Design).

Everything here was already true; none of it was written anywhere. The shipped -> refunded transition in particular was implemented in one place and forbidden by a comment in another, which is the exact condition this module exists to remove.

How the derived version fails in production

These are not hypotheticals; they are the four ways a derived status goes wrong often enough to be recognisable. Notice that none of them produces an exception — every one of them produces a wrong answer that looks like a right answer.

The response column is the design change rather than the fix for the individual incident, because each of these recurs until the derivation stops being duplicated.

Derived state, four real incidents
TriggerSymptomCauseResponse
A refund is issued for a shipped orderThe admin UI says "refunded", the API says "shipped", the report counts it as both.Three derivations with three precedence orders.Store the state; the precedence question gets answered once, at the transition, by someone who can ask the business.
A data fix sets cancelledAt directly in SQLAn order appears cancelled but its stock is still reserved and its payment still captured.The derivation has no transition to hook, so nothing ran the side effects.A single transition function as the only writer, plus a data-quality check that flags states without their expected evidence (Invariant Leaks).
A new state is added for fraud reviewThe mobile app shows "paid" for orders under review, and customers chase an order that is not moving.The client has its own copy of the derivation and cannot be updated in lockstep.Send the state on the wire as an explicit value and treat unknown states as a defined fallback on the client (Enum Evolution: The New Value That Broke Old Clients).
Someone asks how many orders are awaiting dispatchThe query takes four minutes and scans the whole table.State is not stored, so it cannot be indexed.A stored, indexed state column. This is the operational argument for explicit state and it is usually the one that gets it funded.

How to build it

Most important first.

  • Enumerate the states out loud with someone from the business, and write down what each one means in one sentence. If two people give a state two names, that disagreement is the finding (Ubiquitous Language).
  • Store the state explicitly rather than deriving it. A stored state can be indexed, queried, reported on and asserted; a derived one can only be recomputed, differently, by everyone.
  • Keep the timestamps. They answer "when did it become paid", which is a different question, and losing them to make a point is a regression.
  • Make the transition the only writer of both, so state = 'paid' and paidAt are set together or not at all.
  • Migrate existing rows once, deliberately, and record which rows were ambiguous rather than silently picking a precedence for them (Data Migration).
  • Add states only when they change what operations are legal. A state that no code branches on is a label, and a label belongs in a field, not in the lifecycle (Over-Design and Under-Design).

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 "awaiting fraud review" costs a fifth timestamp, a change to every derivation you can find, and a bug in the one you cannot.
  • After: it is one new state name, plus the transitions into and out of it, plus deciding what is legal there — which is exactly the design conversation that should happen.
  • The next change that gets cheaper and is easy to overlook: any question of the form "how many orders are in state X" becomes an indexed query rather than a full scan with application-side derivation (Which Signal Actually Means "The Database Is Slow").
  • The next change that gets more expensive: renaming a state, once clients depend on the name. That is a contract change with a deprecation period, where before it was an internal detail nobody could see.
What the recommended approach costs
  • A stored state is data that can be wrong, where a derived one is always consistent with its inputs. You are trading "occasionally inconsistent" for "always agreed", and the trade needs the transition boundary to be real.
  • Adding a state column to a schema shared with a warehouse means a migration and a coordination with the reporting team, which is a genuine organisational cost.
  • Explicit states make the wire contract larger and more brittle: every state name is now something a client can depend on.

What can go wrong

Failure modes
  • The state column is added and the old derivations stay, so the system now has five answers instead of four, one of which is authoritative and undocumented.
  • State and timestamps drift because some path writes one without the other — almost always a bulk SQL update or a data fix (Invariant Leaks).
  • The state set becomes a grab bag: paid, paid_pending_review, paid_partial, paid_manual — five variants of one state that differ in attributes, not in what is legal (Primitive Obsession applied to lifecycles).
  • The mitigation fails too: a migration derives historical states with a precedence rule nobody validated, so two hundred thousand rows now assert something the business never agreed to.
Dependencies, and their direction
  • Everything that asks about an order's lifecycle depends on the state type, which is a deliberate, visible fan-in and replaces four invisible copies of a derivation.
  • The state type depends on nothing — it is a closed set of names, which is why it is safe for every module to depend on it (Stable Dependencies).
  • External clients depend on the state names once they are on the wire, which is the one dependency that makes adding a state expensive (Backward Compatibility as a Constraint).
Misreads
  • "So delete the timestamps." No. They answer a different and useful question. The change is which one is authoritative for "what state is this", not which data exists.
  • "Every object needs a status field." Only things with a lifecycle — where the set of legal operations changes over time. A user profile has no lifecycle; giving it a status invents one (YAGNI, With Its Bill Attached).
  • "A state machine is overkill for four states." Four states is where it is cheapest to introduce and where the transition table fits on one screen. The cost of introducing it later, with clients depending on the current behaviour, is much higher (State Machines).
  • "The enum is the design." The enum is a third of it. The transitions and the forbidden transitions are the other two thirds, and they are the parts that carry the rules (Invalid Transitions).
Smells this explains
  • duplicate-knowledge
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • A test that every stored state is one of the known names, run against production data as a data-quality check rather than only in CI.
  • A test that the transition writes state and timestamp together, and a test that no other code path writes the state column (Internal Module Contracts).
  • For the migration, a reconciliation test: derive the state the old way and the new way for every row and report the disagreements rather than resolving them silently (Characterization Tests).
How this design ages
  • The state set grows slowly and should be resisted, because each state multiplies the transition table and every state is a case every consumer must handle.
  • The healthy sign is that new requirements arrive as new transitions rather than new states — "cancel from shipped" is much cheaper than "a new state".
  • What eventually forces change: a lifecycle that is genuinely two lifecycles. When fulfilment states and payment states are interleaved in one enum, the fix is two state fields, not more names (Boolean Flag Explosion).

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 unwritten derivation rule gets rewritten differently by each author is a property of teams rather than languages, and holds anywhere state is inferred from data instead of recorded.
  • LANGUAGE-SPECIFICIn a language with sum types the states can carry their own data — Shipped { trackingNumber } — so the state and the fields that only make sense in that state are one thing, and impossible combinations cannot be written. With a plain enum plus nullable columns the same design needs a runtime check and a test, so the argument for explicitness is the same but the enforcement is much weaker (Making Illegal States Unrepresentable).
  • CONTESTEDThe strongest opposing view: a stored status column is denormalized state that can contradict the facts it summarises, and the "one source of truth" is the event log or the timestamps, from which the state should always be derived. Event-sourcing practitioners are right that a stored status drifts; the counter is that a derived status drifts too, into as many versions as there are derivers, and at least a stored one drifts in a place you can query.

Where the depth lives

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

Domains that do not exist yet
  • Testing & Reliability Engineering — a data-quality assertion that runs against production rows, rather than a unit test against fixtures, is the only thing that catches state drift introduced outside the code path.