Part 01 · Low Level Design

Enums and State Machines

EnumsState machineTransitionsInvalid statesState diagram

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 wrongWhy
Typos create states that don't existAny string is accepted
An order goes from cancelled back to shippedNo rule says which moves are legal
The same check (if status == "paid" or status == "placed") is copied into many screensThe rules aren't in one place
Nobody can say what all the possible states areThey'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?

payshipdelivercancelcancelplacedpaidshippeddeliveredfinalcancelledfinalorder 1001cancelno cancel arrow from shipped: refuseddelivered is final: nothing leaves it
Press play. Order 1001 follows the arrows from placed to paid to shipped. A cancel arrives while it's shipped, but there's no cancel arrow from shipped, so it's refused. Then it moves on to delivered, a final state.

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.

Predict first

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

  1. 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++ has enum class, Python has Enum.
  2. A state is where an object is in its life right now. It decides what the object may do next.
  3. 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.
  4. 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.
  5. 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.
  6. Final states have no outgoing transitions (delivered, cancelled). Saying so explicitly prevents "zombie" objects that keep changing after they should be finished.
  7. 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

Step through it

order.Apply(cancel) while shipped

ScreenOrder.ApplyTransition tableOrder state

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:

FromEventTo
placedpaypaid
placedcancelcancelled
paidshipshipped
paidcancelcancelled
shippeddeliverdelivered

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?

Try each case

Legal transition or not?

Using the order table above.

placed -> paid
works: In the table. The normal first step.

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())
}

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 match on (state, event) pairs, and if you add a new state the compiler points at every match that must handle it. TypeScript uses string-literal unions instead of enums, so "shiped" is a type error; the type is called OrderEvent because browsers already have a global Event. Java uses EnumMap, which is both fast and ordered. Go has no built-in enum, so typed constants plus a String() 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 CHECK constraint) 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

Try each case

Pick the broken lifecycle

order.status = "shiped"
breaks: A typo created a state that no query looks for. Use an enum so the compiler rejects it.

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 return and the state returned, with one new transition: delivered → return → returned.
  • At the end of the story, send return to order 1001, then cancel to 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 →