State Machine Implementation
How do I implement a state machine in code?
TL;DR
- Bottom line: Explicit state + transitions prevent invalid states and eliminate boolean flag soup; model your system as states, events, and a transition table.
- Key tool/command:
state/event/transition table— define every valid (state, event) -> nextState mapping; reject everything else. - Watch out for: Using multiple boolean flags instead of a single state enum, which creates impossible/untested state combinations.
- Works with: Any language — XState (TypeScript), python-statemachine (Python), Spring State Machine (Java), or hand-rolled in Go.
Constraints
- Every state machine must have exactly one initial state and must define all valid transitions explicitly
- Guard conditions must be pure functions with no side effects; never mutate state inside a guard
- Transition actions (entry/exit/transition) must be idempotent when the state machine is persisted or replayed
- Never allow direct state assignment — all state changes must go through the transition function
- For DB-backed workflows, wrap state transitions and side effects in a single database transaction
Quick Reference
| Approach | Complexity | Type Safety | Persistence | Best For |
|---|---|---|---|---|
| Switch/case on enum | Low | Medium | Manual | Simple flows, 3-5 states, no hierarchy |
| State transition table (map) | Low-Medium | High | Easy to serialize | Flat FSMs, config-driven flows |
| State pattern (OOP) | Medium | High | Manual | Open/closed principle, per-state behavior |
| XState / Statecharts | Medium-High | Very High | Built-in (inspect, persist) | Complex UI flows, hierarchical/parallel states |
| DB-backed workflow | High | Medium | Native (row = state) | Order lifecycles, multi-step approval flows |
| Coroutine/generator-based | Medium | Low-Medium | Difficult | Streaming parsers, protocol handlers |
| Component | Role | When to Add |
|---|---|---|
| States (enum) | All valid configurations | Always — define first |
| Events (enum/type) | Triggers that cause transitions | Always — finite set of inputs |
| Transition table | Maps (state, event) -> nextState | Always — the core of the FSM |
| Guards | Boolean predicates that allow/block transitions | When transitions are conditional |
| Entry/exit actions | Side effects on state entry or exit | When states trigger work (API calls, notifications) |
| Context (extended state) | Mutable data carried alongside discrete state | When you need counters, retries, accumulated data |
| Hierarchical states | Nested state machines (substates) | When states share common transitions |
| Parallel states | Orthogonal regions executing simultaneously | When independent concerns evolve independently |
Decision Tree
START
|-- How many states?
| |-- 2-3 simple states, no guards
| | --> switch/case on enum (simplest, inline)
| |-- 4-10 states, flat transitions
| | |-- Need runtime config or serialization?
| | | |-- YES --> State transition table (map/dict)
| | | |-- NO --> State pattern (OOP) or switch/case
| |-- 10+ states OR hierarchical/parallel
| | --> XState / statechart library
|-- Does state survive restarts?
| |-- YES, short-lived (minutes)
| | --> Serialize state to Redis/cache
| |-- YES, long-lived (hours/days)
| | --> DB-backed workflow with row-level state
| |-- NO
| | --> In-memory FSM
|-- Is this a parser or protocol handler?
| |-- YES --> Coroutine/generator-based FSM
| |-- NO --> One of the above based on complexity
Step-by-Step Guide
1. Define your states as an exhaustive enum
Enumerate every valid state your system can be in. Do not leave room for implicit states. [src3]
// TypeScript: use a string literal union for exhaustive checking
type OrderState = 'idle' | 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
Verify: Confirm every state is reachable and every state has at least one exit transition (except terminal states).
2. Define your events
List every trigger that can cause a state change. Events are the inputs to your FSM. [src1]
type OrderEvent =
| { type: 'PLACE_ORDER'; items: string[] }
| { type: 'CONFIRM_PAYMENT' }
| { type: 'SHIP'; trackingId: string }
| { type: 'DELIVER' }
| { type: 'CANCEL'; reason: string };
Verify: Every event has at least one state that handles it. Unhandled events should be explicitly rejected.
3. Build the transition table
Map every valid (currentState, event) pair to its nextState. This is the core of your FSM. [src7]
const transitions: Record<OrderState, Partial<Record<OrderEvent['type'], OrderState>>> = {
idle: { PLACE_ORDER: 'pending' },
pending: { CONFIRM_PAYMENT: 'confirmed', CANCEL: 'cancelled' },
confirmed: { SHIP: 'shipped', CANCEL: 'cancelled' },
shipped: { DELIVER: 'delivered' },
delivered: {},
cancelled: {},
};
Verify: Count transitions. For N states and M events, you should have far fewer than N*M entries.
4. Implement the transition function
Write a single function that looks up the transition, applies guards, and returns the new state. [src4]
function transition(current: OrderState, event: OrderEvent): OrderState {
const nextState = transitions[current]?.[event.type];
if (!nextState) {
throw new Error(`Invalid transition: ${current} + ${event.type}`);
}
return nextState;
}
Verify: transition('idle', { type: 'PLACE_ORDER', items: ['A'] }) returns 'pending'. Invalid transitions throw.
5. Add guards and actions
Guards gate transitions; actions execute side effects on entry, exit, or transition. Keep guards pure and actions idempotent. [src1]
// Guard: only allow cancellation if not yet shipped
function canCancel(state: OrderState): boolean {
return state === 'pending' || state === 'confirmed';
}
// Action: send notification on state entry (idempotent)
function onEnterShipped(context: { trackingId: string }): void {
sendTrackingEmail(context.trackingId);
}
Verify: Test guards return correct boolean for each state. Test actions are idempotent.
Code Examples
TypeScript/XState: Order Lifecycle
// Input: XState v5 installed (npm install xstate)
// Output: Type-safe order state machine with guards and actions
import { createMachine, createActor } from 'xstate';
const orderMachine = createMachine({
id: 'order',
initial: 'idle',
context: { items: [] as string[], trackingId: '' },
states: {
idle: {
on: { PLACE_ORDER: { target: 'pending', actions: 'setItems' } }
},
pending: {
on: {
CONFIRM_PAYMENT: 'confirmed',
CANCEL: 'cancelled'
}
},
confirmed: {
on: {
SHIP: { target: 'shipped', actions: 'setTracking' },
CANCEL: 'cancelled'
}
},
shipped: {
on: { DELIVER: 'delivered' },
entry: 'notifyShipped'
},
delivered: { type: 'final' },
cancelled: { type: 'final' }
}
});
const actor = createActor(orderMachine).start();
actor.send({ type: 'PLACE_ORDER', items: ['Widget'] });
console.log(actor.getSnapshot().value); // 'pending'
Python: Transition Table FSM
# Input: Python 3.10+ (match/case syntax)
# Output: Strict FSM that rejects invalid transitions
from enum import Enum, auto
from dataclasses import dataclass, field
class State(Enum):
IDLE = auto()
PENDING = auto()
CONFIRMED = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
class Event(Enum):
PLACE_ORDER = auto()
CONFIRM_PAYMENT = auto()
SHIP = auto()
DELIVER = auto()
CANCEL = auto()
@dataclass
class StateMachine:
state: State = State.IDLE
_transitions: dict[tuple[State, Event], State] = field(
default_factory=lambda: {
(State.IDLE, Event.PLACE_ORDER): State.PENDING,
(State.PENDING, Event.CONFIRM_PAYMENT): State.CONFIRMED,
(State.PENDING, Event.CANCEL): State.CANCELLED,
(State.CONFIRMED, Event.SHIP): State.SHIPPED,
(State.CONFIRMED, Event.CANCEL): State.CANCELLED,
(State.SHIPPED, Event.DELIVER): State.DELIVERED,
}
)
def send(self, event: Event) -> State:
key = (self.state, event)
if key not in self._transitions:
raise ValueError(
f"Invalid transition: {self.state.name} + {event.name}"
)
self.state = self._transitions[key]
return self.state
# Usage
fsm = StateMachine()
fsm.send(Event.PLACE_ORDER) # -> State.PENDING
fsm.send(Event.CONFIRM_PAYMENT) # -> State.CONFIRMED
Go: Interface-Based State Pattern
// Input: Go 1.21+
// Output: State machine using interfaces for open/closed extensibility
package statemachine
import "fmt"
type Event string
const (
PlaceOrder Event = "PLACE_ORDER"
ConfirmPayment Event = "CONFIRM_PAYMENT"
Ship Event = "SHIP"
Deliver Event = "DELIVER"
Cancel Event = "CANCEL"
)
type State interface {
Name() string
Handle(event Event) (State, error)
}
type IdleState struct{}
func (s *IdleState) Name() string { return "idle" }
func (s *IdleState) Handle(e Event) (State, error) {
if e == PlaceOrder {
return &PendingState{}, nil
}
return nil, fmt.Errorf("invalid event %s in state %s", e, s.Name())
}
type PendingState struct{}
func (s *PendingState) Name() string { return "pending" }
func (s *PendingState) Handle(e Event) (State, error) {
switch e {
case ConfirmPayment:
return &ConfirmedState{}, nil
case Cancel:
return &CancelledState{}, nil
default:
return nil, fmt.Errorf("invalid event %s in state %s", e, s.Name())
}
}
type OrderFSM struct {
Current State
}
func NewOrderFSM() *OrderFSM {
return &OrderFSM{Current: &IdleState{}}
}
func (fsm *OrderFSM) Send(event Event) error {
next, err := fsm.Current.Handle(event)
if err != nil {
return err
}
fsm.Current = next
return nil
}
Java: DB-Backed Order Lifecycle
// Input: Java 17+, JPA/JDBC
// Output: Database-persisted state machine with transactional transitions
public enum OrderState {
IDLE, PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED;
private static final Map<OrderState, Map<String, OrderState>> TRANSITIONS = Map.of(
IDLE, Map.of("PLACE_ORDER", PENDING),
PENDING, Map.of("CONFIRM_PAYMENT", CONFIRMED, "CANCEL", CANCELLED),
CONFIRMED, Map.of("SHIP", SHIPPED, "CANCEL", CANCELLED),
SHIPPED, Map.of("DELIVER", DELIVERED),
DELIVERED, Map.of(),
CANCELLED, Map.of()
);
public OrderState transition(String event) {
Map<String, OrderState> allowed = TRANSITIONS.getOrDefault(this, Map.of());
OrderState next = allowed.get(event);
if (next == null) {
throw new IllegalStateException(
"Invalid transition: " + this + " + " + event
);
}
return next;
}
}
// Service layer — wrap in a transaction
@Transactional
public Order processEvent(Long orderId, String event) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order not found"));
OrderState newState = order.getState().transition(event);
order.setState(newState);
order.setUpdatedAt(Instant.now());
return orderRepo.save(order); // @Version for optimistic locking
}
Anti-Patterns
Wrong: Boolean flags instead of explicit states
// BAD -- boolean flags create 2^N possible combinations, most of which are invalid
interface Order {
isPending: boolean;
isConfirmed: boolean;
isShipped: boolean;
isCancelled: boolean;
}
// What does { isPending: true, isShipped: true, isCancelled: true } mean?
Correct: Single state enum
// GOOD -- exactly N valid states, no impossible combinations
interface Order {
state: 'idle' | 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
}
Wrong: Implicit transitions via direct state mutation
# BAD -- anyone can set any state at any time; no validation
class Order:
def __init__(self):
self.state = "idle"
def ship(self):
self.state = "shipped" # No check: can "ship" a cancelled order
order = Order()
order.state = "delivered" # Direct mutation bypasses all rules
Correct: Transitions enforced through a single function
# GOOD -- all state changes go through validated transition function
class Order:
def __init__(self):
self._state = State.IDLE
@property
def state(self):
return self._state
def send(self, event):
key = (self._state, event)
if key not in TRANSITIONS:
raise ValueError(f"Cannot {event.name} from {self._state.name}")
self._state = TRANSITIONS[key]
return self._state
Wrong: Missing guard conditions on transitions
// BAD -- transition always succeeds regardless of business rules
public OrderState ship(Order order) {
order.setState(OrderState.SHIPPED); // No guard: 0-item order can be "shipped"
return order.getState();
}
Correct: Guards validate preconditions before transitioning
// GOOD -- guard ensures business invariants before state change
public OrderState ship(Order order) {
if (order.getItems().isEmpty()) {
throw new IllegalStateException("Cannot ship an order with no items");
}
if (order.getState() != OrderState.CONFIRMED) {
throw new IllegalStateException("Can only ship confirmed orders");
}
order.setState(OrderState.SHIPPED);
return order.getState();
}
Common Pitfalls
- State explosion in flat FSMs: Adding states for every combination (e.g.,
pending_with_coupon) leads to exponential growth. Fix: use extended state (context) for data that varies within a state, or hierarchical states. [src1] - Forgotten terminal states: Omitting
type: 'final'or not detecting completion means actors/processes never clean up. Fix: explicitly mark terminal states and handle thedoneevent. [src1] - Race conditions in DB-backed FSMs: Two concurrent requests reading the same row can both transition from the same state. Fix: use
SELECT ... FOR UPDATEor optimistic locking (@Version). [src6] - Side effects in guards: Putting API calls or DB writes inside guard functions makes transitions non-deterministic and breaks replay. Fix: guards return
booleanonly; put side effects in actions. [src3] - No error/failure state: Happy-path-only FSMs crash ungracefully. Fix: add an explicit
errororfailedstate with retry transitions. [src7] - Testing only happy paths: Testing
idle -> pending -> confirmed -> shipped -> deliveredbut never testing invalid transitions. Fix: test that every invalid (state, event) pair throws or returns an error. [src4]
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| System has 3+ distinct states with defined transitions | Only 2 states (on/off, enabled/disabled) | Simple boolean or enum toggle |
| Multiple boolean flags create impossible combinations | State changes are trivial and linear (A -> B -> C, no branching) | Simple sequential pipeline |
| Correctness is critical (payments, auth, workflows) | UI component with simple show/hide logic | Conditional rendering / CSS classes |
| You need to persist and resume long-running processes | Real-time streaming data transformation | Stream processors (RxJS, Kafka Streams) |
| Business rules dictate which transitions are allowed | Full workflow with parallel branches, compensation, timers | Workflow engine (Temporal, Camunda) |
| You want to visualize and communicate system behavior | Need probabilistic or fuzzy state transitions | Markov chains, ML models |
Important Caveats
- XState v5 introduced significant breaking changes from v4 (actors replace services,
createMachineAPI changes) — do not mix v4 and v5 patterns in the same codebase. - The State design pattern (GoF) and a finite state machine are not the same thing. The State pattern delegates behavior to state objects but does not enforce a transition table — combine both for maximum safety.
- Statecharts (Harel, 1987) extend FSMs with hierarchy, parallelism, and history — they solve state explosion but add conceptual complexity. Only adopt statecharts when flat FSMs become unwieldy (15+ states).
- For distributed systems, a state machine running on a single node is not sufficient — consider Raft-based replicated state machines or event-sourced aggregates for consistency across nodes.
- Performance is rarely a concern: even a naive map lookup FSM handles millions of transitions per second. Optimize for correctness and readability first.