| name | state-machine-design |
| description | Model complex workflows as explicit state machines. Outputs state diagrams, transition tables, guard conditions, and production-ready implementations with audit trails. |
| argument-hint | ["entity being modelled","states","triggering events","side effects needed"] |
| allowed-tools | Read, Write |
State Machine Design
State machines make implicit workflow logic explicit. Instead of scattered if status == 'x' checks, you get a single source of truth: a defined set of states, valid transitions, and guards that enforce business rules. Invalid transitions become impossible, not just unexpected.
Process
- List all states. Every distinct situation the entity can be in. Include terminal states (completed, cancelled, failed).
- List all events/triggers. What causes transitions? User actions, timeouts, external callbacks, system events.
- Define transitions. For each (state, event) pair: what's the target state? What guards apply? What side effects fire?
- Draw the state diagram. Visualise. Missing arrows reveal undefined behaviour. Unreachable states reveal dead code.
- Define entry/exit actions. What happens automatically when entering or leaving a state?
- Add guards. Conditions that must be true for a transition to be allowed.
- Implement. Persist current state. Apply transitions atomically. Log every transition with actor and timestamp.
- Handle invalid transitions. Explicit error, not silent no-op.
State Diagram — Order Lifecycle
┌─────────┐
│ DRAFT │◄─────────────────────────────┐
└────┬────┘ │
│ submit() │
▼ │
┌────────────┐ │
│ PENDING │ │
└─────┬──────┘ │
┌─────────────┼──────────────┐ │
│ │ │ │
pay() │ reject() │ cancel() │ │
▼ ▼ ▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ PAID │ │ REJECTED │ │CANCELLED │ │
└────┬─────┘ └──────────┘ └──────────┘ │
│ │
ship() │ refund() │
▼ │
┌────────────┐ payment_failed() │
│ SHIPPED │──────────────────────────────────────────────┘
└─────┬──────┘ (refund → DRAFT for retry)
│
deliver() │
▼
┌────────────┐
│ DELIVERED │ ← terminal
└────────────┘
Transition Table
| Current State | Event | Guard | Next State | Actions |
|---|
| DRAFT | submit | items.count > 0 | PENDING | notify_ops |
| PENDING | pay | payment.valid | PAID | capture_payment, reserve_stock |
| PENDING | reject | fraud_score > 80 | REJECTED | notify_customer |
| PENDING | cancel | — | CANCELLED | release_hold |
| PAID | ship | stock_reserved | SHIPPED | create_tracking, notify_customer |
| SHIPPED | deliver | — | DELIVERED | release_funds, request_review |
| SHIPPED | payment_failed | — | DRAFT | refund, release_stock |
| * | any | — | — | raise InvalidTransition |
Implementation
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Callable, Dict, Tuple
class OrderState(str, Enum):
DRAFT = "draft"
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
REJECTED = "rejected"
CANCELLED = "cancelled"
class OrderEvent(str, Enum):
SUBMIT = "submit"
PAY = "pay"
REJECT = "reject"
CANCEL = "cancel"
SHIP = "ship"
DELIVER = "deliver"
REFUND = "refund"
class InvalidTransition(Exception):
def __init__(self, state, event):
super().__init__(f"Cannot apply '{event}' in state '{state}'")
@dataclass
class TransitionResult:
previous_state: OrderState
new_state: OrderState
event: OrderEvent
occurred_at: datetime
actor: str
class OrderStateMachine:
_transitions: [, ] = {
(OrderState.DRAFT, OrderEvent.SUBMIT): (OrderState.PENDING, , ),
(OrderState.PENDING, OrderEvent.PAY): (OrderState.PAID, , ),
(OrderState.PENDING, OrderEvent.REJECT): (OrderState.REJECTED, , ),
(OrderState.PENDING, OrderEvent.CANCEL): (OrderState.CANCELLED, , ),
(OrderState.PAID, OrderEvent.SHIP): (OrderState.SHIPPED, ,),
(OrderState.SHIPPED, OrderEvent.DELIVER): (OrderState.DELIVERED, , ),
(OrderState.SHIPPED, OrderEvent.REFUND): (OrderState.DRAFT, , ),
}
():
._order = order
._services = services
._history = []
() -> TransitionResult:
key = (._order.state, event)
key ._transitions:
InvalidTransition(._order.state, event)
next_state, guard_name, action_name = ._transitions[key]
guard_name:
guard = (, guard_name)
guard(**kwargs):
InvalidTransition(
._order.state,
)
previous = ._order.state
._order.state = next_state
action_name:
(, action_name)(**kwargs)
result = TransitionResult(
previous_state=previous,
new_state=next_state,
event=event,
occurred_at=datetime.utcnow(),
actor=actor,
)
._history.append(result)
result
():
(._order.items) >
():
payment payment.is_valid
():
._services.inventory.is_reserved(._order.)
():
._services.notifications.notify_ops(._order)
():
._services.payments.capture(payment)
._services.inventory.reserve(._order)
():
tracking = ._services.shipping.create_shipment(._order)
._order.tracking_number = tracking.number
._services.notifications.notify_customer(._order, )
():
._services.payments.release_to_merchant(._order)
._services.reviews.request(._order.customer_id)
():
._services.notifications.notify_customer(._order, )
():
._services.inventory.release_hold(._order.)
():
._services.payments.refund(._order)
._services.inventory.release_stock(._order)
() -> :
(._order.state, event) ._transitions
() -> :
[e (s, e) ._transitions s == ._order.state]
Persisted State with Audit Trail
"""
CREATE TABLE orders (
id UUID PRIMARY KEY,
state VARCHAR(20) NOT NULL DEFAULT 'draft',
updated_at TIMESTAMP NOT NULL,
version INTEGER NOT NULL DEFAULT 0 -- optimistic locking
);
CREATE TABLE order_state_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id),
previous_state VARCHAR(20),
new_state VARCHAR(20) NOT NULL,
event VARCHAR(50) NOT NULL,
actor VARCHAR(255),
metadata JSONB,
occurred_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
class OrderRepository:
def transition(self, order_id: str, event: OrderEvent, actor: str, **kwargs):
with self.db.transaction():
order = self.db.query(
"SELECT * FROM orders WHERE id = %s FOR UPDATE",
[order_id]
).fetchone()
sm = OrderStateMachine(order, self.services)
result = sm.apply(event, actor, **kwargs)
rows = self.db.execute(
"""UPDATE orders SET state = %s, updated_at = NOW(), version = version + 1
WHERE id = %s AND version = %s""",
[result.new_state, order_id, order.version]
)
if rows == 0:
raise ConcurrentModificationError(order_id)
self.db.execute(
"""INSERT INTO order_state_history
(order_id, previous_state, new_state, event, actor, occurred_at)
VALUES (%s, %s, %s, %s, %s, %s)""",
[order_id, result.previous_state, result.new_state,
event, actor, result.occurred_at]
)
result
XState — JavaScript State Machine
import { createMachine, interpret } from 'xstate';
const orderMachine = createMachine({
id: 'order',
initial: 'draft',
states: {
draft: { on: { SUBMIT: { target: 'pending', guard: 'hasItems' } } },
pending: {
on: {
PAY: { target: 'paid', guard: 'paymentValid', actions: 'capturePayment' },
REJECT: { target: 'rejected', actions: 'notifyRejected' },
CANCEL: { target: 'cancelled', actions: 'releaseHold' },
}
},
paid: { on: { SHIP: { target: 'shipped', guard: 'stockReady' } } },
shipped: { on: { DELIVER: { target: 'delivered', actions: 'releaseFunds' },
REFUND: { target: 'draft', : } } },
: { : },
: { : },
: { : },
},
}, {
: {
: context.. > ,
: event.?.,
: context.,
},
: {
: paymentService.(event.),
: emailService.(context., ),
: paymentService.(context.),
: paymentService.(context.),
: inventoryService.(context.),
}
});
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Fix |
|---|
| Status flags scattered in code | if status == 'x' and is_paid and not cancelled everywhere | Single state field, machine enforces validity |
| Missing terminal states | State can loop forever | Model DELIVERED, CANCELLED, FAILED explicitly |
| Side effects outside actions | Logic outside the machine bypasses guards | All state changes go through machine |
| No audit trail | Can't reconstruct what happened or when | Log every transition with actor and timestamp |
| Concurrent transitions without locking | Race conditions put entity in invalid state | Optimistic or pessimistic locking on state change |
| Too many states | Machine becomes unreadable | Nested/hierarchical states for complex sub-flows |
| Transition without event | State mutated directly: order.state = 'paid' | Always apply named events |
10 Rules
- States are nouns; events are verbs.
PAID is a state. pay() is an event.
- Undefined (state, event) pairs are always errors — never silent no-ops.
- Every transition is atomic — state change and side effects succeed or fail together.
- Guards are pure functions with no side effects.
- Actions fire after the state has changed — never before.
- Persist audit history alongside state — who triggered what transition and when.
- Terminal states have no outgoing transitions.
- Draw the diagram first. If you can't draw it, you don't understand it yet.
- Use optimistic locking to prevent concurrent transitions corrupting state.
- Expose
available_events() from the machine to drive UI — button enable/disable comes from machine, not from scattered if logic.