Low-Level Design (Object-Oriented Design) Module 3 – Class relationships and UML
UML state machine diagrams
Model an object lifecycle with a UML state machine: events, guards, effects, entry and exit, composite states and timeouts, then a transition table and tests.
What you will learn
- Model states, events, guards and effects for an object's lifecycle
- Use entry and exit behaviour and composite states where they simplify
- Convert a state diagram into a transition table and tests
- Model a timeout as an event produced by a clock that tests control
Before you start
What you’ll learn · Before you start
What you will learn
- Model states, events, guards and effects for an object's lifecycle
- Use entry and exit behaviour and composite states where they simplify
- Convert a state diagram into a transition table and tests
- Model a timeout as an event produced by a clock that tests control
Before you start
On this page
A state machine diagram shows the lifecycle of one kind of object: the states it can be in, the events that move it
from one state to another, the conditions that must hold for a move, and what happens on the way. Each state is a
rounded box and each transition an arrow labelled event [guard] / effect, such as
select(p) [credit ≥ price(p)] / release p. Anything the diagram does not show is not allowed, and that is its main
value in a design: an order that is already delivered cannot be cancelled, because no arrow says it can.
Lifecycles are everywhere in low-level design rounds: bookings, payments, orders, rides, tickets, vending machines. This lesson draws one vending machine with guards, entry and exit behaviour, a composite state and a timeout, turns the diagram into a transition table with one test per row, and lets the Java compiler check that no state is forgotten.
States, events, guards and effects
A state is a situation in which the object waits for something to happen and its rules stay the same. An
event is something that happens to it: a method call such as coin(10), a message, or time passing. A
transition’s label has three optional parts, in UML 2.5.1’s order:
- the trigger, one or more events separated by commas:
cancel, after(30 s); - a guard in square brackets, a condition checked when the event arrives:
[credit ≥ price(p)]; - an effect after a slash, the behaviour run while the transition fires:
/ hand back credit.
A filled dot is the initial pseudostate, which points at the state a new object starts in; a filled dot inside a circle is a final state, where the object’s lifecycle ends. Here is a vending machine that sells tea for 20 and coffee for 30:
A vending machine: states, events, guards and effects, with one composite state
Text description of the diagram
A composite state named InService contains three states: Idle, Collecting and Dispensing. Outside it is a fourth state, Maintenance. Each transition is labelled event [guard] / effect.
- Inside InService, the initial pseudostate, a filled dot, points to Idle: the machine starts there, and so does every entry into InService.
- Idle to Collecting: coin(c) / credit += c.
- Collecting to itself: coin(c) / credit += c, and select(p) [else] / show price.
- Collecting to Dispensing: select(p) [credit ≥ price(p) and p in stock] / release p.
- Collecting to Idle: cancel, or after(30 s), / hand back credit.
- Dispensing to Idle: take / hand back change.
- From the edge of InService, so from any of its three states, to Maintenance: door_open / hand back credit.
- From Maintenance back to the edge of InService: door_close, which enters InService at its initial pseudostate, so the machine starts again in Idle. The two transitions are drawn on one line, with an arrowhead and a label at each end.
Inside the Maintenance state are its internal behaviours: entry / lock coin slot, exit / unlock coin slot, and the internal transition refill(p, n) / stock(p) += n, which does not leave the state.
The same event can lead to different places depending on its guard. select(p) in Collecting releases the item
when the credit covers the price and the item is in stock; otherwise ([else]) it shows the price and stays put.
Guards on transitions that share a trigger must not both be true at once, or the diagram does not say which one
fires.
Entry, exit and internal transitions
A state can have its own behaviour, written inside its box. entry / runs every time the state is entered, exit /
every time it is left, and do / runs while the object stays in the state. Maintenance locks the coin slot on
entry and unlocks it on exit, so no transition into or out of maintenance has to remember either step. Behaviour
that belongs to every way in or every way out goes there, written once.
Inside the same box, refill(p, n) / stock(p) += n is an internal transition: it handles the event without
leaving the state, so neither the exit nor the entry behaviour runs. An arrow from a state back to itself is
different: it leaves the state and enters it again, running exit and then entry. For Maintenance that would unlock
and relock the coin slot on every refill, which is why refill is drawn inside the box.
Composite states
Idle, Collecting and Dispensing sit inside a composite state, InService. The door_open arrow starts at the
edge of InService, so it applies in all three of its substates: whatever the machine is doing, opening the door
hands back any credit and enters Maintenance. One arrow replaces three, and adding a fourth substate later needs
no new arrow at all. A common use in booking systems: every substate of Active can be cancelled. The door_close
arrow ends at the edge of InService too: entering a composite state from outside goes through its own initial
pseudostate, so the machine starts again in Idle.
A composite state can also remember where it was. A shallow history pseudostate, an H in a circle, stands for
the most recent active substate, so a transition into it resumes there instead of starting again. Use it when an
interruption should return the object to what it was doing, and leave it out otherwise, because it makes the diagram
harder to check.
Time is an event too
after(30 s) is a time event: it fires 30 seconds after the object entered Collecting, if it is still there. UML
also has change events, written when(condition), which fire as soon as a condition becomes true. In code, the
easiest way to make time testable is to pass the clock in. The machine below never reads the system clock: it asks a
clock object for the time, and its tick() method turns time passing into the timeout event when one is due. A
test then moves a fake clock to 29 seconds and to 30 seconds and checks both sides of the boundary.
The diagram also says when the 30 seconds start again. coin(c) in Collecting is an arrow back to the same
state, so each coin leaves and re-enters Collecting, and the time event counts from the new entry. In the code,
add_credit() restarts the count by noting the time of each coin.
From the diagram to a transition table
A transition table has one row for each arrow: the source state, the event, the guard, the effect and the target state. Pairs of a state and an event without a row are illegal transitions, and the code should refuse them explicitly rather than ignore them. The vending machine’s table has 12 rows:
| # | State | Event | Guard | Effect | Next state |
|---|---|---|---|---|---|
| 1 | Idle | coin(c) |
credit += c | Collecting | |
| 2 | Collecting | coin(c) |
credit += c | Collecting | |
| 3 | Collecting | select(p) |
credit ≥ price, in stock | release p | Dispensing |
| 4 | Collecting | select(p) |
else | show the price | Collecting |
| 5 | Collecting | cancel |
hand back credit | Idle | |
| 6 | Collecting | timeout |
hand back credit | Idle | |
| 7 | Dispensing | take |
hand back change | Idle | |
| 8 | Maintenance | refill(p, n) |
stock += n | (stays: internal) | |
| 9 | Maintenance | door_close |
Idle | ||
| 10–12 | Idle, Collecting, Dispensing | door_open |
hand back credit | Maintenance |
The composite state’s single door_open arrow became rows 10 to 12, one per substate: a flat table spells out what
the composite state says once. In code, the table is a dictionary from (state, event) to its rows, and a
fire() method looks the pair up, raises IllegalTransition when it is missing, runs the exit behaviour, the effect
and the entry behaviour, and moves to the target. The check below runs one test per row, then tries every pair that
has no row, then drives the timeout with a fake clock:
vending/check_vending.py
"""One test for every row of the vending machine's transition table, then every pair
that has no row, then the timeout, driven by a clock the test controls."""
from vending import EVENTS, STATES, TABLE, IllegalTransition, VendingMachine
class FakeClock:
def __init__(self):
self.now = 0
def __call__(self):
return self.now
def machine_in(state, credit=0, stock=None):
m = VendingMachine(stock or {"tea": 2, "coffee": 1}, FakeClock())
m.state, m.credit = state, credit
return m
# (row, state, credit, stock, event, args, expected state, expected output)
CASES = [
((("Idle", "coin"), 0), "Idle", 0, None, "coin", (10,), "Collecting", []),
((("Collecting", "coin"), 0), "Collecting", 10, None, "coin", (20,), "Collecting", []),
((("Collecting", "select"), 0), "Collecting", 30, None, "select", ("tea",), "Dispensing", ["tea"]),
((("Collecting", "select"), 1), "Collecting", 30, {"coffee": 0}, "select", ("coffee",), "Collecting", ["display: coffee costs 30"]),
((("Collecting", "cancel"), 0), "Collecting", 20, None, "cancel", (), "Idle", ["coins: 20"]),
((("Collecting", "timeout"), 0), "Collecting", 20, None, "timeout", (), "Idle", ["coins: 20"]),
((("Dispensing", "take"), 0), "Dispensing", 10, None, "take", (), "Idle", ["coins: 10"]),
((("Maintenance", "refill"), 0), "Maintenance", 0, None, "refill", ("tea", 5), "Maintenance", []),
((("Maintenance", "door_close"), 0), "Maintenance", 0, None, "door_close", (), "Idle", ["coin slot unlocked"]),
((("Idle", "door_open"), 0), "Idle", 0, None, "door_open", (), "Maintenance", ["coin slot locked"]),
((("Collecting", "door_open"), 0), "Collecting", 20, None, "door_open", (), "Maintenance", ["coins: 20", "coin slot locked"]),
((("Dispensing", "door_open"), 0), "Dispensing", 0, None, "door_open", (), "Maintenance", ["coin slot locked"]),
]
ARGS = {"coin": (10,), "select": ("tea",), "refill": ("tea", 5)}
rows = {(key, i) for key, alternatives in TABLE.items() for i in range(len(alternatives))}
tested = {case[0] for case in CASES}
assert rows == tested, f"rows without a test: {sorted(rows - tested)}"
passed = 0
for n, (row, state, credit, stock, event, args, want_state, want_out) in enumerate(CASES, start=1):
m = machine_in(state, credit, stock)
m.fire(event, *args)
ok = m.state == want_state and m.out == want_out
passed += ok
call = f"{event}({', '.join(map(str, args))})"
print(f"row {n:>2} {state:<11} {call:<17} -> {m.state:<11} {'ok' if ok else 'FAILED: ' + str(m.out)}")
print(f"{passed} of {len(CASES)} rows pass")
refused = 0
for state in STATES:
for event in EVENTS:
if (state, event) in TABLE:
continue
m = machine_in(state, 10)
try:
m.fire(event, *ARGS.get(event, ()))
except IllegalTransition:
refused += (m.state, m.credit, m.out) == (state, 10, [])
print(f"{refused} of {len(STATES) * len(EVENTS) - len(TABLE)} unlisted pairs refused, with the state unchanged")
clock = FakeClock()
m = VendingMachine({"tea": 1}, clock)
m.fire("coin", 10)
clock.now = 29
before = m.tick()
clock.now = 30
print(f"timeout: {before} at 29 s, {m.tick()} at 30 s, handed back {m.out}") Output
row 1 Idle coin(10) -> Collecting ok row 2 Collecting coin(20) -> Collecting ok row 3 Collecting select(tea) -> Dispensing ok row 4 Collecting select(coffee) -> Collecting ok row 5 Collecting cancel() -> Idle ok row 6 Collecting timeout() -> Idle ok row 7 Dispensing take() -> Idle ok row 8 Maintenance refill(tea, 5) -> Maintenance ok row 9 Maintenance door_close() -> Idle ok row 10 Idle door_open() -> Maintenance ok row 11 Collecting door_open() -> Maintenance ok row 12 Dispensing door_open() -> Maintenance ok 12 of 12 rows pass 21 of 21 unlisted pairs refused, with the state unchanged timeout: Collecting at 29 s, Idle at 30 s, handed back ['coins: 10']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_vending.py
vending/vending.py
"""A vending machine's lifecycle, kept as a transition table.
TABLE maps (state, event) to a list of (guard, effect, target) rows, tried in order;
the first row whose guard holds is taken. A pair with no row is an illegal transition
and is refused. A target of None is an internal transition: the state does not change
and its entry and exit behaviour do not run. The clock is passed in, so the timeout
is an ordinary event in tests.
"""
PRICES = {"tea": 20, "coffee": 30}
TIMEOUT = 30 # seconds without a selection before the credit is handed back
IN_SERVICE = ("Idle", "Collecting", "Dispensing") # the substates of the composite state InService
STATES = (*IN_SERVICE, "Maintenance")
EVENTS = ("coin", "select", "cancel", "timeout", "take", "door_open", "door_close", "refill")
class IllegalTransition(Exception):
pass
def always(m, *args):
return True
def can_buy(m, item):
return m.credit >= PRICES[item] and m.stock.get(item, 0) > 0
def add_credit(m, amount):
m.credit += amount
m.since = m.clock()
def vend(m, item):
m.stock[item] -= 1
m.credit -= PRICES[item]
m.out.append(item)
def ask_for_more(m, item):
m.out.append(f"display: {item} costs {PRICES[item]}")
def hand_back_credit(m, *args):
if m.credit:
m.out.append(f"coins: {m.credit}")
m.credit = 0
def add_stock(m, item, count):
m.stock[item] = m.stock.get(item, 0) + count
def nothing(m, *args):
pass
TABLE = {
("Idle", "coin"): [(always, add_credit, "Collecting")],
("Collecting", "coin"): [(always, add_credit, "Collecting")],
("Collecting", "select"): [(can_buy, vend, "Dispensing"), (always, ask_for_more, "Collecting")],
("Collecting", "cancel"): [(always, hand_back_credit, "Idle")],
("Collecting", "timeout"): [(always, hand_back_credit, "Idle")],
("Dispensing", "take"): [(always, hand_back_credit, "Idle")],
("Maintenance", "refill"): [(always, add_stock, None)],
("Maintenance", "door_close"): [(always, nothing, "Idle")],
}
# One arrow on the diagram, from the composite state InService, is one row per substate here.
for _state in IN_SERVICE:
TABLE[(_state, "door_open")] = [(always, hand_back_credit, "Maintenance")]
ENTRY = {"Maintenance": lambda m: m.out.append("coin slot locked")}
EXIT = {"Maintenance": lambda m: m.out.append("coin slot unlocked")}
class VendingMachine:
def __init__(self, stock, clock):
self.state = "Idle"
self.credit = 0
self.stock = dict(stock)
self.clock = clock
self.since = clock()
self.out = []
def fire(self, event, *args):
rows = TABLE.get((self.state, event))
if rows is None:
raise IllegalTransition(f"{event} is not allowed in {self.state}")
for guard, effect, target in rows:
if guard(self, *args):
if target is not None:
EXIT.get(self.state, nothing)(self)
effect(self, *args)
if target is not None:
self.state = target
ENTRY.get(target, nothing)(self)
return self.state
raise IllegalTransition(f"no guard of {event} holds in {self.state}")
def tick(self):
"""Turns the passing of time into the timeout event, when one is due."""
if self.state == "Collecting" and self.clock() - self.since >= TIMEOUT:
return self.fire("timeout")
return self.state Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
All 12 rows pass, and all 21 pairs without a row are refused with the state, the credit and the output unchanged.
Row 8 shows the internal transition at work: refilling produced no “coin slot” lines, while row 9, leaving
Maintenance, ran its exit behaviour. The check also asserts that every row of the table has a test, so a row
added later without one fails at once. Four states and eight events make 32 pairs; testing every one of them takes
milliseconds, and that is how a missing refusal is caught before a user finds it.
Let the compiler list the states
In Java 25 a lifecycle can be written so that the compiler checks it. The states are records that implement a
sealed interface, so the compiler knows every permitted state, and a switch expression over a sealed type must
cover all of them. A state can carry its own data: Collecting(int credit) holds the credit, so a state without
credit cannot have any by mistake. Record patterns take the data apart in the case, and the unnamed pattern _
ignores what a case does not need:
session · compiled/java_session.py
from session import run, versions
# VendingFlow.java runs as a single source file; MissingCase.java is expected not to compile.
versions("java -version")
run(
"""
java VendingFlow.java
javac -d classes MissingCase.java
""",
may_fail=("javac -d classes MissingCase.java",),
) Output
# openjdk version "25.0.4.1" LTS
$ java VendingFlow.java
Coin[amount=10] -> Collecting[credit=10]
Select[item=tea] -> Collecting[credit=10]
Coin[amount=20] -> Collecting[credit=30]
Select[item=tea] -> Dispensing[item=tea, change=10]
Take[] -> Idle[]
refused: Take[] is not allowed in Idle[]
$ javac -d classes MissingCase.java
MissingCase.java:9: error: the switch expression does not cover all possible input values
return switch (state) {
^
1 error
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 java_session.py
compiled/VendingFlow.java
// The vending machine's states and events as sealed interfaces of records. A switch over
// a sealed type must cover every permitted subtype, so the compiler lists the states.
import java.util.Map;
public class VendingFlow {
static final Map<String, Integer> PRICES = Map.of("tea", 20, "coffee", 30);
static VendingState next(VendingState state, Event event) {
return switch (state) {
case Idle _ -> switch (event) {
case Coin(int amount) -> new Collecting(amount);
case DoorOpen _ -> new Maintenance();
default -> throw refused(state, event);
};
case Collecting(int credit) -> switch (event) {
case Coin(int amount) -> new Collecting(credit + amount);
case Select(String item) when credit >= PRICES.get(item) -> new Dispensing(item, credit - PRICES.get(item));
case Select _ -> state;
case Cancel _ -> new Idle();
case DoorOpen _ -> new Maintenance();
default -> throw refused(state, event);
};
case Dispensing(String item, int change) -> switch (event) {
case Take _ -> new Idle();
case DoorOpen _ -> new Maintenance();
default -> throw refused(state, event);
};
case Maintenance _ -> switch (event) {
case DoorClose _ -> new Idle();
default -> throw refused(state, event);
};
};
}
static IllegalStateException refused(VendingState state, Event event) {
return new IllegalStateException(event + " is not allowed in " + state);
}
public static void main(String[] args) {
VendingState state = new Idle();
for (Event event : new Event[] {new Coin(10), new Select("tea"), new Coin(20), new Select("tea"), new Take()}) {
state = next(state, event);
System.out.println(event + " -> " + state);
}
try {
next(state, new Take());
} catch (IllegalStateException e) {
System.out.println("refused: " + e.getMessage());
}
}
}
sealed interface VendingState permits Idle, Collecting, Dispensing, Maintenance {}
record Idle() implements VendingState {}
record Collecting(int credit) implements VendingState {}
record Dispensing(String item, int change) implements VendingState {}
record Maintenance() implements VendingState {}
sealed interface Event permits Coin, Select, Cancel, Take, DoorOpen, DoorClose {}
record Coin(int amount) implements Event {}
record Select(String item) implements Event {}
record Cancel() implements Event {}
record Take() implements Event {}
record DoorOpen() implements Event {}
record DoorClose() implements Event {} compiled/MissingCase.java
// VendingFlow.java with one state forgotten: the switch over VendingState has no case for
// Maintenance, and javac refuses to compile it.
import java.util.Map;
public class MissingCase {
static final Map<String, Integer> PRICES = Map.of("tea", 20, "coffee", 30);
static VendingState next(VendingState state, Event event) {
return switch (state) {
case Idle _ -> switch (event) {
case Coin(int amount) -> new Collecting(amount);
case DoorOpen _ -> new Maintenance();
default -> throw refused(state, event);
};
case Collecting(int credit) -> switch (event) {
case Coin(int amount) -> new Collecting(credit + amount);
case Select(String item) when credit >= PRICES.get(item) -> new Dispensing(item, credit - PRICES.get(item));
case Select _ -> state;
case Cancel _ -> new Idle();
case DoorOpen _ -> new Maintenance();
default -> throw refused(state, event);
};
case Dispensing(String item, int change) -> switch (event) {
case Take _ -> new Idle();
case DoorOpen _ -> new Maintenance();
default -> throw refused(state, event);
};
};
}
static IllegalStateException refused(VendingState state, Event event) {
return new IllegalStateException(event + " is not allowed in " + state);
}
public static void main(String[] args) {
VendingState state = new Idle();
for (Event event : new Event[] {new Coin(10), new Select("tea"), new Coin(20), new Select("tea"), new Take()}) {
state = next(state, event);
System.out.println(event + " -> " + state);
}
try {
next(state, new Take());
} catch (IllegalStateException e) {
System.out.println("refused: " + e.getMessage());
}
}
}
sealed interface VendingState permits Idle, Collecting, Dispensing, Maintenance {}
record Idle() implements VendingState {}
record Collecting(int credit) implements VendingState {}
record Dispensing(String item, int change) implements VendingState {}
record Maintenance() implements VendingState {}
sealed interface Event permits Coin, Select, Cancel, Take, DoorOpen, DoorClose {}
record Coin(int amount) implements Event {}
record Select(String item) implements Event {}
record Cancel() implements Event {}
record Take() implements Event {}
record DoorOpen() implements Event {}
record DoorClose() implements Event {} compiled/session.py
"""Replays commands the way a terminal shows them: "$ " and the command, then all it printed.
Standard output and standard error are merged in the order they were written. A command
that exits with a status other than 0 is followed by "[exit status N]"; unless the script
lists it in may_fail, the session then exits with status 1, so a run that could not build
a program is never recorded as if it had worked.
Java is the pinned Java 25 (Eclipse Temurin 25.0.4) from the mise installation next to
the Python that runs this file. It lives in the home folder, which learn:verify's sandbox
does not let a run read, so the sessions of this folder are recorded with
LEARN_VERIFY_NETWORK=allow. They read only this folder and the JDK, write only inside the
run's own folder and temporary home, and use no network.
"""
import os
import re
import subprocess
import sys
from pathlib import Path
# <mise>/installs/python/3.14.8/bin/python3 -> <mise>/installs
INSTALLS = Path(sys.executable).resolve().parents[3]
JDK = INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin"
# java -version prints a release date; pages show no dates.
RELEASE_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}")
def _shell(command):
env = dict(os.environ, PATH=os.pathsep.join([str(JDK), os.environ.get("PATH", "")]))
return subprocess.run(command, shell=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def versions(*commands):
"""Prints the first line of each version command, as a comment."""
for command in commands:
done = _shell(command)
lines = done.stdout.splitlines()
if done.returncode != 0 or not lines:
sys.exit(f"{command} did not run: the pinned JDK is missing on this computer")
print("#", RELEASE_DATE.sub("", lines[0]))
def run(script, may_fail=()):
"""Runs each line of the script as one shell command."""
unexpected = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
done = _shell(command)
print(done.stdout, end="")
if done.returncode != 0:
print(f"[exit status {done.returncode}]")
if command not in may_fail:
unexpected += 1
if unexpected:
sys.exit(1) MissingCase.java is the same program without the Maintenance case, and javac refuses it: the switch
expression does not cover all possible input values. When someone adds a state to the sealed interface, every
switch that forgot it stops compiling, which is the transition-table test done by the compiler. The events inside
each state still use default, because most pairs are illegal; the refusal of those pairs is still a job for tests.
Mistakes that make lifecycles fragile
- A status string that anyone can set. If any code can write
order.status = "PREPARING", nothing stops a delivered order going back to the kitchen. Make the transitions the only way to change the state. - Boolean flags instead of states.
is_paid,is_cancelledandis_shippedallow eight combinations, most of them meaningless (paid, cancelled and shipped at once). One state field allows only the states you drew. - Data in state names.
Credit10,Credit20andCredit30are not three states but one state with an attribute. Keep the credit as data, asCollecting(int credit)does. - Silent no-ops. An illegal event that is quietly ignored hides bugs. Refuse it with an error that names the state and the event.
- Forgotten failures and timeouts. Payments fail, holds expire and drivers do not turn up. Ask, for every state, “what if nothing happens?” and “what if this fails?”
Practise
Write the transition table of a food-delivery order with six states and six events, and make illegal transitions fail loudly. The sample tests try all 36 pairs.
Exercise · Medium · Python
A food order's lifecycle as a transition table
A food-delivery order goes through six states: placed, accepted, preparing, picked_up, delivered and cancelled. Six events move it: accept, reject, start, pick_up, deliver and cancel. These are the only allowed transitions:
| From | Event | To | | --- | --- | --- | | placed | accept | accepted | | placed | reject | cancelled | | placed | cancel | cancelled | | accepted | start | preparing | | accepted | cancel | cancelled | | preparing | pick_up | picked_up | | preparing | cancel | cancelled | | picked_up | deliver | delivered |
delivered and cancelled are final: every event is refused there.
In food_order.py, complete TRANSITIONS with every row of the table and make FoodOrder.handle(event) follow it: an allowed event moves the order to the next state and returns it; any other pair raises IllegalTransition and leaves the order exactly as it was. One effect belongs to a transition: cancelling while the order is preparing sets cancellation_fee to half the price (rounded down), because the kitchen has started; cancelling earlier leaves the fee at 0.
The sample tests try all 36 pairs of a state and an event, check the allowed ones and the refusals, and check the fee.
Starter code · food_order.py
"""A food order's lifecycle, kept as a transition table."""
STATES = ("placed", "accepted", "preparing", "picked_up", "delivered", "cancelled")
EVENTS = ("accept", "reject", "start", "pick_up", "deliver", "cancel")
# (state, event) -> next state. Add the other rows of the table.
TRANSITIONS = {
("placed", "accept"): "accepted",
}
class IllegalTransition(Exception):
pass
class FoodOrder:
def __init__(self, price):
self.price = price
self.state = "placed"
self.cancellation_fee = 0
def handle(self, event):
"""Moves the order to its next state and returns it; refuses pairs the table does not list."""
self.state = TRANSITIONS.get((self.state, event), self.state)
return self.state The sample tests · test_food_order.py
from food_order import EVENTS, STATES, TRANSITIONS, FoodOrder, IllegalTransition
ALLOWED = {
("placed", "accept"): "accepted",
("placed", "reject"): "cancelled",
("placed", "cancel"): "cancelled",
("accepted", "start"): "preparing",
("accepted", "cancel"): "cancelled",
("preparing", "pick_up"): "picked_up",
("preparing", "cancel"): "cancelled",
("picked_up", "deliver"): "delivered",
}
def order_in(state, price=300):
order = FoodOrder(price)
order.state = state
return order
def test_the_table_lists_exactly_the_allowed_transitions():
"""TRANSITIONS has the eight rows of the table and nothing else"""
assert TRANSITIONS == ALLOWED
def test_allowed_transitions_move_the_order():
"""each allowed event moves the order to the next state and returns it"""
for (state, event), target in ALLOWED.items():
order = order_in(state)
assert order.handle(event) == target, f"{event} in {state}"
assert order.state == target, f"{event} in {state}"
def test_every_other_pair_is_refused():
"""the 28 other pairs raise IllegalTransition and leave the order as it was"""
refused = 0
for state in STATES:
for event in EVENTS:
if (state, event) in ALLOWED:
continue
order = order_in(state)
try:
order.handle(event)
except IllegalTransition:
refused += 1
else:
raise AssertionError(f"{event} in {state} was allowed")
assert (order.state, order.cancellation_fee) == (state, 0), f"{event} in {state} changed the order"
assert refused == 28
def test_cancellation_fee():
"""cancelling while preparing costs half the price, rounded down; earlier it costs nothing"""
for state, fee in (("placed", 0), ("accepted", 0), ("preparing", 150)):
order = order_in(state, price=300)
order.handle("cancel")
assert order.cancellation_fee == fee, f"cancel in {state}"
odd = order_in("preparing", price=125)
odd.handle("cancel")
assert odd.cancellation_fee == 62
def test_a_whole_order():
"""an order goes from placed to delivered, and then refuses a late cancel"""
order = FoodOrder(240)
for event, state in (("accept", "accepted"), ("start", "preparing"), ("pick_up", "picked_up"), ("deliver", "delivered")):
assert order.handle(event) == state
try:
order.handle("cancel")
except IllegalTransition:
pass
else:
raise AssertionError("a delivered order was cancelled")
assert order.state == "delivered" A hint
Write the table as a dictionary from (state, event) to the next state, and let handle() look the pair up first: if it is missing, raise before changing anything. Run the fee rule only for the one pair it belongs to, ("preparing", "cancel").
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between an event and a guard on a transition?
The event is what triggers the transition: something that happens to the object, such as a method call
(select(p)), a message, or time passing (after(30 s)). The guard is a condition in square brackets that is
checked when the event arrives, such as [credit ≥ price(p)], and the transition fires only if it is true. So the
same event can lead to different states with different guards, and an event whose guards are all false leaves the
object where it was. In code, the event is usually a method, the guard an if at its start, and the effect what the
method does before it changes the state. Guards should read data the object already has, not cause side effects,
because they may be checked without the transition firing.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Design the lifecycle of a cab ride, from requested to completed or cancelled, as a state machine. Where do the driver's response timeout and the cancellation fee go, and how do you test every transition?
-
Mid to senior
Your order service stores the status as a string column, and any code can set it. Support reports orders that went from DELIVERED back to PREPARING. How do you redesign it, and how do you migrate safely?
Key takeaways
- A state machine diagram shows one object’s lifecycle; a transition is
event [guard] / effect. - Anything the diagram does not show is an illegal transition, and the code should refuse it loudly.
- Put behaviour shared by every way in or out of a state into
entry /andexit /; an internal transition runs neither, an arrow back to the same state runs both. - A composite state’s arrow applies in every substate: one arrow on the diagram, one row per substate in a table.
- Treat timeouts as events produced by a clock you pass in, so tests can control them.
- Turn the diagram into a table, test every row and every missing pair, and in Java let a sealed type’s
switchlist the states.
References
- Unified Modeling Language (UML) 2.5.1, formal specification (chapter 14, State Machines) (Object Management Group)
- JEP 409: Sealed Classes (OpenJDK)
- JEP 440: Record Patterns (OpenJDK)
- JEP 441: Pattern Matching for switch (OpenJDK)
- JEP 456: Unnamed Variables & Patterns (OpenJDK)
- The Python Tutorial: dictionaries (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress