Reach for this skill when an entity moves through named stages and only some moves are legal:
-
Enumerate states and events as two CLOSED sets first — on paper/in a table before any code. A state is a named condition the entity rests in (pending, paid, shipped); an event is a named trigger that may cause a move (Pay, Ship, Cancel). Keep them disjoint and finite. The single best diagnostic that you need this skill: you have ≥3 booleans describing one entity and not all 2^n combinations are valid. isPaid + isShipped + isCancelled admits "shipped but not paid" and "paid and cancelled" — nonsense states the type system permits. Replace them with one status enum whose values are exactly the legal conditions. Make illegal states unrepresentable.
-
Define the transition as a total function (state, event) → state with guards, entry/exit actions — a TABLE, not scattered ifs. This table is the entire spec; review it like one. Anything not in the table is illegal by default.
| From | Event | Guard (must be true) | To | Entry action (on arrival) |
|---|
pending | Pay | amount == order.total | paid | capture funds, emit OrderPaid |
pending | Cancel | — | cancelled | release inventory |
paid | Ship | inventory.reserved | shipped | create shipment, notify |
paid | Refund | — | refunded | reverse charge |
shipped | Deliver | — | delivered | close order |
shipped | Refund | within return window | refunded | reverse charge, RMA |
- Guard = boolean precondition; if false, the event is rejected (transition does not fire), not an error-state.
pending --Pay[amount≠total]--> simply doesn't move.
- Entry action runs on every arrival into a state (idempotent, since you may re-enter); exit action runs on leaving. Prefer entry actions over per-transition actions so the side effect is tied to being in the state, not the path taken.
delivered, cancelled, refunded are terminal — no outgoing transitions. Mark them; assert nothing leaves.
-
Reject undefined (state, event) pairs LOUDLY — the rejection is the feature. The whole point over flag-soup is that an out-of-order event can't silently corrupt state. The transition function must, for any pair not in the table, return a typed rejection (don't throw for expected business rejections; throw/log for impossible ones). Distinguish:
- Guard-failed (legal event, precondition not met) → 409/422, "cannot Ship: inventory not reserved", state unchanged.
- Illegal event for state (
Ship while cancelled) → 409 + log/metric illegal_transition{from,event}; this often signals a real bug (double-click, replayed message, race) and you want the alarm.
function transition(s: State, e: Event, ctx): Result<State> {
const row = table[s]?.[e.type];
if (!row) return reject("illegal", `${e.type} not allowed in ${s}`);
if (row.guard && !row.guard(e, ctx)) return reject("guard", row.why);
return ok(row.to);
}
Never write if (status !== 'cancelled') { ... } ad hoc — that's flag-soup creeping back. Route every change through the one function.
-
Persist the current state as ONE column and make the write a guarded compare-and-set so concurrent transitions can't corrupt it. Store status as a single enum/text column with a CHECK or DB enum constraint — not N booleans, not a separate row per flag. The transition write must be conditional on the expected from-state (optimistic concurrency), so two racing transitions don't both "succeed":
UPDATE orders SET status = 'shipped', version = version + 1, updated_at = now()
WHERE id = $1 AND status = 'paid' AND version = $2;
WHERE status = <expected_from> is the cheap optimistic lock; 0 rows updated means the precondition no longer holds → reload and re-decide, never blind-overwrite. Add a status_history(order_id, from, to, event, actor, at) audit row in the same transaction so "how did it get here?" is answerable. (For locking semantics → async-concurrency-correctness; for making a re-sent command idempotent → idempotency-keys.)
-
Drive side effects from entry actions, and make external effects atomic-with-the-state-change via an outbox. A side effect that must happen because you entered a state (charge, email, shipment) belongs in that state's entry action, so it fires on every path in and only once. But "update status row" + "call Stripe/send email" as two separate operations can crash between them (state changed, effect lost — or effect fired, state didn't). Write the status change AND an outbox row in one DB transaction; a relay publishes the outbox at-least-once and consumers dedup. This keeps the FSM's state and its observable effects consistent. (Outbox/dedup mechanics → idempotency-keys; emitting domain events into a log instead → design-event-sourcing-cqrs.)
-
When flat states explode combinatorially, go hierarchical/parallel/history (statecharts) — don't multiply states. Harel statecharts (the basis of SCXML and XState) add three tools that kill state explosion:
- Hierarchy (nested/compound states): group
connecting/open/reconnecting under a parent online; a single Disconnect transition on the parent applies to all children — write the common edge once instead of N times.
- Parallel (orthogonal regions): independent concerns that vary simultaneously become separate regions instead of the cross-product. A media player's
{playing|paused} × {muted|unmuted} is 2 regions, not 4 states; add a third dimension and you avoid 2×2×2 = 8.
- History states: re-entering a compound state resumes the last active child (
H shallow / H* deep) — for "resume where the wizard left off" or reconnect-to-prior-substate.
Rule of thumb: a handful of states + a clear table → hand-rolled transition table (zero deps, fully testable, easiest to review). Nesting/parallelism/history, or you want a visualizer and typed assign context → XState v5 (setup({ types, actions, guards }).createMachine({...}), actor.send(event), statelyai inspector). Cross-language/standards interop → SCXML. Don't reach for a library for 3 states; don't hand-roll 4 orthogonal regions.
-
Model time/retries as real states + events, not sleeps buried in code. reconnecting with a backoff timer is a state; the timer firing is an event (RetryTimeout) that transitions back to connecting (or to failed after attempts >= max, a guard on context). Keep the retry count in machine context, not a module global. This makes the backoff policy reviewable in the table and testable without real clocks. (The retry policy — jitter, budget, circuit-breaker → resilience-timeouts-retries; this skill places those decisions as guarded transitions in the lifecycle.)
-
Visualize and review the graph; treat unreachable/trap states as bugs. Generate a diagram from the table (XState → Stately inspector; hand-rolled → emit Mermaid stateDiagram-v2, see mermaid-diagram) and eyeball it for: a trap (non-terminal state with no outgoing edge — entity gets stuck), an unreachable state (no incoming edge — dead enum value), and a missing terminal (a "done" that still has edges). Every non-terminal state should have at least one path to a terminal/expected state. A new status added without table edges shows up immediately as an orphan node.
-
Test the full transition MATRIX, including the illegal edges — that's the differentiator. For every (state, event) pair: assert legal ones land in the right target and run the entry action exactly once; assert illegal ones leave state unchanged and return the typed rejection (and emit the illegal_transition metric). Property test: from any reachable state, applying any event either transitions per the table or rejects — it never produces a state outside the enum. Add a concurrency test: two parallel Ship on the same paid order → exactly one wins (guarded UPDATE), the other gets 0-rows-and-reject. (Structure the suite → write-tests; the matrix-as-cases is a natural fit for test-data-factories/property-based-testing.)
Done = the lifecycle is one persisted enum driven by a single total transition function with explicit guards and entry/exit actions, every illegal (state,event) pair is rejected loudly (not silently swallowed), persistence is a from-state-guarded compare-and-set, side effects are atomic with the state change via an outbox, hierarchy/parallel/history are used only where flat states would explode, and the full transition matrix — legal AND illegal edges plus the concurrent-write race — is proven by the tests in checks 3–9.