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. |
|
global | 2026-07-13 | 2026-07-13 |
|
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