Course 1 ยท Chapter 8 ยท State Machines and Activity Diagrams
Class diagrams show structure and sequence diagrams show one conversation. Two questions remain. What can happen to a single object over its life, and in what order? (A loan is borrowed, may be renewed, becomes overdue, is returned or lost.) That is a state machine. And: what are the steps of a process that crosses several people and systems? (A reserved book comes back; who does what, and in what order, and what runs in parallel?) That is an activity diagram.
What was run, and what was not
PlantUML 1.2026.8 drew the state and activity diagrams; Mermaid 12.1.0 drew a flat version of the state diagram and was probed for the rest. Python 3.14 runs everything else: the loan lifecycle written twice (a transition table, and the State pattern), 28 tests that run against both, a transition matrix, and a reservation workflow with 10 tests.
The tools' fidelity is part of the story: I tested what Mermaid does with history states and entry/exit actions, and the answer is that it accepts them without error and draws them wrongly. That is why the full diagram is PlantUML.
From general knowledge, not re-read: the UML rule that an event which triggers no transition is simply discarded, how entry actions behave when a state is re-entered through history, and the token semantics of activity diagrams (not covered here).
State Machines: The Vocabulary
Term
Notation
Meaning
In the loan lifecycle
State
Rounded box
A condition an object stays in, in which it reacts to some events and not others
OnLoan, Overdue, Suspended, Returned, Lost
Event
Label on an arrow
Something that happens and may cause a transition
return_book, renew, day_passed
Transition
Arrow
A move from one state to another when an event occurs
OnLoan to Overdue on day_passed
Guard
[condition]
A test that must be true for the transition to fire
[today > due]
Action
/ action on an arrow
Work done during the transition
/ charge fine
Entry and exit action
entry / ... inside the box
Work done every time the state is entered or left, however you got there
Overdue: entry / send overdue notice
Initial and final
Filled dot; circled dot
Where the object starts; where its life ends
Starts OnLoan; ends Returned or Lost
Composite state
A box containing states
A group of states that share transitions
Active holds OnLoan and Overdue
History state
Circled H
"Go back to whichever substate you were in last"
Reopening the library resumes OnLoan or Overdue
A Loan's Life
The rules, from earlier chapters and a few new ones: a loan starts OnLoan. It can be renewed for 14 days up to twice, unless someone else is waiting. When the due date passes it becomes Overdue, and an overdue notice goes out. It can be returned from either of those, with a fine of 10p a day if late. An overdue loan 60 days late can be declared lost, for a 25-pound replacement fee. If the library closes (a snow day, say), the loan is Suspended, and when it reopens the loan goes back to what it was, with the due date pushed back by the days closed.
PlantUML. Generated from the transition table in loan_sm.py (below), not drawn by hand. Active is a composite state: return_book and close_library are drawn once, from the box, and apply to both OnLoan and Overdue. The H is the history state.
@startuml
scale 1.3
hide empty description
[*] --> Active
state Active {
[*] --> OnLoan
state OnLoan
state Overdue
Overdue : entry / send overdue notice
}
Returned : entry / free the copy
OnLoan --> Overdue : day_passed [today > due]
OnLoan --> OnLoan : renew [renewals < 2 and nobody waiting] / extend due date 14 days
Active --> Returned : return_book / charge fine
Overdue --> Lost : declare_lost [today >= due + 60 days] / charge replacement fee
Active --> Suspended : close_library / remember closing date
Suspended --> Active[H] : reopen / push due date back by days closed
Returned --> [*]
Lost --> [*]
@enduml
Three things the diagram makes visible that a paragraph hides. The composite state saves arrows: without Active, return_book and close_library would each need two arrows. History is the right tool for "go back": reopening does not need to know whether the loan was overdue. Nothing leaves Returned or Lost except to the final dot: a returned loan cannot be renewed, which is a rule you can see.
What the diagram does not show: events that are deliberately ignored (the daily clock tick in Overdue, Suspended, Returned and Lost) are conventionally left off, which is a reason to have the matrix below.
The Same Loan in Code, From a Table
A state machine is data: a list of (source state, event, guard, action, target). Writing the code as that list makes the diagram and the code the same thing, because the diagram above was generated from the list. Guards and actions are named, so the diagram text and the code cannot drift.
"""The lifecycle of a library loan as a table-driven state machine.
States: OnLoan and Overdue (both inside the composite state Active), Suspended, Returned, Lost.
Events: renew, day_passed, return_book, declare_lost, close_library, reopen.
The transition table is the single source: the diagram is generated from it, and so is the transition matrix.
"""
from dataclasses import dataclass
from datetime import date, timedelta
FINE_PER_DAY = 10
LOST_AFTER_DAYS = 60
LOST_FEE = 2500
MAX_RENEWALS = 2
RENEWAL_DAYS = 14
COMPOSITES = {"Active": ["OnLoan", "Overdue"]}
INITIAL = "OnLoan"
class IllegalTransition(Exception):
pass
@dataclass
class Loan:
due_on: date
reserved_by_other: bool = False # someone else is waiting for this title
renewals: int = 0
notices_sent: int = 0
fine_pence: int = 0
copy_freed: bool = False
closed_on: date | None = None
@dataclass(frozen=True)
class Transition:
source: str # a leaf state, or a composite (applies to all its substates)
event: str
target: str # a state, or 'Active.history'
guard: str = "" # shown on the diagram
action: str = "" # shown on the diagram
def leaves(state: str) -> list[str]:
return COMPOSITES.get(state, [state])
# --- guards, actions, entry and exit, by name, so the diagram text and the code cannot drift apart ---
def guard(name: str, loan: Loan, today: date | None) -> bool:
if name == "":
return True
if name == "today > due":
return today > loan.due_on
if name == "renewals < 2 and nobody waiting":
return loan.renewals < MAX_RENEWALS and not loan.reserved_by_other
if name == "today >= due + 60 days":
return today >= loan.due_on + timedelta(days=LOST_AFTER_DAYS)
raise KeyError(name)
def action(name: str, loan: Loan, today: date | None) -> None:
if name == "":
return
if name == "extend due date 14 days":
loan.due_on += timedelta(days=RENEWAL_DAYS)
loan.renewals += 1
elif name == "charge fine":
loan.fine_pence += max(0, (today - loan.due_on).days) * FINE_PER_DAY
elif name == "charge replacement fee":
loan.fine_pence += LOST_FEE
elif name == "remember closing date":
loan.closed_on = today
elif name == "push due date back by days closed":
loan.due_on += today - loan.closed_on
loan.closed_on = None
else:
raise KeyError(name)
ENTRY = {"Overdue": "send overdue notice", "Returned": "free the copy"}
EXIT = {"Suspended": "(none)"}
def do_entry(state: str, loan: Loan) -> None:
if state == "Overdue":
loan.notices_sent += 1
elif state == "Returned":
loan.copy_freed = True
TRANSITIONS = [
Transition("OnLoan", "day_passed", "Overdue", guard="today > due"),
Transition("OnLoan", "renew", "OnLoan", guard="renewals < 2 and nobody waiting", action="extend due date 14 days"),
Transition("Active", "return_book", "Returned", action="charge fine"),
Transition("Overdue", "declare_lost", "Lost", guard="today >= due + 60 days", action="charge replacement fee"),
Transition("Active", "close_library", "Suspended", action="remember closing date"),
Transition("Suspended", "reopen", "Active.history", action="push due date back by days closed"),
]
IGNORED = {("Overdue", "day_passed"), ("Suspended", "day_passed"), ("Returned", "day_passed"), ("Lost", "day_passed")} # a clock tick changes nothing here
STATES = ["OnLoan", "Overdue", "Suspended", "Returned", "Lost"]
EVENTS = ["renew", "day_passed", "return_book", "declare_lost", "close_library", "reopen"]
class LoanMachine:
def __init__(self, loan: Loan):
self.loan = loan
self.state = INITIAL
self._last_active = INITIAL # the history of the composite state Active
def send(self, event: str, today: date | None = None) -> str:
if (self.state, event) in IGNORED:
return self.state
candidates = [t for t in TRANSITIONS if t.event == event and self.state in leaves(t.source)]
if not candidates:
raise IllegalTransition(f"{event} is not allowed in state {self.state}")
for t in candidates:
if guard(t.guard, self.loan, today):
self._take(t, today)
return self.state
return self.state # guards all false: the event is discarded
def _take(self, t: Transition, today: date | None) -> None:
if self.state in COMPOSITES["Active"]:
self._last_active = self.state # remember where we were inside Active
action(t.action, self.loan, today)
self.state = self._last_active if t.target == "Active.history" else t.target
do_entry(self.state, self.loan)
def matrix() -> dict[tuple[str, str], str]:
"""For each state and event: what happens, with the probe arguments chosen so that each guard is satisfied."""
due = date(2026, 11, 1)
args = {"renew": None, "day_passed": due + timedelta(days=1), "return_book": due + timedelta(days=1), "declare_lost": due + timedelta(days=61),
"close_library": due + timedelta(days=1), "reopen": due + timedelta(days=2)}
out = {}
for s in STATES:
for e in EVENTS:
m = machine_in(s, due)
try:
out[(s, e)] = m.send(e, args[e]) if (s, e) not in IGNORED else "ignored"
except IllegalTransition:
out[(s, e)] = "illegal"
return out
def machine_in(state: str, due: date) -> LoanMachine:
"""Drive a fresh machine into a state by legal events."""
m = LoanMachine(Loan(due))
if state == "Overdue":
m.send("day_passed", due + timedelta(days=1))
elif state == "Suspended":
m.send("close_library", due - timedelta(days=5))
elif state == "Returned":
m.send("return_book", due)
elif state == "Lost":
m.send("day_passed", due + timedelta(days=1))
m.send("declare_lost", due + timedelta(days=61))
return m
def to_mermaid() -> str:
"""A flat Mermaid state diagram (no composite, no history: see the chapter for why)."""
lines = ["stateDiagram-v2", f" [*] --> {INITIAL}"]
for t in TRANSITIONS:
label = t.event + (f" [{t.guard}]" if t.guard else "") + (f" / {t.action}" if t.action else "")
target = "OnLoan" if t.target == "Active.history" else t.target
for src in leaves(t.source):
note = " (to the state it left)" if t.target == "Active.history" else ""
lines.append(f" {src} --> {target} : {label}{note}")
lines += [" Returned --> [*]", " Lost --> [*]"]
return "\n".join(lines) + "\n"
def to_plantuml() -> str:
lines = ["@startuml", "scale 1.3", "hide empty description", "[*] --> Active", "state Active {", f" [*] --> {INITIAL}"]
for s in COMPOSITES["Active"]:
lines.append(f" state {s}")
if s in ENTRY:
lines.append(f" {s} : entry / {ENTRY[s]}")
lines.append("}")
for s, text in ENTRY.items():
if s not in COMPOSITES["Active"]:
lines.append(f"{s} : entry / {text}")
for t in TRANSITIONS:
label = t.event + (f" [{t.guard}]" if t.guard else "") + (f" / {t.action}" if t.action else "")
target = "Active[H]" if t.target == "Active.history" else t.target
src = t.source
lines.append(f"{src} --> {target} : {label}")
lines += ["Returned --> [*]", "Lost --> [*]", "@enduml"]
return "\n".join(lines) + "\n"
Four decisions in that code are decisions you must make for any state machine in code, because the notation does not make them for you:
An event that fits no transition in the current state. UML's rule, as I remember it, is that such an event is simply discarded. In code that hides bugs, so I made it an error (IllegalTransition) unless I list it as deliberately ignored (the clock tick in four states).
A guard that is false. The event is discarded: renew after two renewals changes nothing, quietly. (Whether that should instead tell the member is a business question; the machine reports the same state.)
Where history is remembered._last_active records which substate of Active we left. It is the code for the H.
What entry means when you come back through history. Re-entering Overdue runs its entry action again, so a member whose library closed for two days gets a second overdue notice when it reopens. A test pins that behaviour down, so it is a visible decision rather than an accident (see below). Whether it is desirable is for the librarian.
Every State, Every Event: The Matrix
A diagram shows the transitions that exist. A transition matrix shows what happens for every state and event, including the ones that do not exist. It is computed by driving a fresh machine into each state and sending each event (with arguments chosen so each guard is satisfied):
from the transition table
renew day_passed return_book declare_lost close_library reopen
OnLoan OnLoan Overdue Returned illegal Suspended illegal
Overdue illegal ignored Returned Lost Suspended illegal
Suspended illegal ignored illegal illegal illegal OnLoan
Returned illegal ignored illegal illegal illegal illegal
Lost illegal ignored illegal illegal illegal illegal
the State-pattern implementation, driven into every state: identical to the table
Read the empty-looking cells as design: illegal everywhere in Returned and Lost except the ignored clock tick (a finished loan accepts nothing else), declare_lost only from Overdue, reopen only from Suspended, renew only from OnLoan. Two caveats: the guards are all assumed true in this table (a guard that is false in a given case discards the event, as the tests below show), and the reopen cell says OnLoan because the matrix reached Suspended from OnLoan; reached from Overdue it returns to Overdue.
The Same Loan With the State Pattern
Design Patterns 8 teaches the State pattern; here is the same lifecycle written that way, as one class per state with each handling the events it allows, so you can compare. The behaviour is checked by the same tests, below.
"""The same loan lifecycle written with the State pattern: one class per state, each handling the events it allows.
Same public behaviour as loan_sm.LoanMachine (the shared tests and the matrix check prove it)."""
from datetime import date, timedelta
from loan_sm import FINE_PER_DAY, LOST_AFTER_DAYS, LOST_FEE, MAX_RENEWALS, RENEWAL_DAYS, IllegalTransition, Loan
class State:
name = "?"
def renew(self, m, today=None):
raise IllegalTransition(f"renew is not allowed in state {self.name}")
def day_passed(self, m, today=None):
raise IllegalTransition(f"day_passed is not allowed in state {self.name}")
def return_book(self, m, today=None):
raise IllegalTransition(f"return_book is not allowed in state {self.name}")
def declare_lost(self, m, today=None):
raise IllegalTransition(f"declare_lost is not allowed in state {self.name}")
def close_library(self, m, today=None):
raise IllegalTransition(f"close_library is not allowed in state {self.name}")
def reopen(self, m, today=None):
raise IllegalTransition(f"reopen is not allowed in state {self.name}")
def enter(self, m):
pass
class Active(State):
"""Behaviour shared by OnLoan and Overdue: the composite state."""
def return_book(self, m, today=None):
m.loan.fine_pence += max(0, (today - m.loan.due_on).days) * FINE_PER_DAY
m.go(Returned())
def close_library(self, m, today=None):
m.last_active = self
m.loan.closed_on = today
m.go(Suspended())
class OnLoan(Active):
name = "OnLoan"
def renew(self, m, today=None):
if m.loan.renewals < MAX_RENEWALS and not m.loan.reserved_by_other:
m.loan.due_on += timedelta(days=RENEWAL_DAYS)
m.loan.renewals += 1
def day_passed(self, m, today=None):
if today > m.loan.due_on:
m.go(Overdue())
class Overdue(Active):
name = "Overdue"
def enter(self, m):
m.loan.notices_sent += 1
def day_passed(self, m, today=None):
pass # still overdue: nothing changes
def declare_lost(self, m, today=None):
if today >= m.loan.due_on + timedelta(days=LOST_AFTER_DAYS):
m.loan.fine_pence += LOST_FEE
m.go(Lost())
class Suspended(State):
name = "Suspended"
def day_passed(self, m, today=None):
pass
def reopen(self, m, today=None):
m.loan.due_on += today - m.loan.closed_on
m.loan.closed_on = None
m.go(m.last_active) # history: back to the state it left
class Returned(State):
name = "Returned"
def day_passed(self, m, today=None):
pass
def enter(self, m):
m.loan.copy_freed = True
class Lost(State):
name = "Lost"
def day_passed(self, m, today=None):
pass
class LoanStateMachine:
def __init__(self, loan: Loan):
self.loan = loan
self.current = OnLoan()
self.last_active = self.current
@property
def state(self) -> str:
return self.current.name
def go(self, new: State) -> None:
self.current = new
new.enter(self)
def send(self, event: str, today: date | None = None) -> str:
getattr(self.current, event)(self, today)
return self.state
Transition table
State pattern (one class per state)
Diagram to code
Direct: the rows are the arrows, and the diagram and matrix are generated from them
Indirect: the arrows are spread across methods, and there is no generated diagram
Behaviour that differs a lot by state
Awkward: actions are named strings dispatched by if
Natural: each state class holds its own code, and can have its own data
Adding a state
New rows, plus any new action code
A new class, and a new method wherever the other states must handle its events
Forgetting a case
Visible in the matrix, and unhandled events raise an error
The base class raises an error for anything not overridden; the ignored events must be remembered in each class
Fits when
Many states and events, little behaviour per state; the diagram matters
A few states with a lot of behaviour each
Neither is "the" answer. The table suits this loan, where the transitions carry the interest. I have not benchmarked either; the comparison is of what the code is like to change.
One Test Suite, Two Implementations, and What It Caught
28 tests (14 behaviours run against each implementation) cover every transition, the guards, the ignored events, the history cases, and the illegal ones. The first run did not pass:
ERROR: test_becoming_overdue_sends_exactly_one_notice (test_loan_states.TableDriven.test_becoming_overdue_sends_exactly_one_notice)
raise IllegalTransition(f"{event} is not allowed in state {self.state}")
loan_sm.IllegalTransition: day_passed is not allowed in state Overdue
Ran 28 tests in 0.002s
FAILED (errors=1)
the State-pattern implementation, driven into every state: DIFFERENCES
in Overdue, event day_passed: table says 'illegal', pattern implementation says 'Overdue'
What went wrong: in the table I listed the daily clock tick as ignored in Suspended, Returned and Lost, and forgot Overdue. A nightly job that sends day_passed to every loan would have crashed on the first overdue loan it met. The State-pattern version, where I had written the no-op in the Overdue class, passed the same test. Two independent checks found the same hole: the test, and the matrix comparison (the pattern implementation was driven into every state and compared to the table, in matrix_check.py). I added the row, and the whole suite and the matrix comparison now agree:
Ran 28 tests in 0.001s
OK
"""One suite, run against both implementations of the loan lifecycle."""
import unittest
from datetime import date, timedelta
import loan_sm
import loan_state_pattern
from loan_sm import IllegalTransition, Loan
DUE = date(2026, 11, 1)
class Behaviour:
make = None
def machine(self, **kw):
return self.make(Loan(DUE, **kw))
def test_on_time_return(self):
m = self.machine()
self.assertEqual(m.send("return_book", DUE), "Returned")
self.assertEqual((m.loan.fine_pence, m.loan.copy_freed), (0, True))
def test_late_return_charges_ten_pence_a_day(self):
m = self.machine()
m.send("day_passed", DUE + timedelta(days=1))
self.assertEqual(m.send("return_book", DUE + timedelta(days=3)), "Returned")
self.assertEqual(m.loan.fine_pence, 30)
def test_day_passed_before_or_on_the_due_date_changes_nothing(self):
m = self.machine()
self.assertEqual(m.send("day_passed", DUE - timedelta(days=1)), "OnLoan")
self.assertEqual(m.send("day_passed", DUE), "OnLoan")
def test_becoming_overdue_sends_exactly_one_notice(self):
m = self.machine()
self.assertEqual(m.send("day_passed", DUE + timedelta(days=1)), "Overdue")
m.send("day_passed", DUE + timedelta(days=2))
m.send("day_passed", DUE + timedelta(days=3))
self.assertEqual(m.loan.notices_sent, 1)
def test_renew_extends_by_fourteen_days_twice_and_then_is_refused_quietly(self):
m = self.machine()
m.send("renew")
m.send("renew")
self.assertEqual((m.loan.due_on, m.loan.renewals), (DUE + timedelta(days=28), 2))
self.assertEqual(m.send("renew"), "OnLoan")
self.assertEqual(m.loan.renewals, 2) # guard false: the event is discarded
def test_renew_is_refused_when_someone_is_waiting(self):
m = self.machine(reserved_by_other=True)
m.send("renew")
self.assertEqual((m.loan.due_on, m.loan.renewals), (DUE, 0))
def test_renew_is_illegal_once_overdue(self):
m = self.machine()
m.send("day_passed", DUE + timedelta(days=1))
with self.assertRaises(IllegalTransition):
m.send("renew")
def test_declare_lost_needs_sixty_days_overdue(self):
m = self.machine()
m.send("day_passed", DUE + timedelta(days=1))
self.assertEqual(m.send("declare_lost", DUE + timedelta(days=59)), "Overdue")
self.assertEqual(m.send("declare_lost", DUE + timedelta(days=60)), "Lost")
self.assertEqual(m.loan.fine_pence, 2500)
def test_declare_lost_is_illegal_while_on_loan(self):
with self.assertRaises(IllegalTransition):
self.machine().send("declare_lost", DUE + timedelta(days=100))
def test_closure_while_on_loan_resumes_on_loan_with_the_due_date_pushed_back(self):
m = self.machine()
m.send("close_library", DUE - timedelta(days=3))
self.assertEqual(m.state, "Suspended")
self.assertEqual(m.send("day_passed", DUE + timedelta(days=5)), "Suspended") # the clock is ignored while closed
self.assertEqual(m.send("reopen", DUE - timedelta(days=3) + timedelta(days=8)), "OnLoan")
self.assertEqual(m.loan.due_on, DUE + timedelta(days=8))
def test_closure_while_overdue_resumes_overdue(self):
m = self.machine()
m.send("day_passed", DUE + timedelta(days=1))
m.send("close_library", DUE + timedelta(days=2))
self.assertEqual(m.send("reopen", DUE + timedelta(days=4)), "Overdue")
def test_history_reruns_the_entry_action(self):
"""A consequence to decide about: coming back to Overdue through history enters Overdue again, so a second notice goes out."""
m = self.machine()
m.send("day_passed", DUE + timedelta(days=1))
m.send("close_library", DUE + timedelta(days=2))
m.send("reopen", DUE + timedelta(days=4))
self.assertEqual(m.loan.notices_sent, 2)
def test_cannot_return_while_the_library_is_closed(self):
m = self.machine()
m.send("close_library", DUE - timedelta(days=1))
with self.assertRaises(IllegalTransition):
m.send("return_book", DUE)
def test_terminal_states_accept_nothing_but_clock_ticks(self):
m = self.machine()
m.send("return_book", DUE)
for event in ("renew", "return_book", "declare_lost", "close_library", "reopen"):
with self.assertRaises(IllegalTransition):
m.send(event, DUE)
self.assertEqual(m.send("day_passed", DUE + timedelta(days=9)), "Returned")
class TableDriven(Behaviour, unittest.TestCase):
make = staticmethod(loan_sm.LoanMachine)
class StatePattern(Behaviour, unittest.TestCase):
make = staticmethod(loan_state_pattern.LoanStateMachine)
if __name__ == "__main__":
unittest.main()
"""The transition matrix: for every state and event, what happens. Computed from the table-driven machine, and, independently, by
driving the State-pattern implementation into each state and sending it each event. If they disagree, one of them is wrong."""
from datetime import date, timedelta
import loan_sm
import loan_state_pattern as sp
from loan_sm import EVENTS, STATES, IllegalTransition, Loan
DUE = date(2026, 11, 1)
ARGS = {"renew": None, "day_passed": DUE + timedelta(days=1), "return_book": DUE + timedelta(days=1), "declare_lost": DUE + timedelta(days=61),
"close_library": DUE + timedelta(days=1), "reopen": DUE + timedelta(days=2)}
def pattern_machine_in(state: str) -> sp.LoanStateMachine:
m = sp.LoanStateMachine(Loan(DUE))
if state == "Overdue":
m.send("day_passed", DUE + timedelta(days=1))
elif state == "Suspended":
m.send("close_library", DUE - timedelta(days=5))
elif state == "Returned":
m.send("return_book", DUE)
elif state == "Lost":
m.send("day_passed", DUE + timedelta(days=1))
m.send("declare_lost", DUE + timedelta(days=61))
return m
def pattern_matrix() -> dict:
out = {}
for s in STATES:
for e in EVENTS:
m = pattern_machine_in(s)
try:
before = m.state
after = m.send(e, ARGS[e])
out[(s, e)] = after
except IllegalTransition:
out[(s, e)] = "illegal"
return out
def show(title: str, matrix: dict) -> None:
print(title)
print(f" {'':<11}" + "".join(f"{e:<15}" for e in EVENTS))
for s in STATES:
print(f" {s:<11}" + "".join(f"{matrix[(s, e)]:<15}" for e in EVENTS))
if __name__ == "__main__":
table = loan_sm.matrix()
show("from the transition table", table)
pat = pattern_matrix()
print()
# the table version reports an event it deliberately ignores as 'ignored'; the pattern version just stays in the same state
normal = {k: (k[0] if v == "ignored" else v) for k, v in table.items()}
differences = {k: (normal[k], pat[k]) for k in normal if normal[k] != pat[k]}
print("the State-pattern implementation, driven into every state:", "identical to the table" if not differences else "DIFFERENCES")
for (s, e), (a, b) in sorted(differences.items()):
print(f" in {s}, event {e}: table says {a!r}, pattern implementation says {b!r}")
A matrix shows what each implementation does and the tests say whether that is intended; you need both. Note the history test (test_history_reruns_the_entry_action): it documents the second overdue notice rather than judging it.
What Mermaid Does With the Same Diagram
Chapter 2 said the course would use Mermaid for state diagrams unless composite states or history needed more. I tested each feature in Mermaid 12.1.0, looking at the rendered picture rather than only the exit status, because the exit status was misleading. (I inspected the pictures for the history and entry/exit cases; for the others I confirmed they rendered without error and did not check every detail.)
Feature
Result in Mermaid 12.1.0
Composite state (state Active { ... })
Works
Choice, fork and join pseudo-states
Work (<<choice>>, <<fork>>, <<join>>)
Notes
Work
Guards and actions as part of the arrow label
Work (they are only text)
History state [H]
No error, wrong result: drawn as an ordinary state literally named "[H]"
Entry and exit actions written as Overdue : entry / send notice
No error, wrong result: the description line replaced the state's name, so the box showed "entry / send notice" and "exit / stop notices" and no longer said "Overdue"
Workaround for entry and exit
state "Overdue<br/>entry / send notice<br/>exit / stop notices" as Overdue draws a box with the name and the lines
So Mermaid can draw a flat state diagram well, and the same table generates one (below, for comparison), but not history, and entry/exit actions only by a text workaround. For this loan, PlantUML is the right tool. The rule to take away is wider than the tool: a diagram tool that accepts your syntax without complaint may still be drawing something else; look at the picture.
Mermaid, flat, generated from the same table. The composite state is gone (return_book and close_library each appear twice) and the history is only a note on the arrow back.
Activity Diagrams
A state diagram follows one object. An activity diagram follows a process: a sequence of actions with decisions, parallel branches and the people or systems responsible. It is the diagram for a workflow.
Element
Notation
Meaning
Action
Rounded box
One step
Start, end
Filled dot; circled dot
Where the process begins and where a path ends
Decision, merge
Diamond
A choice with guards on the outgoing arrows; a merge brings paths together again
Fork, join
Thick bar
Split into parallel paths; wait for all of them before continuing
Swimlane (partition)
Vertical column per actor
Who is responsible for each action
When an Activity Diagram Beats a Flowchart
A flowchart describes an algorithm: the steps one program follows. An activity diagram earns its place when the process involves several actors (swimlanes) or things happening at once (fork and join). If there is one actor and no concurrency, a flowchart (or just a use case description) is simpler and no worse.
The reservation workflow, in two diagrams. First, a member asks to reserve a title:
PlantUML. Two lanes. Two nested decisions, and the paths merge before the member receives the answer.
@startuml
scale 1.3
|Member|
start
:Ask to reserve a title;
|System|
if (member eligible?) then (no)
:Refuse and give the reason;
stop
else (yes)
if (a copy is on the shelf?) then (yes)
:Hold that copy for the member;
:Tell the member the pick-up date;
else (no)
:Add the member to the title's queue;
:Tell the member their position;
endif
endif
|Member|
:Receive the answer;
stop
@enduml
Second, a reserved title's copy comes back (this is where parallel work and three actors matter):
PlantUML. When someone is waiting, the system notifies them and the librarian puts the copy on the hold shelf: neither has to come first, and the process waits for both (the join) before starting the three-day hold. If the member does not collect, the hold is released and the copy is treated as returned again, so the next member in the queue gets a turn.
@startuml
scale 1050 width
|Librarian|
start
:Scan the returned copy;
|System|
if (someone is waiting for this title?) then (no)
|System|
:Mark the copy available;
|Librarian|
:Put the copy on the shelf;
stop
else (yes)
|System|
:Take the first member from the queue;
fork
|System|
:Notify the member;
fork again
|Librarian|
:Put the copy on the hold shelf;
end fork
|System|
:Start the 3-day hold;
|Member|
if (collects within 3 days?) then (yes)
:Borrow the copy;
stop
else (no)
|System|
:Release the hold;
:Treat the copy as returned again;
stop
endif
endif
@enduml
The Workflow as Running Code
I implemented both activities (reservations.py, with 10 tests) so that the diagram's decisions are checked, not just drawn. The story below follows the diagram: two members queue for the only copy of Dune, it comes back, the first does not collect in three days, the hold expires, the second collects. The log is the real output:
"""Reservation handling: the workflow the activity diagrams describe, as running code with a log of each step."""
from dataclasses import dataclass, field
from datetime import date, timedelta
HOLD_DAYS = 3
class Refusal(Exception):
pass
@dataclass
class Hold:
member: str
expires_on: date
@dataclass
class Reservations:
eligible: set # cards allowed to reserve
queue: dict = field(default_factory=dict) # title -> [cards waiting, oldest first]
holds: dict = field(default_factory=dict) # copy barcode -> Hold
shelf: set = field(default_factory=set) # barcodes of copies freely available
log: list = field(default_factory=list)
def say(self, text: str) -> None:
self.log.append(text)
# --- activity 1: a member asks to reserve a title ---
def place(self, card: str, title: str, copy_on_shelf: str | None, today: date) -> str:
if card not in self.eligible: # decision: member eligible?
self.say(f"{card} refused: not eligible")
raise Refusal("not eligible")
if card in self.queue.get(title, []):
raise Refusal("already queued for this title")
if copy_on_shelf is not None: # decision: a copy on the shelf?
self.shelf.discard(copy_on_shelf)
self.holds[copy_on_shelf] = Hold(card, today + timedelta(days=HOLD_DAYS))
self.say(f"{card}: copy {copy_on_shelf} held until {self.holds[copy_on_shelf].expires_on}")
return "held"
self.queue.setdefault(title, []).append(card)
self.say(f"{card}: queued for {title} at position {len(self.queue[title])}")
return "queued"
# --- activity 2: a copy comes back ---
def copy_returned(self, barcode: str, title: str, today: date) -> str:
waiting = self.queue.get(title, [])
if not waiting:
self.shelf.add(barcode)
self.say(f"copy {barcode} returned: nobody waiting, back on the shelf")
return "shelved"
card = waiting.pop(0)
self.holds[barcode] = Hold(card, today + timedelta(days=HOLD_DAYS))
# the two actions of the fork: they have no order between them
self.say(f" (fork) notify {card}")
self.say(f" (fork) put copy {barcode} on the hold shelf")
self.say(f" (join) {card} has until {self.holds[barcode].expires_on}")
return "held"
def collect(self, card: str, barcode: str, today: date) -> None:
hold = self.holds.get(barcode)
if hold is None or hold.member != card:
raise Refusal("no hold for this member on this copy")
if today > hold.expires_on:
raise Refusal("the hold has expired")
del self.holds[barcode]
self.say(f"{card} collected copy {barcode}: it becomes a loan")
def day_passed(self, today: date, titles: dict) -> None:
"""titles maps a barcode to its title, so an expired hold can pass the copy to the next member."""
for barcode, hold in list(self.holds.items()):
if today > hold.expires_on:
del self.holds[barcode]
self.say(f"hold on {barcode} for {hold.member} expired")
self.copy_returned(barcode, titles[barcode], today)
ada: queued for Dune at position 1
bo: queued for Dune at position 2
(fork) notify ada
(fork) put copy B1 on the hold shelf
(join) ada has until 2026-11-08
hold on B1 for ada expired
(fork) notify bo
(fork) put copy B1 on the hold shelf
(join) bo has until 2026-11-12
bo collected copy B1: it becomes a loan
Ran 10 tests in 0.000s
OK
The two (fork) lines are the parallel branches: the code does them in one order, but the diagram says the order does not matter, and that is a statement about the process, not about the program. Look also at what the code needed that the diagram did not say: whether the last day of the hold is still valid (a test pins "yes"), and what an expired hold does when nobody is waiting (the copy goes back to the shelf).
State Machine or Activity Diagram?
If your question is...
Draw
"What can happen to this one thing, and what is it allowed to do in each condition?"
State machine
"What are the steps of this process, who does each, and what happens in parallel?"
Activity diagram
"What is the order of calls for this one scenario?"
Sequence diagram (chapter 7)
"What can this system do for whom?"
Use cases (chapter 3)
They connect: each box in the reservation activity ("Hold that copy", "Release the hold") is a change of state in some object's state machine (a copy becomes Held; a hold ends). An activity says when in the process, the state machine says what that object is permitted to do next.
Usual Mistakes
Mistake
Fix
A state that is really an action ("Calculating fine")
States are conditions an object stays in; actions happen on transitions
A boolean flag instead of a state, and later a second flag, and a third
If flags combine into impossible combinations (overdue and returned), you have a state machine hiding
Unhandled events silently discarded in code
Choose per state: illegal (raise) or deliberately ignored (list it)
Drawing only the happy path
Draw the loss, the cancellation, the timeout; they are where bugs live
A state with no way out that is not a final state
Run a reachability check (Exercise 2)
Activity diagram as a program flowchart
Use lanes and concurrency, or use a flowchart and say so
Everything in one huge diagram
One object's life, or one process, per diagram
Mistakes while preparing this chapter
"It rendered" proved nothing. Every Mermaid probe returned success, including the two that drew the wrong thing. If I had checked exit codes, I would have written that Mermaid supports history and entry actions. Takeaway: for diagram syntax, the check is the picture.
I forgot an event in my own table. The clock tick was missing for Overdue (above). I had it right in the State-pattern version and wrong in the table. Takeaway: write the matrix for every state machine; a hole you cannot see in the diagram is visible in the matrix.
My first exercise claimed one check would find two mistakes. I wrote the "find the holes" exercise (Exercise 2) for a copy lifecycle with two missing rows, and said a reachability check finds both. It finds only one (a copy in repair can never come back). The other (a held copy has no way out if nobody collects it) is not a reachability problem, because the copy can still leave Held by being collected; it needs a different kind of check, a rule about states that wait for a person. I rewrote the exercise around the two checks. Takeaway: run the analysis before describing what it finds.
PlantUML swimlanes carried over. In my first returned-copy diagram I changed lanes in one branch of a decision, and the other branch started in the same lane, putting the system's "take the first member" step in the librarian's column. I only saw it in the picture. The fix is to name the lane at the start of every branch. The diagram was also too wide, so it now sets a width.
Leftover junk in a demonstration script. The reservation story script briefly contained a line ending in if False else None, a leftover from an idea I dropped. It did nothing, which is exactly why it nearly stayed. I deleted it.
Hands-On Exercises
Exercise 1
A reservation passes through Queued, Held, Collected, Expired and Cancelled. A copy becoming available moves Queued to Held (with a three-day deadline); collecting in time ends in Collected; a day passing after the deadline moves Held to Expired; cancelling is allowed from Queued and Held. Write it as a transition table, generate the transition matrix, write tests (including collecting one day late and cancelling twice), and generate the Mermaid diagram.
The solution file has the state table for a physical copy of a book with two missing rows. Write checks that find them (hint: they are different kinds of mistake), show the output before and after adding the rows, and say why one check cannot find both.
Draw "join the library" as an activity diagram with three swimlanes (Applicant, Librarian, System): the applicant fills in the form and shows proof of address; the librarian checks it; if it is not acceptable the applicant is told what is missing; if it is, the system creates the record, and in parallel the system records the card number and the librarian prints the card; then the card is handed over and signed for.
State machine = one object's life: states, events, transitions with [guard] and / action, entry and exit actions, initial and final, composite states (shared transitions), history (return to the substate you left)
In code, decide: an unhandled event is illegal or deliberately ignored; a false guard discards the event; where history lives; whether entry runs again on return through history (it did here: a second notice)
A transition table makes the diagram and the matrix generate from the code; the State pattern fits when each state has a lot of behaviour; neither is the answer for all cases
Write the matrix (every state by every event) and the tests, and run reachability checks, plus rules for states that wait for a person: a diagram hides what is missing
Mermaid 12.1.0: composite, choice, fork, notes work; history [H] and State : entry / ... are accepted and drawn wrongly; use PlantUML for those
Activity diagram = a process: actions, decisions and merges, fork and join, swimlanes; beats a flowchart when there are several actors or parallel work
State machine for what one thing may do; activity for the steps of a process; sequence for the order of calls; use cases for goals
Coming next
Object-Oriented Design with UML 9 (Components, Packages and Deployment): component diagrams with provided and required interfaces, package diagrams and dependency direction, deployment diagrams and object diagrams; how they relate to the C4 model in Software Architecture Fundamentals 9; keeping dependencies acyclic, and the library's components and deployment.