State Design
States represent conditions -- what the machine currently is. The most important test for a state name is the "is" test: "The order is ___". If it does not read naturally, it probably should not be a state.
The Decision Rule
Does the value change which transitions are available? State. Does it just carry data forward? Context.
This single question prevents 90% of state-design mistakes. If a piece of information affects the set of outgoing transitions, it belongs in the state hierarchy. If it is merely data that actions or guards read, it belongs in context.
Anti-Pattern: State Explosion
Three independent boolean dimensions produce eight combinations:
// Anti-pattern: 8 states for 3 booleans
'states' => [
'processing_priority_insured' => [],
'processing_priority_uninsured' => [],
'processing_standard_insured' => [],
'processing_standard_uninsured' => [],
'completed_priority_insured' => [],
'completed_priority_uninsured' => [],
'completed_standard_insured' => [],
'completed_standard_uninsured' => [],
],Each new boolean doubles the state count. This is unmanageable.
Fix: Use context for the independent dimensions and keep only the lifecycle in states.
// Clean: 2 states + context flags
'context' => [
'is_priority' => false,
'is_insured' => false,
],
'states' => [
'processing' => [
'on' => [
'ORDER_COMPLETED' => [
'target' => 'completed',
'guards' => 'isInsuredGuard', // reads context
],
],
],
'completed' => ['type' => 'final'],
],If the booleans genuinely create independent lifecycles (different events, different transitions), use parallel states instead.
Anti-Pattern: Flag States
Encoding a context flag into the state name:
// Anti-pattern: same lifecycle, different flag
'processing_with_priority' => [
'on' => ['ORDER_COMPLETED' => 'completed'],
],
'processing_without_priority' => [
'on' => ['ORDER_COMPLETED' => 'completed'],
],Both states have identical transitions. The "priority" flag does not change the machine's structure.
Fix: One processing state with is_priority in context. Guards or actions read the flag when it matters.
'context' => [
'is_priority' => false,
],
'states' => [
'processing' => [
'on' => [
'ORDER_COMPLETED' => [
'target' => 'completed',
'actions' => 'notifyCompletionAction', // action checks is_priority
],
],
],
'completed' => ['type' => 'final'],
],Anti-Pattern: Mirrored States
Creating parallel copies for different actors:
// Anti-pattern: duplicated structure
'approved_by_manager' => ['on' => ['ORDER_COMPLETED' => 'completed']],
'approved_by_director' => ['on' => ['ORDER_COMPLETED' => 'completed']],
'approved_by_vp' => ['on' => ['ORDER_COMPLETED' => 'completed']],Fix: One approved state, store the approver in context.
'context' => [
'approved_by' => null,
],
'states' => [
'approved' => [
'on' => ['ORDER_COMPLETED' => 'completed'],
],
'completed' => ['type' => 'final'],
],Refactoring Recipe: Flat to Hierarchical
Consider a 12-state flat machine for an order workflow:
// Before: 12 flat states with duplicated CANCEL transitions
'states' => [
'idle' => ['on' => ['ORDER_SUBMITTED' => 'submitted', 'ORDER_CANCELLED' => 'cancelled']],
'submitted' => ['on' => ['PAYMENT_RECEIVED' => 'awaiting_shipment', 'ORDER_CANCELLED' => 'cancelled']],
'awaiting_shipment' => ['on' => ['SHIPMENT_DISPATCHED' => 'shipped', 'ORDER_CANCELLED' => 'cancelled']],
'shipped' => ['on' => ['DELIVERY_CONFIRMED' => 'delivered']],
'delivered' => ['on' => ['ORDER_CLOSED' => 'completed']],
// ... more states, each repeating ORDER_CANCELLED ...
'cancelled' => ['type' => 'final'],
'completed' => ['type' => 'final'],
],Every cancellable state duplicates ORDER_CANCELLED. Refactor into a hierarchical machine:
// After: hierarchical, with root-level cancel
'states' => [
'active' => [
'initial' => 'idle',
'states' => [
'idle' => ['on' => ['ORDER_SUBMITTED' => 'submitted']],
'submitted' => ['on' => ['PAYMENT_RECEIVED' => 'awaiting_shipment']],
'awaiting_shipment' => ['on' => ['SHIPMENT_DISPATCHED' => 'shipped']],
'shipped' => ['on' => ['DELIVERY_CONFIRMED' => 'delivered']],
'delivered' => ['on' => ['ORDER_CLOSED' => '#order_workflow.completed']],
],
'on' => [
'ORDER_CANCELLED' => '#order_workflow.cancelled', // one handler for all
],
],
'cancelled' => ['type' => 'final'],
'completed' => ['type' => 'final'],
],The ORDER_CANCELLED handler is defined once on the active parent. Event bubbling handles the rest.
Error States vs Alarm Actions
Don't create a separate state for each possible failure cause. Use a single error state per concern area and differentiate failure reasons through entry actions and context.
Anti-Pattern: State Proliferation for Errors
// Anti-pattern: one state per error cause
'payment_gateway_timeout' => ['type' => 'final'],
'payment_insufficient_funds' => ['type' => 'final'],
'payment_card_declined' => ['type' => 'final'],
'payment_fraud_detected' => ['type' => 'final'],Four final states for the same concern (payment failure). Each adds transitions, test cases, and cognitive load without adding control value -- the parent machine rarely routes differently for each cause.
Fix: Single Error State, Reason in Context
'payment_failed' => [
'type' => 'final',
'entry' => 'recordPaymentFailureAction', // stores reason in context
],The entry action records the specific cause (gateway_timeout, insufficient_funds, etc.) in context for logging and debugging. The state itself carries the control signal: "payment failed."
When Separate Error States ARE Appropriate
If the machine is a child and the parent needs to route differently based on failure type, use separate final states with @done.{state} routing. Separate states are for control (the parent needs to branch). Context and actions are for information (logging, notification, debugging).
Guidelines
Apply the "is" test. Every state name must complete "The order is ___". Use adjectives (
idle,active), past participles (submitted,paid), or present participles (processing,validating).State changes = transition changes. If adding a new value does not add, remove, or alter any transitions, it belongs in context, not states.
Prefer hierarchy over duplication. Group states that share common transitions under a parent and define shared handlers once.
Limit leaf states to 5-7 per parent. If a parent has more than seven children, look for grouping opportunities.
Related
- Naming Conventions -- state naming rules
- Hierarchical States -- parent-child structure
- Context Design -- when to use context instead
- Event Bubbling -- how parent handlers work