Behavioral modeling
State machines
A state machine is a finite set of statuses plus the allowed transitions between them. You draw the legal arrows first; illegal arrows are bugs waiting to happen.
Example: a ticket is created, becomes active when the car parks, paid when the driver pays, and closed when the gate opens. A webhook retries and tries to make it ACTIVE again. Two booleans will not show that bug — a transition table will.
Ticket machine
CREATED → ACTIVE → PAID → CLOSED
Four nodes. Three legal arrows. Everything else throws. This picture is the spec. Coding style comes later.
CREATED
Issued, car not yet counted as parked
ACTIVE
Stall occupied, clock running
PAID
Fee taken, gate may open
CLOSED
Terminal. Record only.
CLOSED → ACTIVE is illegal
Also illegal: PAID → ACTIVE, CLOSED → PAID, CREATED → CLOSED. A retry does not drive backwards on the happy path.
Ticket
CREATED
| activate()
v
ACTIVE
| pay()
v
PAID
| close()
v
CLOSED
CLOSED → ACTIVE illegal
PAID → ACTIVE illegal
CLOSED → PAID illegalCommands, not setStatus
Keep the current state as a . Expose : activate(), pay(), close(). Each command consults the table. Do not expose setStatus(Status). That is how CLOSED becomes ACTIVE in one assignment, and how a retry “fixes” a ticket that was already finished.
This shape shows up everywhere you will design: ATM sessions, vending machines, elevators, bookings, orders, payments, schedulers. If what the object is allowed to do depends on where it is in its life, you have a state machine. Draw the table before you pick a coding style.
Later you will choose how to code the table: a status enum with guards, or State objects when each state has a page of behavior. The machine comes first. The Gang of Four State pattern is one way to implement a busy machine, not a substitute for drawing the arrows. That choice is state versus status.
The entity owns the table
Repository saves whatever legal status the entity already has. Controller reports the exception. Strategy never hears about status. Factory creates CREATED. Those pockets are from responsibility assignment. The arrows themselves live on Ticket.
enum TicketStatus { CREATED, ACTIVE, PAID, CLOSED }
class Ticket {
private TicketStatus status = TicketStatus.CREATED;
void activate() { require(TicketStatus.CREATED); status = TicketStatus.ACTIVE; }
void pay() { require(TicketStatus.ACTIVE); status = TicketStatus.PAID; }
void close() { require(TicketStatus.PAID); status = TicketStatus.CLOSED; }
private void require(TicketStatus expected) {
if (status != expected) {
throw new IllegalStateException(status + " cannot move from here");
}
}
}
// tickets.get(id).activate() on a CLOSED row throws.
// There is no setStatus. A webhook cannot resurrect the stay.Where this machine appears
- Parking ticket — this page’s running example.
- Order / booking — placed, paid, fulfilled, cancelled.
- ATM session — no-card, has-card, authenticated, dispensing.
- Elevator — idle, moving, boarding; doors are not a boolean soup.
What the machine is not
- Not a pipeline of checks (auth → balance → fraud). That is a chain of steps.
- Not three flags that can all be true at once.
- Not setStatus from a controller “just this once.”
- Not the State pattern until each chapter has real behavior.
Boolean soup versus one field
isPaid, isClosed, isActive can all be true at once. One status field with a table cannot. If you need three flags to know where you are, you needed states. Leftover flags are a machine you refused to name — see object lifecycle.
Flags
parked=true, paid=true, closed=true
Now what is activate() supposed to do?
One status
status == CLOSED
activate() throws. The table is the truth.
Interview walk
- List the states on the board. Four words for Ticket.
- List the legal arrows and name the commands.
- List the illegal arrows clearly. State clearly that CLOSED → ACTIVE is not a retry.
- Say the field is private. Say there is no setter.
- Only then decide enum-plus-guard versus State objects.
Common mistakes
Public setStatus
A setter is an unmarked door. CLOSED → ACTIVE becomes one assignment. Commands force every change through the table.
Boolean soup
isPaid, isClosed, isActive can all be true at once. One status field with a table cannot. If you need three flags to know where you are, you needed states.
Jumping to the State pattern
CreatedState that only assigns an enum is extra ceremony. Draw the machine first. Open State pattern when each chapter has a page of behavior, as in vending or ATM.
Same machine, other products
Write the four words first, then the illegal arrows, then the commands. The product name changes; the discipline does not. An elevator that is “moving and idle” at once is boolean soup with doors. An ATM that lets dispense run before PIN is a missing require(). A booking that reopens after checkout is CLOSED → ACTIVE with a nicer label.
Booking
HELD → CONFIRMED → CHECKED_IN → COMPLETE. No COMPLETE → HELD.
ATM session
NO_CARD → HAS_CARD → AUTH → DISPENSING. No DISPENSING without AUTH.
Order
PLACED → PAID → SHIPPED → DELIVERED. Refund is a new row, not a reverse.
Private field, public questions
isClosed() is allowed. setStatus is not. Readers may ask where the ticket is. Writers must go through activate, pay, and close. Keeping transitions inside the type is how you encapsulate the machine.
Read next
Takeaway
Write the states and the arrows. Ticket is CREATED → ACTIVE → PAID → CLOSED. CLOSED → ACTIVE is illegal. Commands, not setters. The machine is the spec; enum versus State objects is a later spelling.