SecondBrain/reference/state-machine-pattern.md

64 lines
4.1 KiB
Markdown
Raw Permalink Normal View History

2026-07-13 18:36:59 +00:00
---
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
2026-07-13 20:39:01 +00:00
source: delta-refinery/docs/state_machine_reference.md
2026-07-13 18:36:59 +00:00
---
# 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