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).