State machines and state diagrams
Part of the free Low-Level Design course on LogicWiz, in the chapter: Modelling behaviour: sequence and state diagrams. The class-diagram exercises run in the app.
Week 9: The refund for money never taken
"We refunded 840 rupees on a payment that was never captured. The code allowed it because nobody wrote down which moves are legal."
Priya, reading the incident report
A lifecycle is a list of legal moves
A SwiftKart payment goes through a few steps. When the customer pays by card, the bank first authorises the payment: it holds the money but does not take it yet. When the order is packed, SwiftKart captures it: the money moves. If items are missing, SwiftKart may void the hold before capture, or refund after capture.
Arjun's Payment class had a status field and a setStatus method that anyone could call. So a support tool called refund() on a payment that was only authorised. Money that SwiftKart never took was sent back to the customer.
The fix starts on paper. A state machine is a model of an object's life with four parts:
- States: the conditions the object can be in, such as
AuthorisedorCaptured. - Events: things that happen to it, such as
captureorrefund. - Transitions: arrows that say "in this state, this event moves the object to that state".
- Guards: conditions on a transition, written in square brackets. The move happens only if the guard is true.
Everything that has no arrow is not allowed. That last rule is the whole value of the model.
Drawing it
A state diagram draws the state machine. The black dot [*] marks where the object starts, and an arrow to [*] marks where it ends.
stateDiagram-v2
[*] --> Created
Created --> Authorised: authorise [bank approves]
Created --> Failed: authorise [bank declines]
Authorised --> Captured: capture [amount within hold]
Authorised --> Voided: void
Captured --> Refunded: refund [within 7 days]
Failed --> [*]
Voided --> [*]
Refunded --> [*]
Read one arrow aloud: "When an authorised payment gets the capture event, and the amount is within the hold, it moves to Captured."
Now look for the missing arrow. There is no arrow labelled refund leaving Authorised. So the incident was a move the diagram never allowed. If the code had followed the diagram, the bad refund would have been rejected.
Notice also the two arrows out of Created with the same event. The guard decides which one is taken. Guards on the same event must not overlap, or the machine does not know which way to go.
The states that end the life are called final states: Failed, Voided and Refunded. No event moves a payment out of them. If product later wants partial refunds, you add a state such as PartlyRefunded and its arrows on the diagram first, then in code.
Rejecting invalid moves
In a mock interview, a candidate said: "If a move is not allowed, I skip it and carry on, so the user never sees an error."
That hides bugs. The refund incident would have looked like a success in the logs. When an event is not allowed in the current state, reject it clearly:
- throw an exception such as
IllegalStateExceptionwith the current state and the event in the message, or return a result that says "rejected" and why, - log it, so support can see the attempt,
- leave the state unchanged.
There is one real exception. Payment providers often send the same event twice, for example a "captured" callback retried after a timeout. If the payment is already Captured and a second capture arrives for the same payment, treat it as done, not as an error. This property is called idempotency: repeating an operation has the same effect as doing it once.
Every event goes through the same decision:
flowchart TD
E["Event arrives"] --> A{"Arrow from the current state for this event?"}
A -->|"No"| R["Reject: log it, state unchanged"]
A -->|"Yes"| G{"Guard true?"}
G -->|"No"| R2["Reject with the reason"]
G -->|"Yes"| M["Run the action, move to the new state, record it"]
From diagram to code: two ways
Way 1: an enum and a table of allowed moves. When the states mostly differ in which moves are allowed, keep the transitions as data.
public enum PaymentStatus { CREATED, AUTHORISED, CAPTURED, REFUNDED, FAILED, VOIDED }
public final class PaymentTransitions {
private static final Map<PaymentStatus, Set<PaymentStatus>> ALLOWED = Map.of(
PaymentStatus.CREATED, Set.of(PaymentStatus.AUTHORISED, PaymentStatus.FAILED),
PaymentStatus.AUTHORISED, Set.of(PaymentStatus.CAPTURED, PaymentStatus.VOIDED),
PaymentStatus.CAPTURED, Set.of(PaymentStatus.REFUNDED));
public static void check(PaymentStatus from, PaymentStatus to) {
if (!ALLOWED.getOrDefault(from, Set.of()).contains(to)) {
throw new IllegalStateException(from + " to " + to + " is not allowed");
}
}
}
Each arrow in the diagram is one entry in the map. The guards stay in the methods of Payment:
public void refund(LocalDate today) {
PaymentTransitions.check(status, PaymentStatus.REFUNDED);
if (capturedOn.plusDays(7).isBefore(today)) {
throw new IllegalStateException("Refund window of 7 days is over");
}
gateway.refund(id, amount);
status = PaymentStatus.REFUNDED;
}
Remove setStatus. The status changes only inside methods that check the table first.
Way 2: the State pattern from Week 8. When each state behaves differently, give each state a class. AuthorisedState.capture() calls the bank. CapturedState.refund() checks the 7-day window. AuthorisedState.refund() throws. The diagram tells you which classes to write: one per state, one method per event.
How to choose:
- Table: many states, simple rules, and you want to see every legal move in one place. Easy to change from config.
- State classes: fewer states, but real behaviour per state that would otherwise become a switch in every method.
💡 Tip: Even with State classes, save the status in the database as an enum column. When you load a payment, map the enum back to its state class. Also keep a history row (from, to, time, reason) for each move; support will ask for it.
⚠️ Warning: Two events can arrive at the same moment, such as a capture and a void. The table does not stop both from passing the check. That is a concurrency problem, and Module 10 solves it with locks and version checks.
Practice: the State classes for a payment
The starter is Arjun's Payment with a status enum and a public setStatus. Using the diagram above, design the State-pattern classes: a PaymentState type with a method per event, and a class for each of at least four states.
📌 Summary: A state diagram lists states, events, transitions and guards; any move without an arrow is rejected. Code it as an enum with a table of allowed moves, or as State classes when behaviour differs.
What Arjun learns next
The diagram is safe for one request at a time. On sale day, two requests can change the same payment, or book the last delivery slot, at the same moment. Next module: thread safety.
Check your understanding
- The payment diagram has no refund arrow out of Authorised. A support tool calls refund() on an authorised payment. What should happen?
- On the diagram, "Captured --> Refunded: refund [within 7 days]". What is the part in square brackets?
- The provider sends the "captured" callback twice because of a timeout. The payment is already Captured. What is the best handling?
- A delivery slot has five states. They differ only in which moves are allowed, and the rules change often. Which code shape fits?