| name | design-state-machine |
| description | Models a lifecycle (order status, connection, checkout/approval flow, device/job state) as an EXPLICIT finite state machine or statechart instead of boolean-flag soup — enumerate states + events as closed sets, define transitions as a total (state×event)→state function with guards and entry/exit actions, make the current state a single persisted column (not N booleans), reject every undefined (state,event) pair loudly, and reach for hierarchical/parallel/history statecharts (Harel/SCXML semantics, XState v5 setup/createMachine, or a hand-rolled transition table) once flat states explode combinatorially; persist with optimistic-lock guarded transitions, drive side effects from entry actions or an outbox, and test by asserting the full transition matrix including illegal-edge rejection. |
| when_to_use | A thing moves through named stages where only some transitions are legal and code is sprouting isPaid/isShipped/isCancelled flags, scattered if-ladders, or "how did it get into THIS state?" bugs — order/payment/subscription status, WebSocket/TCP connection lifecycle, multi-step wizard or approval workflow, document review, or a long-running job. Distinct from design-event-sourcing-cqrs (the append-only event LOG is the source of truth and state is a fold/projection over it; this skill models the state graph itself and may persist only the current state) and async-concurrency-correctness (races/locks/ordering between concurrent tasks; this skill models one entity's legal transitions, then uses a guarded write so concurrent transitions don't corrupt it). |
When to Use
Reach for this skill when an entity moves through named stages and only some moves are legal:
- "Order goes pending → paid → shipped → delivered, can also cancel/refund — model it properly"
- "We have
isPaid && !isShipped && !isCancelled checks everywhere and they keep contradicting"
- "How did this row end up paid AND cancelled?" / "a refund fired on an unpaid order"
- "Connection lifecycle: connecting → open → reconnecting → closed with backoff"
- "Multi-step checkout / approval workflow / document review with back-and-forth"
- "Add a new status and half the if-ladders broke" / "illegal transition slipped through"
- "Should I use XState, or a transition table, or just an enum?"
NOT this skill:
- The append-only event log is the source of truth and state is rebuilt by folding events, with separate read models → design-event-sourcing-cqrs (this skill models the legal-transition graph and may persist only the current state; you can combine them — an FSM that emits events into a log)
- Concurrency between tasks — locks, ordering, races, async correctness → async-concurrency-correctness (this skill defines one entity's legal moves; it then uses a guarded/optimistic write so two concurrent transitions don't corrupt the row)
- Distributed mutual exclusion / leader leases across nodes → distributed-locks-leases
- Workflow orchestration across multiple agents/services (sagas, fan-out, retries) → orchestrate-agent-workflow (use this skill to model each participant's local state)
- Idempotent retries so a replayed transition command is a no-op → idempotency-keys (this skill makes the transition function; that makes invoking it twice safe)
- The DB column type / safe migration to add the status column or new enum value → db-migration-safety; how the enum evolves without breaking old readers → schema-evolution-compatibility
- A front-end multi-step form's validation/field state → build-form-validation; client/server cache sync → manage-client-server-state
Steps
-
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 |
Common Errors
- Boolean-flag soup (
isPaid && !isShipped && !isCancelled). N booleans encode 2^n combinations but only a few are legal; contradictory states ("shipped, not paid") become representable and do happen. Fix: one status enum = exactly the legal conditions; make illegal states unrepresentable.
- Transition logic scattered across
if-ladders in controllers/services. No single place owns "what's legal"; a new caller forgets a guard. Fix: one transition function + table; route 100% of changes through it.
- Silently ignoring out-of-order events.
if (status === 'paid') ship() with no else swallows a Ship on a cancelled order — masking double-clicks, replays, races. Fix: explicit reject + log/metric illegal_transition; the alarm is the value.
- Blind
UPDATE ... SET status = 'shipped' WHERE id = ?. No from-state guard → a stale/concurrent writer overwrites a state it never saw. Fix: WHERE status = <expected_from> AND version = ?; 0 rows ⇒ re-read and re-decide.
- Side effect outside the state transaction. Charge fires, then the status write crashes (or vice versa) → state and effect diverge. Fix: status change + outbox row in one transaction; relay publishes; consumers dedup (idempotency-keys).
- Entry action that isn't idempotent. Re-entering a state (retry, replay) double-sends the email/double-charges. Fix: idempotent entry actions, or gate the effect on the transition having actually committed.
- State explosion from flattening orthogonal concerns. Modeling
{playing,paused}×{muted,unmuted} as 4 flat states, then 8, then 16. Fix: parallel regions (one per independent concern); they compose instead of multiply.
- Reaching for a heavy library for 3 states (or hand-rolling 4 orthogonal regions). Fix: match the tool to the shape — table for flat/small, XState for hierarchy/parallel/history, SCXML for cross-language.
- Trap / unreachable states. A non-terminal state with no exit (stuck forever) or an enum value with no incoming edge (dead). Fix: visualize the graph; assert reachability and that every non-terminal has an outgoing edge.
- Timers/retries as
sleep() buried in handlers. Backoff logic invisible to the spec, untestable without real time. Fix: model reconnecting/RetryTimeout as state+event with the attempt count in context.
Verify
- No flag soup: grep the diff for
is<X> && !is<Y>-style combinations on one entity; the state is a single enum/column, and contradictory combinations are no longer representable.
- One transition function: every status mutation routes through the single
transition(state,event); no ad-hoc SET status = or if (status !== ...) outside it (grep for stray status writes).
- Illegal edges rejected: for the full
(state,event) matrix, illegal pairs leave state unchanged and return the typed rejection + emit illegal_transition; guard-failures return 409/422 with a reason, not a crash.
- Legal edges + entry actions: each table transition lands in the correct target and runs its entry action exactly once (assert via spy/counter), even on a re-entry path.
- Guarded persistence: the UPDATE is conditional on the expected from-state (and version); a test with two concurrent transitions on the same row shows exactly one commits, the other gets 0-rows-and-reject.
- Atomic effects: status change and external-effect publish are in one transaction (outbox); kill the process between them → on restart the relay still publishes (effect recorded iff state changed), no orphan/lost effect.
- Graph is sound: generated diagram has no trap (non-terminal with no exit), no unreachable state (no incoming edge), terminals truly terminal; every non-terminal reaches a terminal.
- Statechart features (if used): a parent transition applies to all nested children (written once); parallel regions vary independently; a history state resumes the prior child.
- Property test holds: from any reachable state, any event either transitions per the table or rejects — never yields a value outside the state enum.
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.