Design an order state machine
An order is not a row with a status column. It is a set of legal next moves.
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.
| State | Means | Terminal |
|---|---|---|
| Draft | cart converted, payment not captured | no |
| Paid | payment captured, stock committed | no |
| Fulfilled | handed to the warehouse or the carrier | no |
| Delivered | carrier confirmed delivery | no, refund window still open |
| Cancelled | stopped before fulfilment, stock released | yes |
| Refunded | money returned, order closed | yes |
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
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();
});
}
}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.
isPaid, isCancelled, isRefunded as independent flags.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.