Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

State machine of a vending machine: Idle, Collecting and Dispensing inside a composite state, and a Maintenance state.InServiceMaintenance──────────entry / lock coin slotexit / unlock coin slotrefill(p, n) / stock(p) += n●IdleCollectingDispensingcoin(c) / credit += ccoin(c) / credit += cselect(p) [else] / show priceselect(p) [credit ≥ price(p)and p in stock] / release pcancel, after(30 s)/ hand back credittake / hand back changedoor_open / hand back creditdoor_close

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:

One test per row, every unlisted pair refused

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

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:

States as sealed records, and a switch the compiler checks

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_cancelled and is_shipped allow 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, Credit20 and Credit30 are not three states but one state with an attribute. Keep the credit as data, as Collecting(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").

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.

  1. Question 1 of 7 In the transition label select(p) [credit ≥ price(p)] / release p, what is [credit ≥ price(p)]?

    Choose one answer.

    Show the answer to question 1

    Answer: The guard, checked when select(p) arrives; the transition fires only if it is true

    A transition label is trigger [guard] / effect. select(p) is the trigger, the bracketed condition is the guard, and release p after the slash is the effect.

  2. Question 2 of 7 Maintenance has entry / lock coin slot and exit / unlock coin slot. What happens on refill if it is drawn as an arrow from Maintenance back to Maintenance instead of an internal transition?

    Choose one answer.

    Show the answer to question 2

    Answer: The slot is unlocked and locked again, because the arrow leaves and re-enters the state

    An arrow back to the same state is an external transition: it runs the exit behaviour, the effect and then the entry behaviour. An internal transition, written inside the state's box, runs only its effect.

  3. Question 3 of 7 The composite state InService holds 3 substates, and one door_open arrow leaves its edge. How many rows does door_open add to a flat transition table?

    Type a number.

    Show the answer to question 3

    Answer: 3

    An arrow from a composite state's edge applies in each of its substates, so a flat table needs one row per substate: Idle, Collecting and Dispensing.

  4. Question 4 of 7 The table has no row for (Idle, take). What should fire("take") do when the machine is Idle?

    Choose one answer.

    Show the answer to question 4

    Answer: Raise an error that names the state and the event, and change nothing

    A pair the diagram does not show is an illegal transition. Refusing it loudly, with the state left as it was, turns a bug in the caller into an error you can see, instead of a silent no-op.

  5. Question 5 of 7 How does the lesson's vending machine make the 30-second timeout testable?

    Choose one answer.

    Show the answer to question 5

    Answer: A clock object is passed in, and tick() turns time passing into the timeout event when it is due

    With the clock passed in, a test sets a fake clock to 29 and then 30 seconds and checks both sides of the boundary in microseconds. Reading the system clock directly would make such a test slow and flaky.

  6. Question 6 of 7 An order is modelled with three booleans, is_paid, is_cancelled and is_shipped. How many combinations of values can those fields hold?

    Type a number.

    Show the answer to question 6

    Answer: 8

    Three booleans give 2 × 2 × 2 = 8 combinations, most of them meaningless, such as cancelled and shipped at once. One state field with the states you drew allows only those states.

  7. Question 7 of 7 In Java 25, the states are records implementing a sealed interface. What happens when a switch expression over the state has no case for one of them?

    Choose one answer.

    Show the answer to question 7

    Answer: The code does not compile; the switch expression does not cover all possible input values

    A switch expression must be exhaustive. Over a sealed type, the compiler knows every permitted subtype, so a missing case is a compile-time error, which is the error recorded for MissingCase.java.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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 / and exit /; 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 switch list the states.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.