state/event/transition table — define every valid (state, event) -> nextState mapping; reject everything else.| 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 |
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
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).
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.
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.
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.
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.
// 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'
# 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
// 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
}
// 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
}
// 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?
// GOOD -- exactly N valid states, no impossible combinations
interface Order {
state: 'idle' | 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
}
# 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
# 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
// 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();
}
// 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();
}
pending_with_coupon) leads to exponential growth. Fix: use extended state (context) for data that varies within a state, or hierarchical states. [src1]type: 'final' or not detecting completion means actors/processes never clean up. Fix: explicitly mark terminal states and handle the done event. [src1]SELECT ... FOR UPDATE or optimistic locking (@Version). [src6]boolean only; put side effects in actions. [src3]error or failed state with retry transitions. [src7]idle -> pending -> confirmed -> shipped -> delivered but never testing invalid transitions. Fix: test that every invalid (state, event) pair throws or returns an error. [src4]| 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 |
createMachine API changes) — do not mix v4 and v5 patterns in the same codebase.