What a state machine is
A state machine defines a finite set of states, the allowed transitions between them, and the events that trigger those transitions. At any moment the system sits in exactly one state; only declared transitions can move it elsewhere. Everything else is invalid by design.
How they help
- Make rules explicit: States and transitions are codified rather than implied, so edge cases are visible instead of hidden in conditionals.
- Prevent illegal moves: Impossible transitions (e.g., "ship" before "paid") simply do not exist, cutting whole classes of bugs.
- Improve observability: Current state and recent transitions act as a timeline you can log, audit, and alert on.
- Simplify testing: Each transition is a small contract to test; fixtures focus on events and expected next states.
- Handle retries and idempotency: Transitions are event-driven with clear preconditions, making safe retries and duplicate suppression easier.
- Tame concurrency: With a single authoritative state and guarded transitions, race conditions shrink to well-defined conflict points.
- Ease rollbacks: Rolling back is just another transition (to a prior or error state) instead of ad-hoc flag surgery.
- Align teams: A shared diagram/table of states and transitions gives product, QA, and engineering the same vocabulary.
When to reach for one
- Workflows with approvals, payments, provisioning, or shipping where order matters.
- Long-running processes with retries, timeouts, and external callbacks/webhooks.
- Systems with many error paths that need graceful recovery (e.g., payments, billing, onboarding).
- Multi-actor processes (customer, back office, external vendor) where you need to prevent illegal jumps.
Design guidelines
- Keep states coarse: prefer a handful of meaningful states over dozens of micro-states. Encode details as data/flags.
- Make transitions event-driven: name events after business facts (e.g.,
payment_captured,document_approved). - Centralize guards: each transition has preconditions; reject with a clear reason when unmet.
- Model failure explicitly: add terminal error states or recovery loops, not silent no-ops.
- Separate state from side effects: compute next state first; fire effects (emails, jobs) after state persists.
- Log transitions: include actor, timestamp, prior state, next state, event, correlation id.
- Version the machine: if you change states/transitions, version or migrate persisted instances safely.
Common pitfalls
- State explosion by modeling every sub-case as a new state instead of data.
- Hidden transitions buried in ad-hoc conditionals outside the machine.
- Side effects inside transition functions that can partially apply on failure.
- Skipping idempotency: duplicate events should either be ignored or handled as safe retries.
- Ignoring time: no handling for timeouts/expiry leaves stuck items.
- Missing ownership: unclear which service/component is the source of truth for state.
Implementation checklist
- Enumerate states and events; reject anything not listed.
- Define allowed transitions as a table/graph; block the rest.
- Specify guards and effects per transition; keep effects retryable.
- Persist state atomically with the triggering event/correlation id.
- Make transitions idempotent; handle duplicates and out-of-order events.
- Expose state + history for observability and audits.
- Add metrics: counts per state, transition success/failure, time-in-state.
Pseudocode example (order workflow)
This is language-agnostic and keeps responsibilities clear: define states/events, declare allowed transitions with optional guards and side effects, then run a small handler that enforces the table.
States: CREATED, PAID, PACKING, SHIPPED, FAILED.
Events: pay, pack, ship, fail.
Transition table (guards and side effects inline):
machine = {
CREATED: {
pay(event) -> PAID if event.valid_payment
fail(reason) -> FAILED
},
PAID: {
pack(event) -> PACKING
fail(reason) -> FAILED
},
PACKING: {
ship(trackingId) -> SHIPPED with sideEffect(sendEmail(trackingId))
fail(reason) -> FAILED
},
SHIPPED: {},
FAILED: {}
}
Handler (enforces table, guards, persistence, then side effects):
- Look up the allowed transition for the current state and incoming event.
- Reject if no transition exists or its guard fails.
- Apply the transition to get the next state and any side effects.
- Persist the state change with the event data, then run side effects.
- Return the new state.
state = CREATED
function handle(event):
current = state
transition = machine[current][event.type]
if not transition:
return error("illegal transition")
if transition.hasGuard and not transition.guard(event):
return error("guard failed")
nextState, sideEffects = transition.apply(event)
persist(currentState=current, nextState=nextState, event=event)
for effect in sideEffects:
effect.run()
state = nextState
return ok(state)
Notes:
- Guards run before changing state; a guard failure leaves state untouched.
- Persist state (and event/correlation id) before firing side effects so retries stay safe.
- Side effects should be retryable or idempotent; log every transition for audits/alerts.
- To add timeouts, model them as events and transitions (e.g.,
timeout -> FAILED).
