software-design/state-machines-why-an-order-can-t-be-delivered-before-it-s-paid.md

State machines: why an order can't be delivered before it's paid

Building software · 4 min read ·

An online order can be in the cart, paid, shipped, delivered or cancelled, but what stops the code from delivering an order nobody paid for? The answer is a state machine: one value for where the order is now, plus a short list of allowed moves.

Play with a tiny shop, try the moves that should be refused, and see why four true/false flags allow 16 combinations while five states allow exactly what you meant. Includes a JavaScript example that was run for real.

A row of boxes labelled cart, paid, shipped and delivered, joined by arrows labelled pay, ship and deliver

An online shop has an order. It starts in the cart, gets paid, gets shipped, and arrives. Now imagine the code lets someone click "Deliver" on an order that was never paid. Nothing crashes. The shop just quietly lies about what happened.

This kind of bug is common, and there is a plain way to prevent it: write down every state an order can be in, and every move that is allowed between them. The result is called a state machine.

The problem with a pile of true/false flags

A natural first attempt is one boolean (a value that is either true or false) per fact: isPaid, isShipped, isDelivered, isCancelled. Four flags feel simple. But each flag has two values, so four of them can be combined in 2 × 2 × 2 × 2 = 16 ways. Only a few of those 16 describe a real order. "Delivered but not paid" and "shipped and cancelled" are both possible to write down, and nothing in the code says they are wrong.

Every new flag doubles the count. The program ends up full of if checks that try to rule out the nonsense combinations, and sooner or later one is missed.

The idea: one state at a time

A state machine replaces the flags with one value that says where the order is right now. This value is the state. The order here has five states: cart, paid, shipped, delivered and cancelled. An order is in exactly one of them, so "delivered and cancelled at once" cannot even be expressed.

Then you list the allowed moves. Each move is triggered by an event (something that happens, such as "pay") and leads from one state to another. A move is called a transition. Anything not on the list is not allowed.

Try it: play the shop

Press the event buttons below. Some moves work. Others are refused, and that refusal is the whole point.

The same thing as a table

Here are the allowed transitions written as a table. Read a row as "from this state, this event leads here".

FromEventTo
cartpaypaid
cartcancelcancelled
paidshipshipped
paidcancelcancelled
shippeddeliverdelivered

Five rows are the whole rulebook. delivered and cancelled have no rows at all, so they are final states: once an order gets there, nothing else can happen to it. Whether a shipped order can be cancelled is a business decision. Here the answer is no, and the table makes that decision visible instead of hiding it inside if statements.

The code is short

In JavaScript the table fits in an object (a collection of named values). Each state is a key, and its value lists the events that state accepts.

const transitions = {
  cart:      { pay: 'paid', cancel: 'cancelled' },
  paid:      { ship: 'shipped', cancel: 'cancelled' },
  shipped:   { deliver: 'delivered' },
  delivered: {},
  cancelled: {}
};

function send(state, event) {
  const next = transitions[state][event];
  if (next === undefined) {
    throw new Error("Cannot " + event + " an order that is " + state);
  }
  return next;
}

let order = "cart";
order = send(order, "pay");
console.log(order);          // paid
order = send(order, "ship");
console.log(order);          // shipped
send(order, "cancel");       // throws an error

Running this in Node.js prints paid, then shipped, and the last line stops the program with the message Cannot cancel an order that is shipped. (When a function raises an error with throw, the program stops there unless some other code catches it.) The function never needs to know what a "good" order looks like. It only looks the move up in the table.

Where you will meet this

State machines are everywhere once you look. A traffic light cycles through red, green and amber. A video player is stopped, playing or paused. A login form is empty, submitting, failed or signed in. A network connection is closed, connecting or open. In each case the real bugs hide in the gaps: pressing "play" twice, or submitting while already submitting.

What to take from this

When you catch yourself adding a third or fourth true/false flag to describe one thing, stop and ask: is this really one thing that is in one of several states? If so, list the states, list the allowed moves, and let anything else be refused loudly. On paper this takes five minutes. It can save hours of hunting for an order that is somehow both cancelled and delivered.

$ ls software-design/

see all →
software-design/the-observer-pattern-how-a-shop-tells-many-functions-about-one-order-w.md The observer pattern: how a shop tells many functions about one order without knowing them A shop that must email a receipt, update stock and add loyalty points ends up with one function that knows about everything. Every new… 5 min read · 27 Oct software-design/refactoring-how-to-tidy-working-code-without-breaking-it.md Refactoring: how to tidy working code without breaking it Refactoring means changing how code is built on the inside without changing what it does. It sounds risky, but there is a simple routine… 6 min read · 24 Oct software-design/unit-tests-why-four-passing-tests-don-t-prove-a-function-works.md Unit tests: why four passing tests don't prove a function works Your function gave the right answer four times. Is it correct? Not necessarily: passing tests only show the bug isn't where you looked… 4 min read · 21 Oct software-design/idempotency-why-clicking-pay-twice-should-charge-you-once.md Idempotency: why clicking Pay twice should charge you once You tap Pay, the screen shows an error, you tap again, and your card is charged twice. Networks drop messages, so a phone often cannot tell… 6 min read · 18 Oct software-design/dependency-injection-why-a-test-passes-at-9am-and-fails-at-3pm.md Dependency injection: why a test passes at 9am and fails at 3pm A test that passes in the morning and fails in the afternoon, with no change to the code, is a puzzle many beginners meet. The cause is… 5 min read · 12 Oct software-design/pure-functions-why-the-same-call-can-give-two-different-answers.md Pure functions: why the same call can give two different answers Call a function twice with the same input and get two different answers? Python isn't broken. The function is doing something extra, called… 5 min read · 9 Oct