--- type: reference subtype: pattern/framework title: State Machine Pattern — Transition Ownership summary: 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. tags: - type/reference - domain/software-design scope: global last_updated: 2026-07-13 date: 2026-07-13 related: - sandi-metz-code-philosophy source: 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. ## Related - [[sandi-metz-code-philosophy]] — the "one reason to change" / registry-over-conditional principles this pattern applies to state routing specifically