SecondBrain/reference/state-machine-pattern.md

4.1 KiB

type subtype title summary tags scope last_updated date related source
reference pattern/framework State Machine Pattern — Transition Ownership When and how to model a multi-stage workflow as an explicit state machine, centered on the rule that the domain object — not the orchestrator or workers — owns its own legal transitions.
type/reference
domain/software-design
global 2026-07-13 2026-07-13
sandi-metz-code-philosophy
delta-refinery

State Machine Pattern — Transition Ownership

Purpose

Reference for deciding when a workflow needs a state machine and how to assign responsibility so the machine stays deterministic as the workflow grows.

Core Principles

Transition ownership is the root idea everything else derives from. The domain object (the thing whose state is changing) is the only class allowed to decide whether a transition is legal. Orchestrators route based on state; workers request transitions via named events. Once this is right, most other design problems in the workflow disappear — and most drift traces back to this eroding.

Decision Framework

Reach for a state machine when: the workflow has named stages, only certain transitions between them are valid, and the next action depends on current state. If you're seeing scattered if/case statements checking or setting a status field across multiple classes, that's the tell that an implicit state machine already exists and needs to be made explicit.

Patterns

Domain object owns transitions

The domain object exposes named event methods (activate!, submit_for_review!, accept!, fail!(reason)), never a raw setter. Only it decides if a transition from the current state is legal.

Workers trigger events, never assign state

A worker that finishes its job calls requirement.submit_for_review!; it never does requirement.state = :review_pending. This keeps the legality check in one place.

Runner routes by inspecting state

The orchestrator picks the next eligible unit of work by reading current state (pipeline.next_requirement_for(:programmer)), not by following a hardcoded sequence. A hardcoded sequence works for a demo but breaks the moment retries, recovery, or parallel work enter the picture — state-based routing doesn't care how an item got to its current state.

State change as the signal

Workers don't need a separate notification channel back to the orchestrator. The orchestrator inspects the domain object's state after the worker returns to decide what happens next.

Invalid transitions fail loudly

Guard every event method so an illegal transition (e.g. accept! from :queued) raises rather than silently succeeding or no-op'ing.

Explicit failure policy

Pick one of: fail-fast (stop the whole run on first failure — good for single-item sequential runs), best-effort (skip failed items, continue routing others — good for batch runs), or recovery-and-retry (route failures to a recovery step, then re-enter the loop). The state machine itself is agnostic to which policy you pick; the runner encodes it.

Anti-Patterns

  • Multiple classes assign state = ... directly → transition-ownership violation; route through named events on the domain object
  • Runner hardcodes a fixed sequence of steps → breaks under retries/recovery/parallelism; route by inspecting state instead
  • Workers fetch their own next unit of work or know about other workers → boundary violation; that's the runner's job
  • Ambiguous or catch-all states (e.g. generic :pending) → unclear terminal/guard semantics; keep states small and specific
  • Invalid transitions silently ignored → hides bugs; raise instead

Known Limitations

This model assumes a single domain object with a single state field per workflow unit. Cross-object transitions (where two domain objects' states must change atomically) need an explicit coordination layer on top of this pattern — it doesn't cover that case by itself.

  • sandi-metz-code-philosophy — the "one reason to change" / registry-over-conditional principles this pattern applies to state routing specifically