Module 03 · Design notation · Lesson 3 of 4
State diagrams
Modelling objects with a lifecycle: states, events, transitions, guards and actions. Why bookings, orders and machines need explicit states, turning a diagram into code, and rejecting illegal transitions.
Why it matters
Many objects in a design are not just data; they move through a lifecycle. An order is placed, paid, shipped, delivered or cancelled. A seat is free, held, sold. An elevator is idle, moving up, moving down, doors open. When those states are implicit, spread across booleans like isPaid and isShipped, impossible combinations creep in (shipped but not paid) and every method needs defensive checks. Making the state machine explicit, on paper and then in code, is one of the highest-value moves in a low-level design answer and the most common gap in otherwise good ones.
The idea
A state machine describes an object as being in exactly one state at a time. Events (method calls, messages, timeouts) trigger transitions to other states. A transition can have a guard (a condition that must hold) and an action (something done as it happens). Any event not allowed in the current state is rejected.
Reading and drawing one
| Element | Drawn as | Example |
|---|---|---|
| State | Rounded box | Held |
| Initial state | Filled dot with an arrow to the first state | → Free |
| Final state | Circled dot | Archived |
| Transition | Arrow labelled event [guard] / action | confirm [hold not expired] / charge card |
| Self-transition | Arrow from a state back to itself | extendHold [extensions under 2] |
| Timed event | after(duration) | after(10 min) from Held to Free |
From diagram to code
For small machines, an enum of states plus a transition check in each method is clear and enough:
enum BookingStatus { HELD, CONFIRMED, CANCELLED, EXPIRED }
final class Booking {
private BookingStatus status = BookingStatus.HELD;
private final Instant holdExpiresAt;
void confirm(Instant now, PaymentResult payment) {
require(status == BookingStatus.HELD, "only held bookings can be confirmed");
if (now.isAfter(holdExpiresAt)) { status = BookingStatus.EXPIRED; throw new HoldExpired(); }
require(payment.succeeded(), "payment must succeed");
status = BookingStatus.CONFIRMED;
}
void cancel(Instant now, Instant showStart) {
require(status == BookingStatus.CONFIRMED || status == BookingStatus.HELD, "nothing to cancel");
require(now.isBefore(showStart), "cannot cancel after the show starts");
status = BookingStatus.CANCELLED;
}
}
For machines where each state behaves very differently (a vending machine that handles coins, selection and dispensing differently in each state), the State pattern gives each state its own class implementing a common interface, and the object delegates to its current state. It removes long switch statements at the cost of more classes; the Patterns stage covers it in detail. A transition table (current state, event to next state) is a third option, good when the machine is data-driven or configurable.
Rules that keep state machines honest
- One status field, not many booleans.
status = SHIPPEDcannot contradict itself;isPaid=false, isShipped=truecan. - Reject illegal transitions loudly. Throw a specific exception; never silently ignore an event that is not valid in the current state.
- Guard with the information the object owns. Expiry, counts and timestamps live on the object, and an injected clock makes timed transitions testable.
- Persist the state and protect transitions under concurrency. Two users confirming the same hold at once is a race; a version column or conditional update (
UPDATE … WHERE status = 'HELD') makes only one win. - Record history when it matters. An audit trail of transitions (who, when, from, to) is often a requirement for orders and payments.
Trade-offs
Explicit state machines add upfront modelling and, with the State pattern, extra classes. They remove whole categories of bugs (impossible combinations, actions in the wrong state) and make behaviour reviewable at a glance. Over-modelling trivial lifecycles as elaborate machines is the opposite mistake; an object with two states and one transition needs a boolean, not a diagram.
In an interview
Whenever an entity has a lifecycle, draw its states early: "A Booking is Held, then Confirmed or Expired; a Confirmed booking can be Cancelled before the show. Each method checks the current state and rejects illegal transitions, and confirm uses a conditional update so two concurrent confirmations cannot both succeed." This is frequently the moment an interviewer decides a candidate is strong.
Check yourself
An Order class has booleans isPaid, isShipped and isCancelled. Support finds orders that are both cancelled and shipped. What modelling change prevents this class of bug?