Part 01 · Low Level Design
Enums and State Machines
Orders, tickets and elevators all move through states. Model them with enums and explicit transitions so an impossible state can't even be written down.
⏱ 1-2 hours · Station 4 of 80
Refunded, then delivered
An online shop stores each order's status as a string: "placed", "paid", "shipped". Different screens set it directly: the payment callback sets "paid", the courier app sets "shipped", the support tool sets "cancelled".
One day support cancels an order and refunds the customer. Ten minutes later the courier's app, which still had the order open, marks it "delivered". Now the order is delivered and refunded. Another time a typo sets the status to "shiped", and the order vanishes from every list that filters by "shipped".
Nothing stopped either mistake, because the code never wrote down which statuses exist and which moves between them are allowed. Writing that down is what a state machine is.
The problem: status changes from everywhere
Almost every business object has a lifecycle: orders, tickets, rides, loan applications, job postings. When status is a free string changed from many places:
| What goes wrong | Why |
|---|---|
| Typos create states that don't exist | Any string is accepted |
| An order goes from cancelled back to shipped | No rule says which moves are legal |
The same check (if status == "paid" or status == "placed") is copied into many screens | The rules aren't in one place |
| Nobody can say what all the possible states are | They're scattered across the code |
Think of a ticket in a cricket stadium
A ticket starts unused. At the gate it's scanned and becomes used. If the match is called off, it becomes refunded. A used ticket can't be refunded, and a refunded ticket can't get you in. The guard at the gate doesn't need to know the whole history; they only check: is this move allowed from where the ticket is now?
Where the comparison breaks down. A stadium guard can make exceptions. Code can't, and shouldn't: if the business needs an exception ("allow cancel after shipping, as a return"), that becomes a new, named transition in the table, not a special case hidden in one screen.
The transition table says: placed → (pay → paid, cancel → cancelled), paid → (ship → shipped, cancel → cancelled), shipped → (deliver → delivered). An order is shipped. What happens on cancel?
Enums, states, transitions
- An enum is a closed set of named values:
Placed, Paid, Shipped, Delivered, Cancelled. The compiler (or the type checker) rejects anything else, so"shiped"can't sneak in. Go uses typed constants, Rust and Java have real enums, TypeScript uses a union of string literals, C++ hasenum class, Python hasEnum. - A state is where an object is in its life right now. It decides what the object may do next.
- A transition is a move from one state to another, caused by an event (pay, ship, cancel). The full list of legal transitions is the state machine.
- Write the transitions in one place, ideally as data: a table or a
match. Then the whole lifecycle can be read, reviewed and tested in one screen. - Make the state private and change it only through one method (
Apply(event)) that looks up the table. That's encapsulation from the last chapter, aimed at the lifecycle. - Final states have no outgoing transitions (delivered, cancelled). Saying so explicitly prevents "zombie" objects that keep changing after they should be finished.
- A state diagram draws states as boxes and transitions as labelled arrows. It's the clearest picture you can show a product manager, and it should match the table line for line.
One cancel, checked against the table
order.Apply(cancel) while shipped
Step 1/5 The support screen asks to cancel
The screen doesn't set a status. It sends an event: cancel.
- event
- cancel
The lifecycle on one page
Picture the lifecycle as a table you could print and stick on the wall:
| From | Event | To |
|---|---|---|
| placed | pay | paid |
| placed | cancel | cancelled |
| paid | ship | shipped |
| paid | cancel | cancelled |
| shipped | deliver | delivered |
Every legal move is a row. Anything not in the table is illegal. When the business changes the rules, you add or remove a row, and the diagram, the code and the tests change together.
Try it: should this be allowed?
Legal transition or not?
Using the order table above.
placed -> paid
The order lifecycle in code
States and events as enums, the transitions as one table (or one match), and Apply as the only way to change the state. Two orders are pushed through valid and invalid events.
package main
import (
"fmt"
"strings"
)
// State and Event are enums: a closed set of names instead of free-form strings.
type State int
const (
Placed State = iota
Paid
Shipped
Delivered
Cancelled
)
func (s State) String() string {
return [...]string{"placed", "paid", "shipped", "delivered", "cancelled"}[s]
}
type Event string
const (
Pay Event = "pay"
Ship Event = "ship"
Deliver Event = "deliver"
Cancel Event = "cancel"
)
// The whole lifecycle in one table: from a state, which events are allowed, and where they lead.
// Anything not listed is an invalid transition.
var transitions = map[State]map[Event]State{
Placed: {Pay: Paid, Cancel: Cancelled},
Paid: {Ship: Shipped, Cancel: Cancelled},
Shipped: {Deliver: Delivered},
// Delivered and Cancelled are final: no way out.
}
type Order struct {
ID int
state State
history []State
}
func NewOrder(id int) *Order {
return &Order{ID: id, state: Placed, history: []State{Placed}}
}
// Apply is the only way to change the state, so an impossible state can't be reached.
func (o *Order) Apply(e Event) error {
next, ok := transitions[o.state][e]
if !ok {
return fmt.Errorf("cannot %s when %s", e, o.state)
}
o.state = next
o.history = append(o.history, next)
return nil
}
func (o *Order) History() string {
parts := []string{}
for _, s := range o.history {
parts = append(parts, s.String())
}
return strings.Join(parts, " > ")
}
func run(o *Order, events ...Event) {
for _, e := range events {
from := o.state
if err := o.Apply(e); err != nil {
fmt.Printf("%-8s %-9s : %v\n", e, from, err)
} else {
fmt.Printf("%-8s %-9s -> %s\n", e, from, o.state)
}
}
}
func main() {
a := NewOrder(1001)
fmt.Printf("order %d %s\n", a.ID, a.state)
run(a, Pay, Ship, Cancel, Deliver, Deliver)
b := NewOrder(1002)
fmt.Printf("order %d %s\n", b.ID, b.state)
run(b, Cancel, Pay)
fmt.Printf("history %d: %s\n", a.ID, a.History())
fmt.Printf("history %d: %s\n", b.ID, b.History())
}from enum import Enum
class State(Enum):
"""A closed set of names instead of free-form strings."""
PLACED = "placed"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class Event(Enum):
PAY = "pay"
SHIP = "ship"
DELIVER = "deliver"
CANCEL = "cancel"
# The whole lifecycle in one table. Anything not listed is an invalid transition.
TRANSITIONS: dict[State, dict[Event, State]] = {
State.PLACED: {Event.PAY: State.PAID, Event.CANCEL: State.CANCELLED},
State.PAID: {Event.SHIP: State.SHIPPED, Event.CANCEL: State.CANCELLED},
State.SHIPPED: {Event.DELIVER: State.DELIVERED},
# DELIVERED and CANCELLED are final: no way out.
}
class InvalidTransition(Exception):
pass
class Order:
def __init__(self, id: int):
self.id = id
self._state = State.PLACED
self._history = [State.PLACED]
@property
def state(self) -> State:
return self._state
def apply(self, e: Event) -> None:
"""The only way to change the state, so an impossible state can't be reached."""
nxt = TRANSITIONS.get(self._state, {}).get(e)
if nxt is None:
raise InvalidTransition(f"cannot {e.value} when {self._state.value}")
self._state = nxt
self._history.append(nxt)
def history(self) -> str:
return " > ".join(s.value for s in self._history)
def run(o: Order, *events: Event) -> None:
for e in events:
frm = o.state
try:
o.apply(e)
print(f"{e.value:<8} {frm.value:<9} -> {o.state.value}")
except InvalidTransition as err:
print(f"{e.value:<8} {frm.value:<9} : {err}")
a = Order(1001)
print(f"order {a.id} {a.state.value}")
run(a, Event.PAY, Event.SHIP, Event.CANCEL, Event.DELIVER, Event.DELIVER)
b = Order(1002)
print(f"order {b.id} {b.state.value}")
run(b, Event.CANCEL, Event.PAY)
print(f"history {a.id}: {a.history()}")
print(f"history {b.id}: {b.history()}")// String-literal unions are TypeScript's enums: a closed set of names, checked by the compiler.
// (OrderEvent, because the browser already has a global named Event.)
type State = "placed" | "paid" | "shipped" | "delivered" | "cancelled";
type OrderEvent = "pay" | "ship" | "deliver" | "cancel";
// The whole lifecycle in one table. Anything not listed is an invalid transition.
const TRANSITIONS: Partial<Record<State, Partial<Record<OrderEvent, State>>>> = {
placed: { pay: "paid", cancel: "cancelled" },
paid: { ship: "shipped", cancel: "cancelled" },
shipped: { deliver: "delivered" },
// delivered and cancelled are final: no way out.
};
class Order {
readonly id: number;
#state: State = "placed";
#history: State[] = ["placed"];
constructor(id: number) {
this.id = id;
}
get state(): State {
return this.#state;
}
// The only way to change the state, so an impossible state can't be reached.
apply(e: OrderEvent): void {
const next = TRANSITIONS[this.#state]?.[e];
if (!next) throw new Error(`cannot ${e} when ${this.#state}`);
this.#state = next;
this.#history.push(next);
}
history(): string {
return this.#history.join(" > ");
}
}
function run(o: Order, ...events: OrderEvent[]): void {
for (const e of events) {
const from = o.state;
try {
o.apply(e);
console.log(`${e.padEnd(8)} ${from.padEnd(9)} -> ${o.state}`);
} catch (err) {
console.log(`${e.padEnd(8)} ${from.padEnd(9)} : ${(err as Error).message}`);
}
}
}
const a = new Order(1001);
console.log(`order ${a.id} ${a.state}`);
run(a, "pay", "ship", "cancel", "deliver", "deliver");
const b = new Order(1002);
console.log(`order ${b.id} ${b.state}`);
run(b, "cancel", "pay");
console.log(`history ${a.id}: ${a.history()}`);
console.log(`history ${b.id}: ${b.history()}`);use std::fmt;
// Enums: a closed set of states. `match` below must cover every case, or it won't compile.
#[derive(Clone, Copy, PartialEq, Debug)]
enum State { Placed, Paid, Shipped, Delivered, Cancelled }
#[derive(Clone, Copy, Debug)]
enum Event { Pay, Ship, Deliver, Cancel }
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.pad(match self {
State::Placed => "placed", State::Paid => "paid", State::Shipped => "shipped",
State::Delivered => "delivered", State::Cancelled => "cancelled",
})
}
}
impl fmt::Display for Event {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.pad(match self { Event::Pay => "pay", Event::Ship => "ship", Event::Deliver => "deliver", Event::Cancel => "cancel" })
}
}
// The whole lifecycle in one match. Anything not listed is an invalid transition.
fn transition(s: State, e: Event) -> Option<State> {
use {Event::*, State::*};
match (s, e) {
(Placed, Pay) => Some(Paid),
(Placed, Cancel) | (Paid, Cancel) => Some(Cancelled),
(Paid, Ship) => Some(Shipped),
(Shipped, Deliver) => Some(Delivered),
_ => None, // Delivered and Cancelled are final: no way out.
}
}
struct Order { id: u32, state: State, history: Vec<State> }
impl Order {
fn new(id: u32) -> Order { Order { id, state: State::Placed, history: vec![State::Placed] } }
// The only way to change the state, so an impossible state can't be reached.
fn apply(&mut self, e: Event) -> Result<(), String> {
let next = transition(self.state, e).ok_or(format!("cannot {e} when {}", self.state))?;
self.state = next;
self.history.push(next);
Ok(())
}
fn history(&self) -> String {
self.history.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" > ")
}
}
fn run(o: &mut Order, events: &[Event]) {
for &e in events {
let from = o.state;
match o.apply(e) {
Ok(()) => println!("{e:<8} {from:<9} -> {}", o.state),
Err(err) => println!("{e:<8} {from:<9} : {err}"),
}
}
}
fn main() {
use Event::*;
let mut a = Order::new(1001);
println!("order {} {}", a.id, a.state);
run(&mut a, &[Pay, Ship, Cancel, Deliver, Deliver]);
let mut b = Order::new(1002);
println!("order {} {}", b.id, b.state);
run(&mut b, &[Cancel, Pay]);
println!("history {}: {}", a.id, a.history());
println!("history {}: {}", b.id, b.history());
}import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
// Enums: a closed set of names instead of free-form strings.
enum State {
PLACED, PAID, SHIPPED, DELIVERED, CANCELLED;
@Override public String toString() { return name().toLowerCase(); }
}
enum Event {
PAY, SHIP, DELIVER, CANCEL;
@Override public String toString() { return name().toLowerCase(); }
}
// The whole lifecycle in one table. Anything not listed is an invalid transition.
static final Map<State, Map<Event, State>> TRANSITIONS = new EnumMap<>(Map.of(
State.PLACED, Map.of(Event.PAY, State.PAID, Event.CANCEL, State.CANCELLED),
State.PAID, Map.of(Event.SHIP, State.SHIPPED, Event.CANCEL, State.CANCELLED),
State.SHIPPED, Map.of(Event.DELIVER, State.DELIVERED)
// DELIVERED and CANCELLED are final: no way out.
));
static final class InvalidTransition extends Exception {
private static final long serialVersionUID = 1L;
InvalidTransition(String msg) { super(msg); }
}
static final class Order {
final int id;
private State state = State.PLACED;
private final List<State> history = new ArrayList<>(List.of(State.PLACED));
Order(int id) { this.id = id; }
State state() { return state; }
// The only way to change the state, so an impossible state can't be reached.
void apply(Event e) throws InvalidTransition {
State next = TRANSITIONS.getOrDefault(state, Map.of()).get(e);
if (next == null) throw new InvalidTransition("cannot " + e + " when " + state);
state = next;
history.add(next);
}
String history() { return history.stream().map(State::toString).collect(Collectors.joining(" > ")); }
}
static void run(Order o, Event... events) {
for (Event e : events) {
State from = o.state();
try {
o.apply(e);
System.out.printf("%-8s %-9s -> %s%n", e, from, o.state());
} catch (InvalidTransition err) {
System.out.printf("%-8s %-9s : %s%n", e, from, err.getMessage());
}
}
}
public static void main(String[] args) {
Order a = new Order(1001);
System.out.printf("order %d %s%n", a.id, a.state());
run(a, Event.PAY, Event.SHIP, Event.CANCEL, Event.DELIVER, Event.DELIVER);
Order b = new Order(1002);
System.out.printf("order %d %s%n", b.id, b.state());
run(b, Event.CANCEL, Event.PAY);
System.out.printf("history %d: %s%n", a.id, a.history());
System.out.printf("history %d: %s%n", b.id, b.history());
}
}#include <cstdio>
#include <map>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
// enum class: a closed set of names, and no silent conversion to int.
enum class State { Placed, Paid, Shipped, Delivered, Cancelled };
enum class Event { Pay, Ship, Deliver, Cancel };
const char* name(State s) {
switch (s) {
case State::Placed: return "placed";
case State::Paid: return "paid";
case State::Shipped: return "shipped";
case State::Delivered: return "delivered";
case State::Cancelled: return "cancelled";
}
return "?";
}
const char* name(Event e) {
switch (e) {
case Event::Pay: return "pay";
case Event::Ship: return "ship";
case Event::Deliver: return "deliver";
case Event::Cancel: return "cancel";
}
return "?";
}
// The whole lifecycle in one table. Anything not listed is an invalid transition.
const std::map<std::pair<State, Event>, State> kTransitions = {
{{State::Placed, Event::Pay}, State::Paid},
{{State::Placed, Event::Cancel}, State::Cancelled},
{{State::Paid, Event::Ship}, State::Shipped},
{{State::Paid, Event::Cancel}, State::Cancelled},
{{State::Shipped, Event::Deliver}, State::Delivered},
// Delivered and Cancelled are final: no way out.
};
class Order {
public:
explicit Order(int id) : id(id) {}
const int id;
State state() const { return state_; }
// The only way to change the state, so an impossible state can't be reached.
void apply(Event e) {
auto it = kTransitions.find({state_, e});
if (it == kTransitions.end())
throw std::runtime_error(std::string("cannot ") + name(e) + " when " + name(state_));
state_ = it->second;
history_.push_back(state_);
}
std::string history() const {
std::string out;
for (State s : history_) out += (out.empty() ? "" : " > ") + std::string(name(s));
return out;
}
private:
State state_ = State::Placed;
std::vector<State> history_{State::Placed};
};
void run(Order& o, std::vector<Event> events) {
for (Event e : events) {
State from = o.state();
try {
o.apply(e);
std::printf("%-8s %-9s -> %s\n", name(e), name(from), name(o.state()));
} catch (const std::exception& err) {
std::printf("%-8s %-9s : %s\n", name(e), name(from), err.what());
}
}
}
int main() {
Order a(1001);
std::printf("order %d %s\n", a.id, name(a.state()));
run(a, {Event::Pay, Event::Ship, Event::Cancel, Event::Deliver, Event::Deliver});
Order b(1002);
std::printf("order %d %s\n", b.id, name(b.state()));
run(b, {Event::Cancel, Event::Pay});
std::printf("history %d: %s\n", a.id, a.history().c_str());
std::printf("history %d: %s\n", b.id, b.history().c_str());
}All six print exactly this:
order 1001 placed
pay placed -> paid
ship paid -> shipped
cancel shipped : cannot cancel when shipped
deliver shipped -> delivered
deliver delivered : cannot deliver when delivered
order 1002 placed
cancel placed -> cancelled
pay cancelled : cannot pay when cancelled
history 1001: placed > paid > shipped > delivered
history 1002: placed > cancelled
Reading the output:
- Order 1001 goes placed → paid → shipped. The cancel while shipped is refused, and the state stays shipped.
- The second deliver is refused because delivered is final. A courier app that retries a callback can't break anything.
- Order 1002 is cancelled straight away; a late pay is refused.
- History only records transitions that happened, so it's a reliable audit trail.
- What each language changed: Rust writes the table as a
matchon(state, event)pairs, and if you add a new state the compiler points at everymatchthat must handle it. TypeScript uses string-literal unions instead of enums, so"shiped"is a type error; the type is calledOrderEventbecause browsers already have a globalEvent. Java usesEnumMap, which is both fast and ordered. Go has no built-in enum, so typed constants plus aString()method do the job; the compiler won't catch a missing case, which is one more reason to keep the table in one place.
State machines in real systems
- Store the state as an enum column (or a string with a
CHECKconstraint) and store the history as rows (order_id, from, to, event, at). The history is your audit log and your debugging tool. - Make transitions atomic with the data they protect. "Mark paid and record the payment" should be one database transaction, often with
UPDATE ... WHERE state = 'placed'so two callbacks can't both win. - Side effects come after the transition succeeds. Send the "your order shipped" SMS only once Apply returned no error, never before.
- Unknown events from outside are normal. Payment gateways and couriers retry callbacks. A state machine that refuses repeats safely makes your system idempotent almost for free.
- When states get behaviour of their own (each state handles many events differently), the State pattern turns each state into a class. That's coming in the behavioural patterns chapter; a table is enough until then.
Designs that cause problems later
Pick the broken lifecycle
order.status = "shiped"
Pause and think
The business wants customers to return a delivered order within 7 days. How would you change the state machine?
Reveal
Add a new state and a new transition, don't bend an old one: delivered --return--> return_requested --refund--> returned. The 7-day rule is a guard on the return transition (it needs the delivery date and today's date as inputs). Delivered is then no longer final, which is a real change to the lifecycle, so update the table, the diagram and the tests together. Don't reuse cancel for this: a return after delivery and a cancel before shipping are different business events with different money flows.
Quick quiz
1. Why is an enum better than a string for a status?
Answer
The set of values is closed and checked by the compiler or type checker, so typos and made-up states can't get in, and every place that handles states can be found.
2. Where should the list of legal transitions live?
Answer
In one place, usually as a table or a match, next to the single method that changes the state. Screens send events; they don't decide the rules.
3. What's a final state, and why mark it explicitly?
Answer
A state with no outgoing transitions, like delivered or cancelled. Marking it stops late or repeated events from changing a finished object.
4. Why replace isPaid, isShipped, isCancelled booleans with one enum?
Answer
Three booleans allow eight combinations, most of them impossible. One enum field allows exactly the states the business has.
Your challenge: returns
Add the return flow: a delivered order can be returned.
- Add the event
returnand the statereturned, with one new transition: delivered → return → returned. - At the end of the story, send
returnto order 1001, thencancelto it.
Before you run it, predict the two new lines and the new history.
You've succeeded when the new lines are return delivered -> returned and cancel returned : cannot cancel when returned, the history line reads history 1001: placed > paid > shipped > delivered > returned, and no other line changes.
In one line
List the states as an enum, write every legal move into one table, keep the state private and change it only through one method that checks the table. Everything not in the table is refused, so impossible states can't happen, and the table, the diagram and the tests always tell the same story.
Station stamp
Finish the chapter
Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.
Go Pro to earn XP →