An order moves through a set of states with rules about which transitions are allowed. How would you design that so it does not become a nest of if statements?
Write the legal transitions down as a table first, then give the entity one entry point that consults it and no state setter at all. The design question is where the transition rules live and how side effects stay outside the decision that changed the state.
What the interviewer is scoring
- Whether the legal transition set is written out before any implementation is chosen
- Does the candidate remove the ability to assign state directly rather than validating in each caller
- That effects of a transition are separated from the decision to transition
- Whether two concurrent events against the same entity are addressed
- Does the answer say when a class per state would be over-engineering
Answer
The transition table is the design
Before choosing a pattern, write the machine down. Which states exist, which events are meaningful, and for each pair, whether the transition is allowed and what it produces. That table is the specification, and most of the value of this exercise comes from producing it, because the awkward cells are where the business rules were never agreed.
stateDiagram-v2
[*] --> Draft
Draft --> Placed: place
Placed --> Paid: payment captured
Paid --> Shipped: dispatch
Shipped --> Delivered: delivery confirmed
Placed --> Cancelled: cancel
Paid --> Cancelled: cancel with refundThe edge worth arguing over is cancellation. Cancelling an unpaid order is a status change; cancelling a paid one owes the customer money; cancelling a shipped one is not a cancellation at all but a return, which is a different process with different states. Three transitions that a naive design would have collapsed into one cancel() method with a comment.
One entry point, no setter
The structural mistake is not "too many if statements", it is that state can be assigned from anywhere. Once order.setState(SHIPPED) is public, the rules live in whichever callers remembered them, and every new caller is a chance to skip a check. No pattern rescues a design with a public setter, and removing it is most of the fix.
So the entity exposes one method that takes an event and either transitions or refuses.
public final class Order {
private OrderState state;
private final OrderId id;
// The only path that changes state. There is deliberately no setState.
public List<DomainEvent> handle(OrderCommand command) {
OrderState from = this.state;
OrderState to = TRANSITIONS.next(from, command.type())
.orElseThrow(() -> new IllegalTransition(from, command.type()));
// Guards are domain rules that may refuse a structurally legal
// transition - a refund requested outside the permitted window.
guard(from, to, command);
this.state = to;
// Effects are returned, not performed, so the decision stays pure.
return List.of(new OrderStateChanged(id, from, to, command.occurredAt()));
}
}
Two things follow from that shape. An illegal transition now fails in one place with a message naming both states, which is the difference between a support ticket you can read and one you cannot. And the transition rules are data, so they can be printed, diffed in review, and compared against what the business believes.
Table or a class per state
Both are legitimate and the choice depends on where the complexity actually is.
A transition table — a map from state and event to the next state, plus optional guards — suits a machine whose complexity is the set of allowed moves, which is the common case for order, ticket and approval workflows. It is compact, it is inspectable at runtime, and adding a transition is a data change rather than a code change.
The state pattern, a class per state implementing a common interface, earns its keep when each state has genuinely different behaviour beyond which transitions it permits — different pricing, different visibility, different fields that are editable. Then the polymorphism carries real weight, and each state class is a natural home for its own rules. Its cost is a class per state and a jump per question, so it is straightforwardly worse for a machine where every state does the same thing to a different next value.
Both beat the switch statement for the same reason: the transition rules exist in one place rather than being rediscovered at each call site. And to be clear about scale, a two-state flag with one transition needs neither. if (!published) publish() is fine, and reaching for a state machine there is the over-engineering version of this answer.
Effects belong outside the decision
The most common design flaw is putting the consequences inside the transition. A Paid state object that charges the card, sends the email and writes to the ledger has bound the machine to three pieces of infrastructure, and now the transition cannot be unit tested, cannot be replayed while rebuilding state from history, and cannot be reasoned about when one of the three fails.
Return the events instead and let a caller — an application service holding the transaction — perform the effects. The order decides that it became Paid; the service decides what the world does about it. This also makes the failure modes discussable: if the state is persisted and the email fails, you want the email retried rather than the state rolled back, and if the state is persisted and the refund fails, you want a compensating process with its own visible state rather than silence. Those are different answers, and you can only give them once the two concerns are separate.
The invariant that survives concurrency
A state machine that is correct in memory is still wrong if two events can be applied to the same entity simultaneously. Both handlers load the order in state Placed, both find cancel and dispatch legal from there, and both save. One write lands second and wins, so an order is dispatched and cancelled, or a refund is issued for a parcel already on a van.
The invariant to protect is that the state you decided against is still the state of record when you write. In practice that is a compare-and-set: read the version along with the state, and make the update conditional on that version being unchanged, so the second writer fails and re-runs the decision against the new state. Pessimistic locking of the row for the duration of the handler is the alternative, and it is the simpler choice when transitions are rare and contention is real.
Idempotency belongs in the same conversation, because retries are certain. If the same payment notification arrives twice, the second attempt finds the order already Paid, at which point the answer should be to do nothing and report success rather than to throw IllegalTransition. Deciding which duplicate events are silently absorbed and which are genuine errors is a rule the table should carry, not something each handler decides for itself.
What makes it reviewable a year later
Two habits pay for themselves. Persist the transitions, not only the current state: an append-only row per change, with the event that caused it and who caused it, answers most production questions about a single order without a debugger. And make the table the thing the tests drive — a test that walks every state and asserts which events it accepts will fail the day somebody quietly adds a transition, which is exactly the change most likely to break a downstream assumption.
The rules are the artefact. Put the allowed transitions in one inspectable place, give the entity a single method that consults them, keep the effects outside it, and make the write conditional on the state you decided against.
Likely follow-ups
- Two events for the same order arrive at the same moment. What stops the second applying to a state that has already moved?
- Where do the side effects live, and what happens when one fails after the new state was saved?
- How would you add a new state without editing every existing state class?
- The business says any state may go to Cancelled except Shipped. Where exactly does that rule live?
Related questions
- A bundle can contain products or other bundles, and the pricing code should not care which it is holding. How would you model that?mediumAlso on invariants5 min
- Numbers arrive one at a time and after each one I want the running median. How would you keep it?mediumAlso on invariants4 min
- Build a shared-expense tracker. Three people split a bill of ten pounds evenly - write the classes, and tell me where the missing penny goes.mediumAlso on invariants6 min
- How do you decide whether a piece of behaviour belongs on the domain object or in a service?mediumAlso on domain-model6 min
- You are given a sorted array that has been rotated by an unknown amount. Find a target value in logarithmic time.mediumAlso on invariants4 min
- Given the heights of a row of bars, work out how much rainwater is trapped between them. Do it in constant extra space.mediumAlso on invariants4 min
- An operation in this code takes two locks and it deadlocks every few weeks. How would you design the locking so that cannot happen?hardAlso on invariants6 min
- Given package weights in a fixed order, find the smallest ship capacity that delivers them all within D daysmediumAlso on invariants5 min