Module 13 · Lesson 04

Design an order state machine

An order is not a row with a status column. It is a set of legal next moves.

Advanced 12 min read Updated 2026-11-03

What you will be able to do

  • List the states an order actually needs, and the ones it does not
  • Express transitions as data rather than nested conditionals
  • Separate guards, effects and the state change itself
  • Make a transition idempotent when events can arrive twice

Start with the states you can defend

Commerce teams over-model here. The honest minimum for a physical-goods order is: created, paid, fulfilled, delivered, cancelled, refunded. Everything else — picking, packed, in transit — belongs to the fulfilment domain, not the order's lifecycle. If you find yourself adding a state because a courier webhook used that word, you are modelling someone else's system.

StateMeansTerminal
Draftcart converted, payment not capturedno
Paidpayment captured, stock committedno
Fulfilledhanded to the warehouse or the carrierno
Deliveredcarrier confirmed deliveryno, refund window still open
Cancelledstopped before fulfilment, stock releasedyes
Refundedmoney returned, order closedyes
The test for a real state
For every state, ask: what may happen next, and what may this state never do again? If the answer to the second half is nothing, it is not a state — it is a property. Partially shipped is a property of the lines, not a state of the order.

Transitions are a table, not a switch

Written as a data structure, the machine is reviewable by a product manager and testable without a single mock. It also makes the illegal moves visible: there is no row for Delivered to Paid, so nobody can write one by accident.

enum OrderState { DRAFT, PAID, FULFILLED, DELIVERED, CANCELLED, REFUNDED }
enum Event { PAYMENT_CAPTURED, DISPATCHED, DELIVERY_CONFIRMED, CANCEL_REQUESTED, REFUND_ISSUED }

record Transition(OrderState from, Event event, OrderState to, Guard guard) {}

static final List<Transition> TABLE = List.of(
    new Transition(DRAFT,      PAYMENT_CAPTURED,   PAID,      Guard.ALWAYS),
    new Transition(DRAFT,      CANCEL_REQUESTED,  CANCELLED, Guard.ALWAYS),
    new Transition(PAID,       DISPATCHED,        FULFILLED, Guard.ALWAYS),
    new Transition(PAID,       CANCEL_REQUESTED,  CANCELLED, Guard.BEFORE_DISPATCH),
    new Transition(FULFILLED,  DELIVERY_CONFIRMED, DELIVERED, Guard.ALWAYS),
    new Transition(FULFILLED,  CANCEL_REQUESTED,  REFUNDED,  Guard.AFTER_DISPATCH),
    new Transition(DELIVERED,  REFUND_ISSUED,     REFUNDED,  Guard.WITHIN_RETURN_WINDOW)
);

Note that cancellation has two outcomes depending on how far the order got: before dispatch it is a cancellation with stock released, after dispatch it is a refund. One event, two rows, decided by the current state. That is the machine doing its job.

Guards, effects and the change itself

Guard reads
A guard may look at time, at the payment provider's status, at whether the carrier has a pickup scan. It must not mutate anything, and it must not call a remote system that can fail halfway.
Transition writes exactly one field
The state change is a single conditional update: set state = next where id = ? and state = expected. Zero rows affected means someone else moved the order first, which is a conflict, not a success.
Effects are queued, not performed inline
Release stock, send the email, issue the refund, publish to the data warehouse: each becomes an outbox row written in the same transaction as the state change. A worker drains the outbox with retries.
Idempotency by event id
Record the event id in the same transaction. A redelivered event finds the row and returns the stored outcome. No transition runs twice.
final class OrderMachine {
    private final OrderRepository orders;
    private final Outbox outbox;
    private final Clock clock;

    OrderState apply(OrderId id, Event event, EventId eventId, Instant now) {
        return orders.inTransaction(tx -> {
            if (tx.alreadyHandled(id, eventId)) return tx.state(id);      // idempotent replay

            var current = tx.state(id);
            var transition = TABLE.stream()
                .filter(t -> t.from() == current && t.event() == event)
                .findFirst()
                .orElseThrow(() -> new IllegalTransition(current, event));

            if (!transition.guard().allows(tx, id, now)) {
                throw new GuardRejected(current, event);
            }

            tx.move(id, current, transition.to());                        // single conditional update
            tx.markHandled(id, eventId);
            tx.enqueue(outbox.effectsFor(transition, id));                // same transaction
            return transition.to();
        });
    }
}
The bug this design exists to prevent
Without the conditional update and the event id, two delivery webhooks arriving together both read PAID, both write DELIVERED, and both enqueue a refund email when the order is later cancelled. The state is fine; the emails are not. Duplicate side effects, not duplicate states, are what customers notice.

What a state pattern buys, and what it costs

  • The pattern fits when each state has genuinely different behaviour with several methods, and the set of states is stable. Then a sealed interface with one record per state gives exhaustive switch checking.
  • The table fits when the behaviour is uniform — validate, move, emit — and only the legal moves differ, which is almost every order lifecycle. Less code, reviewable by non-engineers.
  • Neither fits when the machine is really a workflow engine spanning several aggregates. Then the transitions belong to a saga, and an order is one participant.

Tests that keep the rules honest

  • Every row has a test. Seven transitions, seven small tests. When the table grows, the suite grows with it.
  • Illegal moves are rejected. Cancelling a delivered order raises, it does not silently no-op.
  • Replay is safe. The same event id twice produces one state change and one outbox row.
  • Concurrency is a conflict. Two threads applying DISPATCHED to PAID: one succeeds, one sees a conflict, neither loses an effect.
  • Guard boundaries. A refund at exactly the return-window edge falls on the documented side.
Instead
isPaid, isCancelled, isRefunded as independent flags.
Do this
One state field plus a transition table, so impossible combinations cannot be stored.
Instead
Sending the email, then committing the state change.
Do this
Write an outbox row in the same transaction and let a worker deliver it with retries.
Instead
A guard that calls the carrier API to check dispatch status.
Do this
A guard that reads a local fact; the carrier webhook updates it as an event.
Instead
Treating each webhook as authoritative truth.
Do this
The machine decides the transition; the webhook is only an input to it.
Instead
One lifecycle for support and another for finance.
Do this
One lifecycle, with views projected for each team.

The answer to give when time is short

If there are ten minutes left, do not write the pattern. Write the table on the board, code one guarded transition with a conditional update, and say where the event id and outbox go. That is the complete idea, and it is more convincing than six empty classes with correct names.

Worth remembering

  • Statuses are not states. If two statuses can be true at once, they are flags pretending to be a lifecycle.
  • The legal transitions are a table. Write the table before the class, and the class becomes boring in the best way.
  • Guards decide whether a transition is allowed now; effects are what happens after. Mixing them is how cancellation emails get sent for orders that never shipped.
  • Every transition needs an event id. At-least-once delivery means the same event will arrive again, and the machine must not move twice.

Checkpoint

Answer all 3 questions to finish the lesson.
1. Two carrier webhooks for the same delivery arrive at once. What makes the transition safe?
The conditional update makes the second write lose, and the event id stops its side effects being enqueued again. A read-then-write check is exactly the race.
2. Which pair is genuinely two states rather than one state plus a flag?
Delivered and Refunded are mutually exclusive points in the lifecycle. Partially shipped describes a property of the lines, and created plus a coupon is orthogonal data.
3. Where should the cancellation email be triggered?
The outbox keeps the effect exactly-once-ish with the state change and survives a crash between the two. Sending inline can email before a rollback; a trigger cannot retry an email.