modeling-state-machines
Use when something has a lifecycle — order, job, connection, upload, session, subscription, device, workflow step — or when several booleans and flags describe one thing. Also for status fields, impossible flag combinations, records stuck in a status forever, unexplained state changes, a crash partway through a transition, timeouts and cancellation, and adding or renaming a state that is already persisted. Also use when a trigger from outside — a deep link, a push action, a widget tap, a scheduled job — fires while the thing it acts on is still starting up. Also use when a protective or latching state — a tripped breaker, a lockout, a pairing or a binding — has no exit reachable by the party it is holding.
Overview
Three booleans encode eight combinations; if only five are real, you shipped three bugs and no way to detect them. Name the states, enumerate the transitions, and make the forbidden ones observable failures rather than silent no-ops.
When to use
- Anything with a lifecycle, or any entity carrying three or more flags that qualify each other.
- Conditions like
isLoading && !hasError && data != nullappearing in more than one place. - Symptoms: "how did it get into this state?", rows stuck in
processingforever, duplicate side effects after a reload, a cancelled item that still gets processed. - Adding a state, or renaming one, in something already persisted.
- Not for: rewriting existing stored rows once the states change (data-migrations), or the wire compatibility rules for the state field (schema-evolution).
Finding the real states
- The tell: N booleans on one entity with N ≥ 3, or a status field plus flags that qualify it. Write out all 2^N combinations and mark the impossible ones. Any impossible combination that is reachable in code is a bug you already have and have not noticed.
- A state is a set of (allowed operations, allowed transitions, invariants). Two situations permitting exactly the same operations are the same state however differently they are described. Two differing in even one allowed operation are different states however similar they sound.
- Name the situation, not the last event:
awaiting-payment, notpayment-requested. Event-named states multiply, because every event eventually gets one. - Data valid in only some states belongs inside the state:
Failed { reason, attempts },Active { session, started_at }. A record with six optional fields, each meaningful in one status, is boolean soup wearing a hat. - Right-sizing: under 3 states you do not need a machine. Past roughly 12 for one entity you have merged two machines — usually a lifecycle and a payment or approval status. Split them; orthogonal concerns multiply into a combinatorial explosion inside one machine.
Making illegal states unrepresentable
- Where the language has sum types, tagged unions, or sealed hierarchies (Rust, Swift, Haskell, ML, Kotlin and Java sealed types, TypeScript discriminated unions), model the state as one. Exhaustiveness checking then turns "add a state" into a list of compile errors at exactly the sites that must change. That is the whole payoff and it is worth restructuring for.
- Where it does not (C, Go, Python, Ruby, SQL schemas, wire formats), enforce at the boundary: one constructor per state, a single
transition(current, event) -> nextfunction that is the only writer, and a check constraint or trigger enforcing which columns may be non-null per status. - A state field with public write access is not a state machine; it is a variable. No code outside the transition function assigns it, in any language.
- On the wire, encode a closed set of names, never booleans, and treat it as a growable enum: ship readers that tolerate an unknown state before writers that emit one.
The transition table is the specification
- Rows are states, columns are events, cells are the next state or
—for forbidden. Write it before the code. The forbidden cells are the most valuable part — they are what review and tests check against, and they are what nobody writes down. - Every cell needs a decided answer for a duplicate event: the second
cancelon a cancelled order is usually a successful no-op, occasionally a rejection, never undefined. At-least-once delivery guarantees you will get duplicates. - Timeouts and cancellation are events producing states, never ambient conditions.
Expiredis a state. "Created over 30 minutes ago and still pending" computed at read time is not: two readers can disagree, and nothing fires the side effects of expiring. If a timeout matters, something must actively drive the transition — a scheduled sweep, a timer, or a lazy check that writes. - Every non-terminal state needs an exit: a timeout, a retry limit, or a manual override. A state with no automatic exit is where entities pile up forever; audit for it explicitly, because the symptom appears months later as a support queue.
- Name the actor who clears each exit, and what they press. "A manual override" satisfies the line above while being, for the party actually stuck, no exit at all — an operator with database access is not a route a user has. This bites hardest on protective states, because entering one is the designed behaviour and leaving it is the afterthought: a breaker that trips with no reset control, a pairing or binding nothing in the product can clear, a lockout whose only cure is a support ticket. Where the answer is "somebody edits the store", the guard has turned a bounded protection into an unbounded one.
- Side effects belong to transitions, not states. "On entering
Shipped, send the email" attached to the state re-sends on every reload and every replay.
Where the state lives, and crashes
- Exactly one authoritative store per machine. State duplicated across a cache, a UI, and a database is three machines that will disagree; everything but one must be a projection that can be rebuilt and discarded.
- Guard every write with the expected current state (
UPDATE ... WHERE status = 'pending'). This makes each transition a compare-and-set and kills lost-update races for free. A write that does not check the source state is how two workers both advance one entity. - A transition spanning a state write and an external effect must have a named in-between state —
ChargingbetweenPendingandPaid. "It crashed between the update and the API call" must land on a state you have a written recovery rule for, not an undefined one. - Persisted states are data, not code. Never reuse a removed state's name. Renaming a state is a breaking data change even though it looks like a refactor: it needs a migration or a documented mapping, not a find-and-replace. If the per-state payload shape changes, carry a version alongside.
- Log every transition as
(entity, from, to, event, actor, at). This one table answers "how did it get like this" and removes the most expensive category of support investigation that exists.
An outside trigger meets the state that exists when it arrives
A deep link, a push-notification action, a widget tap, a scheduled job: each fires on its own clock and acts on whatever state exists at that instant. It does not act on the state the person is looking at, and during startup, reconnection or restore those are different things, because the machine passes through intermediate states (loading, restoring, pairing, signing in, migrating) on the way to the one it settles in. "The first moment the handler can run" is a moment inside that transit. A handler that reads the state then captures, previews, navigates or writes against a screen that is gone a second later, and it does so reliably on a cold start and almost never on a warm one, which is why it survives testing.
- Name the transit states, and mark which ones are not a place to act. If the machine has an explicit ready state, gate the trigger on it and queue the trigger until then. That is the first choice, because the machine says when it has settled and nobody has to infer it.
- When the state is not yours to extend (a platform's lifecycle, a navigation stack owned by a framework), act on a settled read: the observed value unchanged for a short quiet period, within a hard cap. Waiting a fixed number of frames or milliseconds is a guess about start-up speed, and a single successful read proves only that the value existed once. "Unchanged for N ms" is a condition;
diagnosing-before-fixing's condition-based waiting covers polling for one, and this is the condition that applies when there is no ready flag to poll. - Decide what the cap does. If the value never settles within the cap, the trigger must degrade deliberately (act on the last read, or tell the person it could not run) and not hang or pick silently.
- Keep the wait a small pure function: the source of reads, the quiet period, the cap and a clock go in, a value or a timeout comes out. Test it with a sequence that keeps changing and then stops, a constant one that returns after exactly the quiet period, and one that never settles and returns at the cap. The clock is injected (
testing-the-untestable), so none of the three sleeps. - A trigger can arrive twice, or after the screen it targeted was replaced. Each action still needs a decided answer for the duplicate (
designing-for-idempotency).
Testing
- Test every cell, including all forbidden ones. A forbidden transition must be rejected and observable, not silently ignored — a silent no-op is indistinguishable from success to the caller that just lost its work.
- Test a duplicate of every event in every state.
- Property test: from any reachable state, any random event sequence yields only named states and never violates an invariant.
- Assert reachability both ways: every state is reachable from the initial state, and every non-terminal state can reach a terminal one. An unreachable state is dead code or a missing transition; an unescapable one is a leak.
- Assert each exit is reachable by the actor who is stuck in it, not merely present in the table. Reachability is a property of the transition table; usability is a property of the interface, and the assertion above passes on a machine whose only way out needs privileges the trapped party does not have.
Common mistakes
| Symptom | Real cause |
|---|---|
| "How did it get into this state?" | No transition log, and the field is assigned in several places |
| Two flags contradict each other | Independent booleans instead of one state |
| Cancelled items still get processed | Forbidden transition silently ignored instead of rejected |
| Duplicate emails after a reload or replay | Side effect attached to a state rather than to a transition |
Rows stuck in processing forever | A state with no timeout and no exit |
| A status rename broke old rows | Persisted state treated as code instead of data |
| Adding a state meant grepping for every switch | State modeled as a string or int rather than a closed type |
| Two workers both advanced the same entity | Write not conditioned on the expected current state |
| UI shows a state the backend does not have | State duplicated instead of projected |
| Expiry never triggers its side effects | Timeout modeled as a read-time computation |
| A user is stuck in a state the transition table says is escapable | The exit exists, and the only actor who can take it is not the one being held |
| A notification or link action works warm and acts on the wrong screen on a cold start | It read the state at first availability, an intermediate state of start-up, not the settled one |
Red flags
- "There is a manual override" — offered without naming who can reach it.
- "Just add a boolean for it."
- "The status is whatever the last event set it to."
- "We compute expired at read time."
- "That transition can't happen" — with nothing enforcing it.
- A status column typed as free text.
- Any code outside the transition function assigning the state field.
- A transition table with no
—cells.