Capstone

Object-Oriented Design with UML

Course 1 · Chapter 12 · Capstone: Designing a System from Requirements

Eleven chapters taught the pieces on one running example, the library. This chapter uses them on a different system, so that what you practise is the method and not the example: a booking service for a community centre's workshops. The order is the order of the course. Requirements, then use cases, a domain model, a class design, the key behaviours (a sequence diagram and a state machine), a component view, then code that runs, tests and checks that tie the diagrams to the code, and a short design document that says what was decided and what was not.

What was run, and what was not
Everything here was run on this machine (Python 3.14, PlantUML 1.2026.8, Mermaid 12.1.0): the service (11 files in three layers), 23 passing tests, three deliberately broken versions of it that the tests catch, a story that exercises every use case, a check that the class and sequence diagrams match the code (and fail on tampered copies), and the dependency rules from chapter 9. Not built: a user interface, a real card service, real e-mail, persistence, and anything beyond one process. The design document lists them as gaps. The system is invented: the centre, its refund rule and its questions are mine, labelled as assumptions where I filled a gap.

Step 1: Requirements, as Words, With the Questions They Raise

The input is what the centre's manager said, written down as she said it. Underneath, the questions the words do not answer. Each unanswered question is either asked or assumed in writing, so the assumption can be challenged later.

WORKSHOP BOOKING SERVICE: REQUIREMENTS (as the community centre's manager described them) The centre runs workshops (pottery, coding for beginners, bookbinding). Each workshop is held on one or more sessions on particular dates. R1 People can see the upcoming sessions and how many seats are left. R2 A person books a seat on a session and pays the session's fee by card at the time of booking. Each session has a limited number of seats. R3 If a session is full, the person can join its waiting list instead. They are not charged. R4 A person can cancel their booking. If the session is at least a week away, they get all their money back; otherwise they get nothing. R5 When a seat becomes free and people are waiting, the person who has waited longest is offered it. They have a day to take it and pay. R6 The organiser can cancel a whole session. Everybody who paid gets everything back, however close the date. R7 People should be told by e-mail when their booking is confirmed, when they are put on a waiting list, when they are offered a seat, and when a refund is made. Not stated, so asked (open questions for the manager; assumptions made so that work could proceed are marked ASSUMED): Q1 Can one person book several seats for friends? ASSUMED: no, one seat per person per session Q2 If the card is declined, is the person put on the waiting list? ASSUMED: no, the booking simply fails and nothing is held Q3 What if the organiser changes a session's date? NOT HANDLED: out of scope for this version Q4 Is the one-week refund rule measured from the booking date or the session date? ASSUMED: the session date Q5 Do people on the waiting list pay when they join? ASSUMED: no; they pay only when a seat is offered Q6 Does the waiting-list offer go to the next person at once if the first does not take it? ASSUMED: yes, when the offer lapses

Note how much of the work is already in the questions. "A week away" (Q4), what happens to someone whose card is declined (Q2) and what a waiting person pays and when (Q5) all change the design, and none was in the manager's words.

Step 2: Use Cases

Actors first (chapter 3): the attendee, the organiser, and three things that are not people: the card service, the e-mail service, and a nightly timer that expires offers. The scope boundary is the service itself.

Workshop booking serviceBrowse sessionsBook a seatJoin the waiting listCancel a bookingAccept an offered seatCancel a sessionExpire unclaimed offersAttendeeOrganiserCard PaymentService«system»NightlyTimer«time»E-mailService«system»«extend»

PlantUML. "Join the waiting list" extends "Book a seat" (the arrow points at the base, as chapter 3 said it must). This diagram is already at the edge of readable: the lines to the two outside services cross. A list of use cases would carry the same facts.

@startuml scale 1.3 left to right direction actor Attendee actor Organiser actor "Card Payment\nService" as Pay <<system>> actor "Nightly\nTimer" as Timer <<time>> actor "E-mail\nService" as Mail <<system>> rectangle "Workshop booking service" { usecase "Browse sessions" as Browse usecase "Book a seat" as Book usecase "Join the waiting list" as Wait usecase "Cancel a booking" as Cancel usecase "Accept an offered seat" as Accept usecase "Cancel a session" as CancelSession usecase "Expire unclaimed offers" as Expire } Attendee --> Browse Attendee --> Book Attendee --> Cancel Attendee --> Accept Organiser --> CancelSession Timer --> Expire Book --> Pay Accept --> Pay Cancel --> Pay CancelSession --> Pay Wait ..> Book : <<extend>> Book --> Mail Cancel --> Mail @enduml

The descriptions carry the substance. Two are written in full. Look at the guarantees and at the extensions: every "what if" from the requirements ended up as a numbered case.

USE CASE: Book a Seat Scope: Workshop booking service (black box) Goal level: User goal Primary actor: Attendee Supporting actors: Card Payment Service, E-mail Service Stakeholders: Attendee (wants a seat, charged once, told clearly) Organiser (wants full sessions and no double-charged or double-booked people) Precondition: The session exists and has not started or been cancelled. Trigger: The Attendee asks to book a seat on a session. Success guarantee: The Attendee holds a confirmed seat and has paid the fee once; an e-mail confirmation is sent. Minimal guarantee: Nobody is charged for a booking that did not succeed, and no seat is held without a payment or an open offer. MAIN SUCCESS SCENARIO 1. The Attendee chooses a session and asks to book. 2. The system checks the session is open and the Attendee has no live booking for it. 3. The system checks a seat is free and holds it for the Attendee. 4. The system charges the fee to the Attendee's card through the Card Payment Service. 5. The system confirms the booking. 6. The system tells the Attendee, by e-mail, that the booking is confirmed. EXTENSIONS 2a. The session has been cancelled, has started, or the Attendee already has a live booking for it: 2a1. The system refuses, saying why. Use case ends. 3a. No seat is free: 3a1. The system puts the Attendee on the waiting list (no charge) and e-mails them. Use case ends. (Join the waiting list) 4a. The payment is declined: 4a1. The system gives up the held seat and records the booking as declined. Nothing is charged. Use case ends. 6a. The e-mail cannot be sent: 6a1. The booking stands; the failure is recorded. Use case ends. OPEN QUESTIONS: see requirements Q1, Q2, Q5.
USE CASE: Cancel a Booking Scope: Workshop booking service (black box) Goal level: User goal Primary actor: Attendee Supporting actors: Card Payment Service, E-mail Service Stakeholders: Attendee (wants as much money back as the rules allow, and no surprises) Organiser (wants a freed seat filled) Precondition: The Attendee has a booking that is Confirmed, Waitlisted or Offered. Trigger: The Attendee asks to cancel the booking. Success guarantee: The booking is cancelled, any refund due is paid, and a freed seat is offered to the longest-waiting person. Minimal guarantee: If the refund cannot be paid, the Attendee is told and the failure is recorded, not lost. MAIN SUCCESS SCENARIO 1. The Attendee asks to cancel a booking. 2. The system finds the booking and its session. 3. The system works out the refund: the full fee if the session is at least 7 days away, otherwise nothing. 4. The system cancels the booking. 5. The system asks the Card Payment Service to refund the amount. 6. The system offers the freed seat to the person who has waited longest, if anyone is waiting. 7. The system tells the Attendee, by e-mail, about the cancellation and the refund. EXTENSIONS 2a. The booking is already cancelled, expired or declined: 2a1. The system refuses: there is nothing to cancel. Use case ends. 3a. The booking was only on the waiting list, or an unpaid offer: 3a1. The refund is zero; continue at step 4. 5a. The refund amount is zero: 5a1. The system does not contact the payment service. Continue at step 6. 5b. The payment service fails to refund: 5b1. NOT YET DESIGNED: see the design document's open decisions.

Writing extension 5b of the second use case ("the payment service fails to refund") produced the first real finding. I did not know what the service should do, so the description says NOT YET DESIGNED instead of pretending. It comes back below: the code, written without an answer, does something harmful there.

Step 3: The Domain Model and a Glossary

The concepts and their links, as in chapter 4: written once as data, producing the diagram and the sentences to read aloud to the manager. The glossary fixes the words, including one it forbids.

is held as

1

0..*

takes

1

0..*

makes

1

0..*

Workshop

title

Session

startsOn

capacity

feePence

cancelled

Attendee

name

email

Booking

state

paidPence

offerDeadline

Mermaid, generated from domain_model.py. Four concepts, three associations. The Booking has a state, which Step 5 expands.

Each Workshop is held as zero or more Sessions. ...and each Session is a session of exactly one Workshop. Each Session takes zero or more Bookings. ...and each Booking is for exactly one Session. Each Attendee makes zero or more Bookings. ...and each Booking is made by exactly one Attendee.
GLOSSARY: Workshop booking service (draft 2026-10-10: to be confirmed with the centre's manager) Workshop A subject the centre teaches ("Pottery for beginners"). It is held as one or more Sessions. Not a thing you book. Session One occasion on which a Workshop is held: a date, a limited number of seats, a fee. This is what an Attendee books. Attendee A person who books, joins a waiting list for, or is offered a seat. Identified by an id; has a name and an e-mail address. Booking An Attendee's claim on a Session, from the moment they ask until it is confirmed, cancelled, declined or expired. It has a state (see the state diagram). "Reservation" is NOT used: it means a booking that has not been paid for, and we have none. Seat One unit of a Session's capacity. A Booking holds a seat while it is Requested, Offered or Confirmed. Waiting list The Bookings of a full Session that are Waitlisted, in the order they were made. Offer A freed Seat held for the Waitlisted Booking that has waited longest, for one day. The Booking is then Offered. Refund Money returned to an Attendee: the full fee when a Booking is cancelled at least 7 days before the Session, and the full fee when the Organiser cancels the Session; otherwise none. Organiser A role played by centre staff. An actor in the use cases; not part of the domain model.

The glossary's note on "Reservation" is chapter 4's lesson applied: in the library a "reservation" was a thing in its own right; here the word would mean "a booking that has not been paid for", which this system never has, so it is banned rather than allowed to mean two things.

Step 4: Class Design and Responsibilities

Using chapter 6's questions (who has the information? who creates? who coordinates?): the Session holds the capacity and the bookings, so it decides who gets a seat and creates the bookings; the Booking knows its own state; a BookingDesk coordinates a use case across the session, the payment service and the notifications and holds no rule. Everything outside is behind an interface that the application declares (chapter 9), and the refund rule is a Strategy (chapter 11).

1

0..*

coordinates

1

0..*

uses

BookingDesk

+book(attendee_id, session_id, today) : Booking

+accept_offer(booking_id, today) : Booking

+cancel(booking_id, today) : int

+cancel_session(session_id, today) : int

+day_passed(today)

Session

+open_booking(attendee_id, today) : Booking

+promote_waitlist(today) : Booking

+cancel_booking(booking, today, policy) : int

+cancel_session() : list

+expire_offers(today) : list

+seats_in_use() : int

+is_full() : bool

Booking

+send(event, today) : str

+holds_a_seat() : bool

+can_accept_offer(today) : bool

«interface»

RefundPolicy

+refund(fee_pence, days_before) : int

DefaultRefund

+refund(fee_pence, days_before) : int

«interface»

SessionStore

+get(session_id) : Session

+find_booking(booking_id)

+all_sessions() : list

+save(session)

«interface»

PaymentService

+charge(attendee_id, pence) : str

+refund(receipt, pence)

«interface»

Subscriber

+notify(event)

InMemoryStore

FakePayments

Recorder

Mermaid. The desk depends on interfaces; each provider points into one. The Session owns its bookings (composition). This diagram is checked against the code (Step 8).

Step 5: Behaviour: One Sequence, One State Machine

The booking's central scenario is "Book a seat". The sequence diagram (chapter 7) draws the three outcomes in one picture with alt fragments: the session is full, the payment is declined, the payment succeeds.

SubscriberPaymentServiceBookingSessionSessionStoreBookingDeskSubscriberPaymentServiceBookingSessionSessionStoreBookingDesksession_full: the booking is Waitlistedpayment_failed: Declined, nothing chargedpaid: Confirmedalt[the payment is declined][the payment succeeds]alt[the session is full][a seat is free]Attendeebook(attendee_id, session_id, today)get(session_id)open_booking(attendee_id, today)create Booking(...)send(event, today)notify(event)charge(attendee_id, pence)send(event, today)send(event, today)notify(event)save(session)BookingAttendee

Mermaid. Drawn by hand from the design, then checked against traces of the real code in Step 8.

The booking also has a life, which is a state machine (chapter 8): it starts Requested, can be Waitlisted, Offered, Confirmed, and ends Declined, Expired or Cancelled. As in chapter 8, the table of transitions is the source, and the diagram is generated from it.

RequestedConfirmedDeclinedWaitlistedOfferedExpiredCancelledpaidpayment_failedsession_fullseat_freed / set the offer deadlinepaid [today <= deadline]offer_lapsed [today > deadline]cancelsession_cancelledcancelsession_cancelledcancelsession_cancelled

PlantUML, generated from TRANSITIONS in workshops/domain/booking.py.

And the matrix, computed by driving a real booking into every state and sending every event ((stays) means the event was ignored or its guard was false):

paid payment_failed session_full seat_freed offer_lapsed cancel session_cancelled Requested Confirmed Declined Waitlisted illegal Requested (stays) illegal illegal Waitlisted illegal illegal illegal Offered Waitlisted (stays) Cancelled Cancelled Offered Confirmed illegal illegal illegal Expired Cancelled Cancelled Confirmed illegal illegal illegal illegal Confirmed (stays) Cancelled Cancelled Declined illegal illegal illegal illegal Declined (stays) illegal illegal Expired illegal illegal illegal illegal Expired (stays) illegal illegal Cancelled illegal illegal illegal illegal Cancelled (stays) illegal illegal

Read the matrix as design review. Terminal states accept nothing. A confirmed booking can only be cancelled (by the attendee or by the organiser). Two cells deserve a question: a booking that is still Requested cannot be cancelled by anyone, and cannot be cancelled by the organiser's session cancellation. With synchronous payment a booking is Requested for the length of one call, so this cannot happen today. If payment ever becomes asynchronous it will, which is recorded as a design decision (D8) and not left as a surprise.

Step 6: Structure

application(BookingDesk)domain(Session, Booking,RefundPolicy)infrastructureInMemoryStoreinfrastructureFakePaymentsinfrastructureRecorder«external»Card PaymentService (external)«external»E-mailService (external)SessionStorePaymentServiceSubscriberwould provide (not built)would be driven by (not built)

PlantUML. The application requires three interfaces and the infrastructure provides them; the two external services are drawn as "would provide" and "would be driven by", because they were not built.

uiinfrastructureapplicationdomain

PlantUML, generated from the real imports by chapter 9's checker, adapted to this package. Everything points at the domain.

analysed 11 files, found 4 cross-package imports package dependencies found in the code (from -> to : first place): application -> domain workshops\application\desk.py:7 (workshops.domain) infrastructure -> application workshops\infrastructure\memory.py:2 (workshops.application) infrastructure -> domain workshops\infrastructure\memory.py:3 (workshops.domain) R1 direction: R2 no cycles: R3 public API only: result: clean

Step 7: The Code

Three layers, with the dependency rules of chapter 9. The domain holds the rules and imports nothing. These are its key files: the booking and its state machine, the session, and the refund strategy.

"""A booking, and its life as a table-driven state machine. The diagram of the booking's states is generated from TRANSITIONS.""" from dataclasses import dataclass from datetime import date, timedelta OFFER_DAYS = 1 # a member offered a freed seat has this many days to pay for it class Refusal(Exception): """A business rule said no. The message is for the person.""" class IllegalTransition(Exception): pass @dataclass(frozen=True) class Transition: source: str event: str target: str guard: str = "" action: str = "" TRANSITIONS = [ Transition("Requested", "paid", "Confirmed"), Transition("Requested", "payment_failed", "Declined"), Transition("Requested", "session_full", "Waitlisted"), Transition("Waitlisted", "seat_freed", "Offered", action="set the offer deadline"), Transition("Offered", "paid", "Confirmed", guard="today <= deadline"), Transition("Offered", "offer_lapsed", "Expired", guard="today > deadline"), Transition("Confirmed", "cancel", "Cancelled"), Transition("Waitlisted", "cancel", "Cancelled"), Transition("Offered", "cancel", "Cancelled"), Transition("Confirmed", "session_cancelled", "Cancelled"), Transition("Waitlisted", "session_cancelled", "Cancelled"), Transition("Offered", "session_cancelled", "Cancelled"), ] STATES = ["Requested", "Waitlisted", "Offered", "Confirmed", "Declined", "Expired", "Cancelled"] EVENTS = ["paid", "payment_failed", "session_full", "seat_freed", "offer_lapsed", "cancel", "session_cancelled"] IGNORED = {(s, "offer_lapsed") for s in STATES if s != "Offered"} # a daily tick that only matters for an offer HOLDS_A_SEAT = {"Requested", "Offered", "Confirmed"} @dataclass class Booking: id: str attendee_id: str session_id: str state: str = "Requested" offer_deadline: date | None = None receipt: str | None = None paid_pence: int = 0 def send(self, event: str, today: date | None = None) -> str: if (self.state, event) in IGNORED: return self.state options = [t for t in TRANSITIONS if t.source == self.state and t.event == event] if not options: raise IllegalTransition(f"{event} is not allowed for a booking that is {self.state}") t = options[0] if t.guard == "today <= deadline" and not today <= self.offer_deadline: raise IllegalTransition("the offer has lapsed") if t.guard == "today > deadline" and not today > self.offer_deadline: return self.state # not yet lapsed: the tick is discarded if t.action == "set the offer deadline": self.offer_deadline = today + timedelta(days=OFFER_DAYS) self.state = t.target return self.state def holds_a_seat(self) -> bool: return self.state in HOLDS_A_SEAT def can_accept_offer(self, today: date) -> bool: return self.state == "Offered" and today <= self.offer_deadline def to_plantuml() -> str: lines = ["@startuml", "scale 1.2", "hide empty description", "[*] --> Requested"] for t in TRANSITIONS: label = t.event + (f" [{t.guard}]" if t.guard else "") + (f" / {t.action}" if t.action else "") lines.append(f"{t.source} --> {t.target} : {label}") lines += [f"{s} --> [*]" for s in ("Declined", "Expired", "Cancelled")] lines.append("@enduml") return "\n".join(lines) + "\n"
"""A session of a workshop: its capacity, its fee, and its bookings. The session decides who gets a seat.""" from dataclasses import dataclass, field from datetime import date from itertools import count from workshops.domain.booking import Booking, Refusal from workshops.domain.refunds import RefundPolicy _ids = count(1) @dataclass class Session: id: str workshop: str starts_on: date capacity: int fee_pence: int bookings: list[Booking] = field(default_factory=list) cancelled: bool = False def seats_in_use(self) -> int: return sum(1 for b in self.bookings if b.holds_a_seat()) def is_full(self) -> bool: return self.seats_in_use() >= self.capacity def find(self, booking_id: str) -> Booking: for b in self.bookings: if b.id == booking_id: return b raise Refusal("no such booking") def open_booking(self, attendee_id: str, today: date) -> Booking: """Create a booking. It holds a seat while it awaits payment, or joins the waiting list if there is none.""" if self.cancelled: raise Refusal("this session has been cancelled") if today >= self.starts_on: raise Refusal("this session has already started") if any(b.attendee_id == attendee_id and b.state in ("Requested", "Waitlisted", "Offered", "Confirmed") for b in self.bookings): raise Refusal("already booked, or waiting, for this session") full = self.is_full() booking = Booking(f"{self.id}-B{next(_ids)}", attendee_id, self.id) self.bookings.append(booking) if full: booking.send("session_full") return booking def promote_waitlist(self, today: date) -> Booking | None: """If a seat is free and someone is waiting, offer it to whoever has waited longest.""" if self.cancelled or self.is_full(): return None for b in self.bookings: # bookings are kept in the order they were made if b.state == "Waitlisted": b.send("seat_freed", today) return b return None def cancel_booking(self, booking: Booking, today: date, policy: RefundPolicy) -> int: """Cancel one booking and return the refund owed.""" was = booking.state booking.send("cancel") if was != "Confirmed": return 0 return policy.refund(booking.paid_pence, (self.starts_on - today).days) def cancel_session(self) -> list[tuple[Booking, int]]: """Cancel the whole session: everyone who had paid is owed everything back.""" self.cancelled = True out = [] for b in self.bookings: if b.state in ("Waitlisted", "Offered", "Confirmed"): owed = b.paid_pence if b.state == "Confirmed" else 0 b.send("session_cancelled") out.append((b, owed)) return out def expire_offers(self, today: date) -> list[Booking]: lapsed = [] for b in self.bookings: if b.state == "Offered" and today > b.offer_deadline: b.send("offer_lapsed", today) lapsed.append(b) return lapsed
"""How much of the fee is returned when an attendee cancels. A Strategy, so the organiser's rule can change without touching bookings.""" from typing import Protocol, runtime_checkable @runtime_checkable class RefundPolicy(Protocol): def refund(self, fee_pence: int, days_before: int) -> int: ... class DefaultRefund: """Everything back if the session is at least `full_days` away, nothing after that.""" def __init__(self, full_days: int = 7): self.full_days = full_days def refund(self, fee_pence: int, days_before: int) -> int: return fee_pence if days_before >= self.full_days else 0

The application layer: the interfaces it requires, and the desk that coordinates the use cases.

"""The interfaces the application REQUIRES. Declared here; the code that provides them lives in infrastructure.""" from typing import Protocol, runtime_checkable from workshops.domain import Booking, Session class PaymentDeclined(Exception): """The payment service said no (card declined, service unavailable).""" @runtime_checkable class SessionStore(Protocol): def get(self, session_id: str) -> Session: ... def find_booking(self, booking_id: str) -> tuple[Session, Booking]: ... def all_sessions(self) -> list[Session]: ... def save(self, session: Session) -> None: ... @runtime_checkable class PaymentService(Protocol): def charge(self, attendee_id: str, pence: int) -> str: ... # returns a receipt; raises PaymentDeclined def refund(self, receipt: str, pence: int) -> None: ... @runtime_checkable class Subscriber(Protocol): def notify(self, event: object) -> None: ...
"""The use cases of the booking desk. It coordinates; the rules live in Session and Booking.""" from datetime import date from workshops.application.events import (AddedToWaitlist, BookingCancelled, BookingConfirmed, BookingDeclined, OfferExpired, OfferMade, SessionCancelled) from workshops.application.ports import PaymentDeclined, PaymentService, SessionStore, Subscriber from workshops.domain import Booking, DefaultRefund, RefundPolicy, Refusal class BookingDesk: def __init__(self, store: SessionStore, payments: PaymentService, subscribers: tuple[Subscriber, ...] = (), refund_policy: RefundPolicy | None = None): self.store, self.payments = store, payments self.subscribers = list(subscribers) self.refund_policy = refund_policy or DefaultRefund() self.errors: list[str] = [] # --- use case: book a seat --- def book(self, attendee_id: str, session_id: str, today: date) -> Booking: session = self.store.get(session_id) booking = session.open_booking(attendee_id, today) if booking.state == "Waitlisted": self._publish(AddedToWaitlist(booking.id, attendee_id, session_id)) else: try: booking.receipt = self.payments.charge(attendee_id, session.fee_pence) except PaymentDeclined: booking.send("payment_failed") self._publish(BookingDeclined(booking.id, attendee_id)) # no waiting list to serve here: with synchronous payment, nobody can be waiting while a seat is free else: booking.paid_pence = session.fee_pence booking.send("paid", today) self._publish(BookingConfirmed(booking.id, attendee_id, session_id)) self.store.save(session) return booking # --- use case: pay for an offered seat --- def accept_offer(self, booking_id: str, today: date) -> Booking: session, booking = self.store.find_booking(booking_id) if not booking.can_accept_offer(today): # decide BEFORE taking money raise Refusal("there is no open offer for this booking") try: booking.receipt = self.payments.charge(booking.attendee_id, session.fee_pence) except PaymentDeclined: raise Refusal("the payment was declined; the offer stays open until its deadline") from None booking.paid_pence = session.fee_pence booking.send("paid", today) self._publish(BookingConfirmed(booking.id, booking.attendee_id, session.id)) self.store.save(session) return booking # --- use case: cancel a booking --- def cancel(self, booking_id: str, today: date) -> int: session, booking = self.store.find_booking(booking_id) receipt = booking.receipt refund = session.cancel_booking(booking, today, self.refund_policy) if refund: self.payments.refund(receipt, refund) self._publish(BookingCancelled(booking.id, booking.attendee_id, refund)) self._offer_freed_seat(session, today) self.store.save(session) return refund # --- use case: the organiser cancels a session --- def cancel_session(self, session_id: str, today: date) -> int: session = self.store.get(session_id) total = 0 for booking, owed in session.cancel_session(): if owed: self.payments.refund(booking.receipt, owed) total += owed self._publish(SessionCancelled(session_id, total)) self.store.save(session) return total # --- the nightly clock tick --- def day_passed(self, today: date) -> None: for session in self.store.all_sessions(): for lapsed in session.expire_offers(today): self._publish(OfferExpired(lapsed.id, lapsed.attendee_id)) self._offer_freed_seat(session, today) self.store.save(session) def _offer_freed_seat(self, session, today: date) -> None: while (offered := session.promote_waitlist(today)) is not None: self._publish(OfferMade(offered.id, offered.attendee_id, offered.offer_deadline)) def _publish(self, event: object) -> None: for s in self.subscribers: try: s.notify(event) except Exception as e: # noqa: BLE001 - a broken subscriber must not undo a booking or silence the others self.errors.append(f"{type(s).__name__} failed on {type(event).__name__}: {type(e).__name__}")

(The infrastructure, workshops/infrastructure/memory.py, holds an in-memory store, a payment fake that can be told to decline particular people, and a recorder that stands in for the e-mail service. The event classes are plain dataclasses. All of it is in the project files.) A few lines carry decisions from the design document: the desk decides whether an offer is still open before it takes money (accept_offer), a failing subscriber is recorded and never undoes a booking (_publish), and the comment in book says why a declined payment needs no waiting-list handling.

Step 8: Tests, Mutants and Checks

The tests cover the state machine (every state and event pair is defined, ignored or illegal), the session's rules (one seat per person, a seat held from the request, the longest waiter first, the refund boundary) and every desk use case including the failure paths. They pass:

Ran 23 tests in 0.250s OK

Passing means little until the tests are shown to fail on broken code. Three plausible bugs, each introduced on purpose:

unmodified: 0 of 21 tests fail [] refund boundary off by one: 1 of 21 tests fail ['SessionRules.test_refund_depends_on_how_close_the_session_is'] newest waiter is offered the seat: 2 of 21 tests fail ['Desk.test_an_unclaimed_offer_expires_and_passes_to_the_next_person', 'SessionRules.test_the_longest_waiting_member_is_offered_the_freed_seat'] money taken before the offer is checked: 1 of 21 tests fail ['Desk.test_a_lapsed_offer_is_refused_before_any_money_is_taken'] restored: 0 of 21 tests fail []

Each was caught: an off-by-one on the refund boundary (exactly seven days must still refund), the newest waiter offered the seat instead of the oldest, and money taken before the offer was checked (the guarantee in the "Book a seat" use case that nobody is charged for something that did not succeed).

Do the Diagrams Match the Code?

The class diagram is checked for drawn operations, attributes and realisations (chapter 11's check). The sequence diagram is checked against three traced runs of the real desk (seat free and paid; session full; payment declined), using chapter 7's tracer, extended to trace a whole package: every call the code makes must be drawn, and every call drawn must be made in at least one run.

"""Check the capstone's design diagrams against the code. 1. the class diagram: every drawn operation and attribute exists, every drawn realisation is real (chapter 11's check); 2. the sequence diagram: run three scenarios (seat free and paid, session full, payment declined) under the tracer, and check that every call the code makes (at the diagram's level of detail) is drawn, and every call drawn is made in at least one scenario. Names only, and the sequence check does not test the ORDER of calls across branches: it says nothing about whether the alternatives are right. """ import pathlib import re import sys from datetime import date import diagram_check as dc from tracetool import to_mermaid, trace from workshops.application import BookingDesk from workshops.domain import Session from workshops.infrastructure import FakePayments, InMemoryStore, Recorder HERE = pathlib.Path(__file__).parent dc.HERE = HERE dc.MODULES = ["workshops.domain", "workshops.application", "workshops.infrastructure"] dc.CHECKS = {"design.class.mmd": ["workshops/domain/session.py", "workshops/domain/booking.py", "workshops/domain/refunds.py", "workshops/application/desk.py", "workshops/application/ports.py", "workshops/infrastructure/memory.py"]} PROVIDERS = {"InMemoryStore": "SessionStore", "FakePayments": "PaymentService", "Recorder": "Subscriber"} HIDDEN = {"is_full", "seats_in_use", "_publish", "holds_a_seat", "can_accept_offer"} ACTOR = "Attendee" TODAY = date(2026, 11, 1) def drawn_calls() -> set[tuple[str, str, str]]: out = set() for line in (HERE / "diagrams" / "book.sequence.mmd").read_text(encoding="utf8").splitlines(): m = re.match(r"^\s*(\w+)(?:->>|-\))\+?(\w+):\s*(.+)$", line) if m: s, r, label = m.groups() op = "create" if label.startswith("create ") else label.split("(")[0] out.add((s, r, op)) return out def traced_calls(scenario) -> set[tuple[str, str, str]]: desk, session = scenario() events, _, _ = trace("workshops", desk.book, "ann", "S1", TODAY, actor=ACTOR) out = set() for line in to_mermaid(events, actor=ACTOR).splitlines(): m = re.match(r"^\s*(\w+)->>(\w+):\s*(.+)$", line) if not m: continue s, r, label = m.groups() op = "create" if label.startswith("create ") else label.split("(")[0] if op in HIDDEN or (op == "create" and r != "Booking"): # the events the desk creates to publish are not drawn continue out.add((PROVIDERS.get(s, s), PROVIDERS.get(r, r), op)) return out def seat_free(): s = Session("S1", "Pottery", date(2026, 12, 1), 2, 2500) return BookingDesk(InMemoryStore([s]), FakePayments(), (Recorder(),)), s def full(): s = Session("S1", "Pottery", date(2026, 12, 1), 1, 2500) desk = BookingDesk(InMemoryStore([s]), FakePayments(), (Recorder(),)) desk.book("other", "S1", TODAY) return desk, s def declined(): s = Session("S1", "Pottery", date(2026, 12, 1), 2, 2500) return BookingDesk(InMemoryStore([s]), FakePayments({"ann"}), (Recorder(),)), s def main() -> int: problems = 0 print("class diagram against the code:") for line in _class_check(): print(" " + line) drawn = drawn_calls() seen: set = set() for name, scenario in (("seat free, paid", seat_free), ("session full", full), ("payment declined", declined)): calls = traced_calls(scenario) seen |= calls undrawn = sorted(calls - drawn) print(f"sequence diagram, scenario '{name}': {len(calls)} calls traced; not drawn: {undrawn or 'none'}") problems += len(undrawn) phantom = sorted(drawn - seen) print(f"sequence diagram, drawn but never made in any scenario: {phantom or 'none'}") return 1 if problems + len(phantom) else 0 def _class_check() -> list[str]: import contextlib import io buf = io.StringIO() with contextlib.redirect_stdout(buf): dc.main() return buf.getvalue().splitlines() if __name__ == "__main__": sys.exit(main())
class diagram against the code: design.class.mmd OK sequence diagram, scenario 'seat free, paid': 8 calls traced; not drawn: none sequence diagram, scenario 'session full': 7 calls traced; not drawn: none sequence diagram, scenario 'payment declined': 8 calls traced; not drawn: none sequence diagram, drawn but never made in any scenario: none

It passed on the first run, which is not evidence on its own, so here it is on a copy where I removed a drawn call, added a call the code never makes, and renamed an operation in the class diagram:

class diagram against the code: design.class.mmd 1 problem(s) Session.start_booking() is drawn but not in the code sequence diagram, scenario 'seat free, paid': 8 calls traced; not drawn: [('BookingDesk', 'SessionStore', 'save')] sequence diagram, scenario 'session full': 7 calls traced; not drawn: [('BookingDesk', 'SessionStore', 'save')] sequence diagram, scenario 'payment declined': 8 calls traced; not drawn: [('BookingDesk', 'SessionStore', 'save')] sequence diagram, drawn but never made in any scenario: [('BookingDesk', 'Session', 'refund_everyone')] (exit status 1)

Each tamper was found. What it does not check: the order of calls across the alternatives, and anything about the meaning of a message.

The Story

All the use cases end to end, with the e-mails the subscriber would send. Two people book a two-seat pottery session and a third is waitlisted; a card is declined on another session; the first booker cancels twenty days ahead (refunded in full), the freed seat is offered to the waiting person, who does not take it (the offer expires at the nightly run); a second cancellation three days before the session earns no refund; the organiser cancels a session; a booking on that session is refused.

"""One story through the running service: booking, waiting list, cancellation, offer, expiry, a declined card, and a session cancelled.""" from datetime import date, timedelta from workshops.application import BookingDesk from workshops.domain import Refusal, Session from workshops.infrastructure import FakePayments, InMemoryStore class Printer: """The stand-in for the e-mail service: prints what it would send.""" def __init__(self): self.today = None def notify(self, event): print(f" [{self.today}] e-mail: {type(event).__name__} {', '.join(f'{k}={v}' for k, v in vars(event).items() if k not in ('booking_id',))}") printer = Printer() pottery = Session("S1", "Pottery for beginners", date(2026, 12, 1), 2, 2500) coding = Session("S2", "Coding for beginners", date(2026, 11, 20), 5, 4000) payments = FakePayments(decline={"dee"}) desk = BookingDesk(InMemoryStore([pottery, coding]), payments, (printer,)) d = date(2026, 11, 1) def step(day, text): printer.today = day print(f"{day} {text}") step(d, "ann and bo book the pottery session (2 seats), cy asks too") ann = desk.book("ann", "S1", d) bo = desk.book("bo", "S1", d) cy = desk.book("cy", "S1", d) step(d, f"dee's card is declined on the coding session; the state is {desk.book('dee', 'S2', d).state}") step(d, f"seats in use on pottery: {pottery.seats_in_use()} of {pottery.capacity}; cy is {cy.state}") d2 = d + timedelta(days=10) step(d2, "ann cancels, 20 days before the session") step(d2, f"refund: {desk.cancel(ann.id, d2)}p; cy is now {cy.state} (deadline {cy.offer_deadline})") d3 = d2 + timedelta(days=3) step(d3, "three days later cy has not paid: the nightly job runs") desk.day_passed(d3) step(d3, f"cy is {cy.state}") d4 = date(2026, 11, 28) step(d4, "bo cancels three days before the session") step(d4, f"refund: {desk.cancel(bo.id, d4)}p") step(d4, "the organiser cancels the coding session") step(d4, f"refunds paid: {desk.cancel_session('S2', d4)}p (no confirmed bookings there, dee was declined)") step(d4, "an attempt to book a cancelled session") try: desk.book("eve", "S2", d4) except Refusal as e: print(f" refused: {e}") print() print("payments charged:", payments.charges) print("payments refunded:", payments.refunds)
2026-11-01 ann and bo book the pottery session (2 seats), cy asks too [2026-11-01] e-mail: BookingConfirmed attendee_id=ann, session_id=S1 [2026-11-01] e-mail: BookingConfirmed attendee_id=bo, session_id=S1 [2026-11-01] e-mail: AddedToWaitlist attendee_id=cy, session_id=S1 [2026-11-01] e-mail: BookingDeclined attendee_id=dee 2026-11-01 dee's card is declined on the coding session; the state is Declined 2026-11-01 seats in use on pottery: 2 of 2; cy is Waitlisted 2026-11-11 ann cancels, 20 days before the session [2026-11-11] e-mail: BookingCancelled attendee_id=ann, refund_pence=2500 [2026-11-11] e-mail: OfferMade attendee_id=cy, deadline=2026-11-12 2026-11-11 refund: 2500p; cy is now Offered (deadline 2026-11-12) 2026-11-14 three days later cy has not paid: the nightly job runs [2026-11-14] e-mail: OfferExpired attendee_id=cy 2026-11-14 cy is Expired 2026-11-28 bo cancels three days before the session [2026-11-28] e-mail: BookingCancelled attendee_id=bo, refund_pence=0 2026-11-28 refund: 0p 2026-11-28 the organiser cancels the coding session [2026-11-28] e-mail: SessionCancelled session_id=S2, refunds_pence=0 2026-11-28 refunds paid: 0p (no confirmed bookings there, dee was declined) 2026-11-28 an attempt to book a cancelled session refused: this session has been cancelled payments charged: [('ann', 2500, 'R1'), ('bo', 2500, 'R2')] payments refunded: [('R1', 2500)]

Step 9: A Review, and the Gap It Found

Chapter 11's checklist, applied to the finished design. The architecture rules are clean; the state machine's matrix has no unexplained cell; the smell detector flags nothing in the session or the desk. It flags one thing in booking.py, "anemic?", for the Transition class, which is a false alarm: a row of a table is data by design. Reading the code found what the detectors did not (the exercises build on these):

  • The desk sets fields on a booking from outside (receipt and paid_pence, four assignments). A mark_paid on the booking would put that rule with the data.
  • Cancelling a session ignores a booking that is still Requested. Harmless with synchronous payment, wrong the day payment is not.
  • A question for the manager nobody asked: booking on the day of the session is refused ("already started"). Is that right? It is an assumption.
  • What if the payment service fails to refund? This is the "NOT YET DESIGNED" extension from the use case. Here is what the code does today:
"""Use case 'Cancel a booking', extension 5b ('the payment service fails to refund') is marked NOT YET DESIGNED. What does the code do today?""" from datetime import date from workshops.application import BookingDesk from workshops.domain import Session from workshops.infrastructure import FakePayments, InMemoryStore, Recorder TODAY = date(2026, 11, 1) class RefundsFail(FakePayments): def refund(self, receipt, pence): raise ConnectionError("payment service unreachable") session = Session("S1", "Pottery", date(2026, 12, 1), 1, 2500) rec = Recorder() desk = BookingDesk(InMemoryStore([session]), RefundsFail(), (rec,)) ann = desk.book("ann", "S1", TODAY) bo = desk.book("bo", "S1", TODAY) # waits rec.events.clear() try: desk.cancel(ann.id, TODAY) except ConnectionError as e: print(f"desk.cancel raised: {type(e).__name__}: {e}") print(f"ann's booking is now: {ann.state} (paid {ann.paid_pence}p, refunded: nothing)") print(f"bo (waiting) is now : {bo.state} (the freed seat was never offered)") print(f"events published : {rec.events or 'none'}")
desk.cancel raised: ConnectionError: payment service unreachable ann's booking is now: Cancelled (paid 2500p, refunded: nothing) bo (waiting) is now : Waitlisted (the freed seat was never offered) events published : none

That is a real defect, found by writing a use case honestly and then asking what the code does where the use case is silent. The attendee's booking is cancelled, money is owed and nothing records it, and the person waiting does not get their offer until the next nightly run. The design document lists it as gap G1 with a proposal, and Exercise 1 closes it, as a subclass that does not touch the package.

Step 10: The Design Document

A short document, two pages as text, for the next person (who may be you in six months): what the system is for, where each artefact lives, the decisions with their reasons and how each is checked, the evidence of quality, and the gaps. This is the whole of it.

WORKSHOP BOOKING SERVICE: DESIGN DOCUMENT version 1, 2026-10-10 1. PURPOSE AND SCOPE Let people book, cancel and wait for seats on a community centre's workshop sessions, pay the fee by card, and be refunded by the centre's rules. In scope: booking, waiting list, offers, cancellation with refund, cancelling a session, notifications. Out of scope: changing a session's date (Q3), several seats per booking (Q1), real card and e-mail providers, a user interface, persistence beyond one process. 2. WHERE THINGS ARE requirements.txt the manager's words, with questions and assumptions usecases.puml, *.usecase.txt who wants what; "Book a seat" and "Cancel a booking" in full glossary.txt, domain.mmd the vocabulary and the concepts (generated from domain_model.py, with review sentences) design.class.mmd classes, responsibilities, interfaces book.sequence.mmd "Book a seat" with its alternatives booking.state.puml the life of a booking (generated from the transition table in workshops/domain/booking.py) components.puml, packages.puml the pieces and the direction of dependency (packages generated from the imports) workshops/ the code: domain, application, infrastructure; 23 tests; story.py runs it end to end 3. DECISIONS D1 Three layers, dependencies pointing inward: infrastructure -> application -> domain. The domain imports nothing. Why: the rules (who gets a seat, what is refunded) must be testable without a card service or a mail server. Checked by: deps.py, run as a test. Consequence: every outside thing is behind an interface owned by the application. D2 The Session decides who gets a seat; BookingDesk only coordinates. Why: the session holds the capacity and the bookings (Information Expert). Consequence: the desk has no business rule in it. D3 A booking is a table-driven state machine (Requested, Waitlisted, Offered, Confirmed, Declined, Expired, Cancelled). Why: the lifecycle has many "not allowed" cases, and a table makes each one visible. Checked by: the matrix and a test that every state and event pair is defined, ignored or illegal. Consequence: a new state is new rows, and the diagram regenerates. D4 A booking holds a seat from the moment it is requested until it ends (Requested, Offered, Confirmed). Why: otherwise two people could be told yes to the last seat. Consequence: the waiting list exists only while the session is full. D5 Refund rule as a Strategy (RefundPolicy). Why: the manager will change it. Consequence: DefaultRefund is one class; a new rule is another. D6 Notifications as events to subscribers (Observer); a failing subscriber is recorded and never undoes a booking or silences the others. Why: R7 will grow (text messages, a log), and a mail outage must not stop bookings. D7 The desk decides an offer is still open BEFORE charging for it. Why: charging then refusing means refunding. Checked by: a test and a mutant. D8 Payment is synchronous: book() asks the card service and waits. Why: simplest thing that meets R2. Consequence: a booking is "Requested" only for the length of one call, and some transitions (cancel or session_cancelled while Requested) are illegal, which is fine now and would need design if payment became asynchronous. 4. QUALITY EVIDENCE 23 tests pass (state machine, session rules, every desk use case, the architecture rules). Three deliberate bugs (refund boundary, wrong waiter, charge before checking) were each caught by the tests. The class diagram and the sequence diagram were checked against the code (design_check.py), which also failed, as it should, on tampered copies. 5. KNOWN GAPS AND OPEN DECISIONS (nothing below is built) G1 If the payment service fails to REFUND, the booking is already cancelled, the attendee is owed money, nothing is recorded and no event is published; the waiting person is only offered the seat at the next nightly run (gap_refund.py shows it). Proposal: record the refund as "due" on the booking, retry it nightly, and tell the attendee. Needs the manager's answer on how long to keep retrying. G2 One process, in memory. Two desks, or a restart, lose or fork the data (chapter 9's two-desk example applies here too). G3 Q3 (date change), Q1 (several seats) and Q4 (how "a week" is counted) are assumed or out of scope; see requirements.txt. G4 Real card and e-mail providers are not built; FakePayments and Recorder stand in. Whatever replaces them must satisfy the PaymentService and Subscriber interfaces, and the card provider's real failure modes (timeouts, duplicate charges) are not designed for. G5 The offer deadline is whole days; "a day to take it" is "until the end of the next calendar day".
Mistakes while preparing this chapter
  • I drew an extend arrow backwards. My first use case diagram had Book a seat pointing at Join the waiting list. Chapter 3 says the extend arrow points at the base, and I still got it wrong on the first try. I only saw it when I looked at the rendered picture. Takeaway: the notation rules you know are not the ones you apply without checking.
  • A test file copied from another project named a file that does not exist here. I reused chapter 11's architecture test, which tampers with domain/model.py to prove the check can fail, and this project has no such file (FileNotFoundError). The error was loud, the cause trivial, and the lesson is that a copied test carries assumptions with it.
  • I wrote code for a case that cannot happen. My first desk, after a declined payment, offered the freed seat to the waiting list. Writing the test for it, I could not construct the situation: someone only waits while the session is full, and with synchronous payment a declined booking never coexists with a waiting person. I deleted the line, replaced the test with one stating the invariant (test_a_waiting_list_never_coexists_with_a_free_seat), and recorded in the design document that asynchronous payment would reopen it.
  • The first test for that case was muddled. It tried to simulate a payment "in flight" by editing a booking's state by hand. A test that has to cheat to reach a case is evidence the case is unreachable, which is what it turned out to be.
  • A shell here-document wrote nothing. The application and infrastructure files were written with a single heredoc command that the shell refused ("unexpected EOF"), and because the whole command failed, the folders stayed empty. I noticed only when I listed them. The file-writing tool is the reliable way to create source; and after a failed command, check what it did not do.
  • Everything passed first time. The 21 tests, the architecture rules, the diagram checks: all green on the first run. That is the moment to distrust it. The mutants and the tampered-diagram run are in the chapter for that reason, not for decoration.

What the Method Gave, and What It Did Not

  • It found the questions early (requirements Q1 to Q6) and made the assumptions visible.
  • It found one real defect (G1) through a use case extension left honest, and several smaller decisions (D4, D7, D8) while drawing.
  • It did not make the design correct. The 23 tests and the checks show the code does what the diagrams say, not that the diagrams are what the centre needs. Only the manager can confirm Q1 to Q6, and nothing here replaces asking her.
  • It cost effort that a smaller system would not repay. For a throwaway script, none of this is warranted (chapter 1). For a service people pay money into, the checks on refunds and offers were cheap compared with one wrong refund.

Hands-On Exercises

Exercise 1

Close gap G1 without editing the package: write a desk subclass that, when a refund fails, records the debt, publishes an event, still offers the freed seat, and retries at the nightly run. Show it with a payment service that is down and then comes back.

📄 View solution
Exercise 2

Add requirement R1, which the design so far lacks: "see the upcoming sessions and how many seats are left". Write it as a read-only query beside the desk, with tests (cancelled and past sessions excluded, seats left never negative, waitlisted people hold no seat).

📄 View solution
Exercise 3

Review the capstone design yourself with the chapter 11 checklist. Back four findings with evidence from the code (run something, do not just assert it): at least one the smell detector reports, one it misses, and one that is a question for the manager.

📄 View solution

Chapter 12 Quick Reference

  • The method, in order: requirements and their questions, use cases, domain model and glossary, class design by responsibility, key sequence and state diagrams, components and packages, code, tests and checks, a short design document
  • Leave a use case extension honest ("NOT YET DESIGNED") rather than invent an answer; then ask what the code does there: it found gap G1
  • Generate what you can (domain diagram from data, state diagram from a table, package diagram from imports); check the rest (class and sequence diagrams against the code)
  • Mutants and tampered diagrams prove the tests and checks can fail; a check that passes first time is not yet evidence
  • A design document records decisions with reasons and how each is checked, the evidence, and the gaps: what you did not build is part of the design
  • Diagrams and tests show the code matches the design, not that the design is right; confirm the assumptions with the people who own them

Course Complete: What You Can Now Do

ChapterYou can now…
1 Why ModelSay what analysis and design are, choose sketch mode, and judge how much modelling a problem deserves
2 Diagrams as CodeWrite diagrams as text in Mermaid and PlantUML, render them, and keep them beside the code
3 Use CasesFind actors and goals and write a use case with alternative flows, then turn it into a test
4 Domain ModelFind the concepts of a problem, draw them with multiplicities, keep a glossary, and review it with non-programmers
5 Class DiagramsRead and draw the full class notation and the six relationships, and know what each becomes in code
6 ResponsibilitiesAssign responsibilities with CRC cards and GRASP, recognise an anemic model, and measure the difference
7 Sequence DiagramsTurn a scenario into a sequence diagram, derive operations from it, and check it against a call trace
8 State and ActivityModel an object's life as a tested state machine and a process as an activity diagram with swimlanes
9 Structure DiagramsDraw components and packages, enforce dependency rules as a test, and ask "what if there are two?" of a deployment
10 UML and CodeGenerate a skeleton from a diagram, reverse-engineer code, and keep diagrams honest with checks
11 Patterns and ReviewRecognise patterns on a diagram, review a design with a checklist, and refactor on the diagram before the code
12 CapstoneTake a new problem from requirements to a checked, running design with a short document and an honest list of gaps

Where to go next. Design Patterns teaches the patterns this course only drew, with their trade-offs, and Clean Code, SOLID & Refactoring covers the principles behind chapters 6 and 11 and how to refactor safely. Software Architecture Fundamentals takes the structure diagrams of chapter 9 up a level (the C4 model, architecture decisions and the qualities a system must have), and The Software Development Lifecycle places requirements, design documents and reviews inside a whole project. Then take something you are building and work through the method on it. Start with the questions: that part costs the least and finds the most.

Course complete
This was the last chapter of Object-Oriented Design with UML. A combined PDF of all twelve chapters is built from these pages.