Structure Diagrams

Object-Oriented Design with UML

Course 1 ยท Chapter 9 ยท Components, Packages and Deployment

Class diagrams are about classes. A real system is also about chunks bigger than a class: the pieces you can build, replace and deploy separately, and which of them may depend on which. Four diagrams cover that level: component diagrams (the pieces and the interfaces between them), package diagrams (how the code is grouped, and the direction of dependencies), deployment diagrams (what runs where) and object diagrams (a snapshot of real objects at one moment). This chapter restructures the library into a real Python package, so the diagrams describe something that exists and can be checked.

What was run, and what was not
Run here (Python 3.14, PlantUML 1.2026.8): the library as a four-package project (domain, application, infrastructure, ui) with 10 behaviour tests over two interchangeable storage components; a command-line program run against a real JSON file; a script that reads the code's imports and checks three dependency rules (on the real tree, on tampered copies, and as a test); an object diagram generated from live objects; and a demonstration of what two desks sharing one file do. Not done: nothing was deployed anywhere: the deployment diagram is a proposal, labelled as such. The C4 comparison is from my memory of Simon Brown's model, not a re-read of Software Architecture Fundamentals 9 (Documenting Architecture: ADRs and the C4 Model), which is the chapter to read for it.

Component Diagrams

A component is a replaceable part of a system with a clear boundary. Its boundary is made of interfaces: a provided interface (a lollipop: what the component offers) and a required interface (a socket: what it needs from others). A component fits another when one's lollipop goes into the other's socket. The notation is small, and the idea is large: the component says what it needs, not who supplies it.

ui(command line)application(LoanDesk)domain(Member, Loan, Copy)infrastructureJsonCatalogueinfrastructureInMemoryCatalogueCatalogueLoanDeskchooses

PlantUML. The library's components. application provides the LoanDesk interface (to the UI) and requires Catalogue; two infrastructure components each provide Catalogue. The dashed arrows are plain dependencies.

@startuml scale 1.3 left to right direction component "ui\n(command line)" as UI component "application\n(LoanDesk)" as App component "domain\n(Member, Loan, Copy)" as Domain component "infrastructure\nJsonCatalogue" as Json component "infrastructure\nInMemoryCatalogue" as Mem interface "Catalogue" as Cat interface "LoanDesk" as Desk UI -( Desk App -- Desk App -( Cat Json -- Cat Mem -- Cat App ..> Domain Json ..> Domain Mem ..> Domain UI ..> Json : chooses @enduml

Here is the point of the diagram in code. The application component declares the interface it requires, in its own package:

"""The interface the application REQUIRES from whatever stores the library's data. Defined here, in the application, not in the storage code.""" from typing import Protocol, runtime_checkable from library.domain import Copy, Loan, Member @runtime_checkable class Catalogue(Protocol): def member(self, card: str) -> Member: ... def copy(self, barcode: str) -> Copy: ... def open_loan_for(self, barcode: str) -> tuple[Member, Loan]: ... def save(self) -> None: ...
"""The use cases of the lending desk. Depends on the domain and on the Catalogue interface; knows nothing about how data is stored.""" from datetime import date from library.application.ports import Catalogue from library.domain import Loan class LoanDesk: def __init__(self, catalogue: Catalogue): self.catalogue = catalogue def borrow(self, card: str, barcode: str, today: date) -> Loan: loan = self.catalogue.member(card).borrow(self.catalogue.copy(barcode), today) self.catalogue.save() return loan def return_book(self, barcode: str, today: date) -> int: member, loan = self.catalogue.open_loan_for(barcode) fine = member.give_back(loan, self.catalogue.copy(barcode), today) self.catalogue.save() return fine

LoanDesk receives anything that fits and never names a storage class. Two components provide it, written without reference to LoanDesk:

"""A Catalogue that keeps everything in memory. Provides the interface the application requires.""" from library.domain import Copy, Loan, Member, Refusal class InMemoryCatalogue: def __init__(self, members=(), copies=()): self.members = {m.card: m for m in members} self.copies = {c.barcode: c for c in copies} def member(self, card: str) -> Member: if card not in self.members: raise Refusal("membership is not valid") return self.members[card] def copy(self, barcode: str) -> Copy: if barcode not in self.copies: raise Refusal("no such copy") return self.copies[barcode] def open_loan_for(self, barcode: str) -> tuple[Member, Loan]: for m in self.members.values(): for l in m.open_loans(): if l.barcode == barcode: return m, l raise Refusal("that copy is not on loan") def save(self) -> None: pass
"""A Catalogue that keeps its data in a JSON file and saves after every change. A second provider of the same required interface.""" import json import os import pathlib import tempfile from datetime import date from library.domain import Copy, Loan, Member from library.infrastructure.memory import InMemoryCatalogue def _d(value: str | None) -> date | None: return date.fromisoformat(value) if value else None class JsonCatalogue: def __init__(self, path: str): self.path = pathlib.Path(path) self._data = self._load() if self.path.exists() else InMemoryCatalogue() # the provided interface: same operations as InMemoryCatalogue, delegated def member(self, card): return self._data.member(card) def copy(self, barcode): return self._data.copy(barcode) def open_loan_for(self, barcode): return self._data.open_loan_for(barcode) def add(self, member: Member | None = None, copy: Copy | None = None) -> None: if member: self._data.members[member.card] = member if copy: self._data.copies[copy.barcode] = copy def save(self) -> None: raw = { "members": [{"card": m.card, "expires_on": m.expires_on.isoformat(), "kind": m.kind_name, "fines_pence": m.fines_pence, "loans": [{"barcode": l.barcode, "borrowed_on": l.borrowed_on.isoformat(), "due_on": l.due_on.isoformat(), "returned_on": l.returned_on.isoformat() if l.returned_on else None} for l in m.loans]} for m in self._data.members.values()], "copies": [{"barcode": c.barcode, "on_loan": c.on_loan} for c in self._data.copies.values()], } fd, tmp = tempfile.mkstemp(dir=self.path.parent, suffix=".tmp") # write to a temp file, then swap it in: never a half-written file with os.fdopen(fd, "w", encoding="utf8") as f: json.dump(raw, f, indent=2) os.replace(tmp, self.path) def _load(self) -> InMemoryCatalogue: raw = json.loads(self.path.read_text(encoding="utf8")) members = [Member(m["card"], date.fromisoformat(m["expires_on"]), m["kind"], m["fines_pence"], [Loan(l["barcode"], date.fromisoformat(l["borrowed_on"]), date.fromisoformat(l["due_on"]), _d(l["returned_on"])) for l in m["loans"]]) for m in raw["members"]] return InMemoryCatalogue(members, [Copy(c["barcode"], c["on_loan"]) for c in raw["copies"]])

The interface is owned by the consumer. Catalogue lives in application, because it describes what the desk needs, not what a database offers. That inversion is what lets you swap the storage component without touching the application. (Clean Code, SOLID & Refactoring 6 and 7 cover the dependency inversion principle properly; here it is as an architectural picture.) One consequence worth noticing: because Python's protocols match by shape, neither storage class imports Catalogue. In the code, the infrastructure package does not depend on the application package at all; the "provides" arrow in the diagram is a statement about the design, not an import.

The tests prove that both providers really do fit, and that the whole thing works with either (10 tests, the same behaviour suite over both):

"""Tests for the layered library: one behaviour suite over both providers of the Catalogue interface, plus persistence.""" import os import tempfile import unittest from datetime import date from library.application import Catalogue, LoanDesk from library.domain import Copy, Member, Refusal from library.infrastructure import InMemoryCatalogue, JsonCatalogue TODAY = date(2026, 10, 10) def seed(catalogue): if isinstance(catalogue, JsonCatalogue): catalogue.add(member=Member("M1", date(2027, 1, 1), "standard")) catalogue.add(member=Member("J1", date(2027, 1, 1), "junior")) for code in ("B1", "B2", "B3", "B4"): catalogue.add(copy=Copy(code)) catalogue.save() return catalogue class Behaviour: def catalogue(self): raise NotImplementedError def test_provider_satisfies_the_required_interface(self): self.assertIsInstance(self.catalogue(), Catalogue) def test_borrow_and_return_with_a_fine(self): desk = LoanDesk(self.catalogue()) self.assertEqual(desk.borrow("M1", "B1", TODAY).due_on, date(2026, 10, 31)) self.assertEqual(desk.return_book("B1", date(2026, 11, 3)), 30) def test_junior_limit_and_junior_fine(self): desk = LoanDesk(self.catalogue()) for code in ("B1", "B2", "B3"): desk.borrow("J1", code, TODAY) with self.assertRaises(Refusal): desk.borrow("J1", "B4", TODAY) self.assertEqual(desk.return_book("B1", date(2026, 11, 3)), 15) def test_refusals(self): desk = LoanDesk(self.catalogue()) for card, code in (("NOPE", "B1"), ("M1", "ZZZ")): with self.assertRaises(Refusal): desk.borrow(card, code, TODAY) with self.assertRaises(Refusal): desk.return_book("B1", TODAY) class InMemory(Behaviour, unittest.TestCase): def catalogue(self): return InMemoryCatalogue([Member("M1", date(2027, 1, 1), "standard"), Member("J1", date(2027, 1, 1), "junior")], [Copy(c) for c in ("B1", "B2", "B3", "B4")]) class JsonFile(Behaviour, unittest.TestCase): def setUp(self): self.dir = tempfile.TemporaryDirectory() self.path = os.path.join(self.dir.name, "library.json") def tearDown(self): self.dir.cleanup() def catalogue(self): return seed(JsonCatalogue(self.path)) def test_state_survives_a_restart(self): desk = LoanDesk(self.catalogue()) desk.borrow("M1", "B1", TODAY) fresh = LoanDesk(JsonCatalogue(self.path)) # a brand-new object reading the file self.assertEqual(fresh.return_book("B1", date(2026, 10, 31)), 0) with self.assertRaises(Refusal): # and that return was saved too LoanDesk(JsonCatalogue(self.path)).return_book("B1", date(2026, 10, 31)) def test_no_temporary_files_are_left_behind(self): LoanDesk(self.catalogue()).borrow("M1", "B1", TODAY) self.assertEqual(os.listdir(self.dir.name), ["library.json"]) if __name__ == "__main__": unittest.main()
.......... ---------------------------------------------------------------------- Ran 10 tests in 0.032s OK

And the command-line front end, run for real against a JSON file. It borrows a copy, is refused a second loan of it, returns it late, and the file keeps the result:

"""A command-line front end for the lending desk. py -m library.ui.cli --data library.json init py -m library.ui.cli --data library.json borrow M1 B1 --today 2026-10-10 py -m library.ui.cli --data library.json return B1 --today 2026-11-03 """ import argparse import sys from datetime import date from library.application import LoanDesk from library.domain import Copy, Member, Refusal from library.infrastructure import JsonCatalogue def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(prog="library") parser.add_argument("--data", required=True) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("init") b = sub.add_parser("borrow") b.add_argument("card") b.add_argument("barcode") b.add_argument("--today", default=date.today().isoformat()) r = sub.add_parser("return") r.add_argument("barcode") r.add_argument("--today", default=date.today().isoformat()) args = parser.parse_args(argv) catalogue = JsonCatalogue(args.data) if args.command == "init": catalogue.add(member=Member("M1", date(2027, 12, 31), "standard")) catalogue.add(member=Member("J1", date(2027, 12, 31), "junior")) for code in ("B1", "B2", "B3"): catalogue.add(copy=Copy(code)) catalogue.save() print("initialised:", args.data) return 0 desk = LoanDesk(catalogue) try: if args.command == "borrow": loan = desk.borrow(args.card, args.barcode, date.fromisoformat(args.today)) print(f"loan recorded: {loan.barcode} due {loan.due_on}") else: fine = desk.return_book(args.barcode, date.fromisoformat(args.today)) print(f"returned; fine {fine}p") except Refusal as e: print(f"refused: {e}") return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
$ py -m library.ui.cli --data library.json init initialised: library.json $ py -m library.ui.cli --data library.json borrow M1 B1 --today 2026-10-10 loan recorded: B1 due 2026-10-31 $ py -m library.ui.cli --data library.json borrow J1 B1 --today 2026-10-10 (the same copy) refused: already on loan $ py -m library.ui.cli --data library.json return B1 --today 2026-11-03 returned; fine 30p the file the desk wrote (first member only): { "card": "M1", "expires_on": "2027-12-31", "kind": "standard", "fines_pence": 30, "loans": [ { "barcode": "B1", "borrowed_on": "2026-10-10", "due_on": "2026-10-31", "returned_on": "2026-11-03" } ] }

Package Diagrams and Dependency Direction

A package is a grouping of code (in Python, a directory with an __init__.py). A package diagram shows packages and the dependencies between them: an arrow from A to B means "A's code uses B's code" (in Python, almost always an import). The diagram is easy to draw and easy to let rot, so here it is generated from the imports:

uiinfrastructureapplicationdomain

PlantUML, generated by deps.py from the real imports. The infrastructure package depends on the domain, not on the application (see above). Everything points toward domain.

The architecture has rules about those arrows. This one is the common "layered" shape: the domain depends on nothing; the application depends on the domain; the infrastructure serves the application; the UI sits on top and wires things together (it is the only place that chooses JsonCatalogue). Written as data, with two further rules, in a script that reads the code:

"""Read the imports of the `library` package, build its package dependency graph, and check three rules. py deps.py [path-to-folder-containing-library] [--puml out.puml] R1 direction : which packages may depend on which (the architecture's rule, written down as data) R2 acyclic : no cycle between packages R3 public API: other packages import a package, never its inner modules (library.domain, not library.domain.model) """ import ast import pathlib import sys ALLOWED = { # package -> packages it may import "domain": set(), "application": {"domain"}, "infrastructure": {"domain", "application"}, "ui": {"domain", "application", "infrastructure"}, } def package_of(path: pathlib.Path, root: pathlib.Path) -> str: rel = path.relative_to(root / "library").parts return rel[0] if len(rel) > 1 else "(root)" def collect(root: pathlib.Path): edges = [] for path in sorted((root / "library").rglob("*.py")): src = package_of(path, root) for node in ast.walk(ast.parse(path.read_text(encoding="utf8"))): names = [] if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("library."): names.append(node.module) elif isinstance(node, ast.Import): names += [a.name for a in node.names if a.name.startswith("library.")] for module in names: parts = module.split(".") dst = parts[1] if dst != src: edges.append({"from": src, "to": dst, "file": str(path.relative_to(root)), "line": node.lineno, "module": module, "deep": len(parts) > 2}) return edges def find_cycles(edges) -> list[list[str]]: graph: dict[str, set[str]] = {} for e in edges: graph.setdefault(e["from"], set()).add(e["to"]) cycles, state = [], {} def visit(node, path): state[node] = 1 for nxt in sorted(graph.get(node, ())): if state.get(nxt) == 1: cycles.append(path[path.index(nxt):] + [nxt]) elif nxt not in state: visit(nxt, path + [nxt]) state[node] = 2 for n in sorted(graph): if n not in state: visit(n, [n]) return cycles def check(root: pathlib.Path, show_edges: bool = True) -> int: files = list((root / "library").rglob("*.py")) edges = collect(root) print(f"analysed {len(files)} files, found {len(edges)} cross-package imports") if not files: print("result: NOTHING WAS ANALYSED, so 'clean' would mean nothing: is the path right?") return 1 if not edges: print("note: no imports between packages at all, so there is nothing for the rules to check (fine for independent packages)") if show_edges: print("package dependencies found in the code (from -> to : first place):") seen = set() for e in edges: key = (e["from"], e["to"]) if key not in seen: seen.add(key) print(f" {e['from']:<15} -> {e['to']:<15} {e['file']}:{e['line']} ({e['module']})") problems = 0 print("R1 direction:") for e in edges: if e["from"] in ALLOWED and e["to"] not in ALLOWED[e["from"]]: problems += 1 print(f" VIOLATION {e['from']} may not depend on {e['to']}: {e['file']}:{e['line']} imports {e['module']}") print("R2 no cycles:") cycles = find_cycles(edges) for c in cycles: problems += 1 print(" VIOLATION cycle: " + " -> ".join(c)) print("R3 public API only:") for e in edges: if e["deep"]: problems += 1 print(f" VIOLATION {e['file']}:{e['line']} reaches inside {e['to']} ({e['module']}); import library.{e['to']} instead") print("result:", "clean" if problems == 0 else f"{problems} violation(s)") return problems def to_plantuml(edges) -> str: lines = ["@startuml", "scale 1.3", "left to right direction"] for pkg in ("ui", "infrastructure", "application", "domain"): lines.append(f'package "{pkg}" as {pkg}') for key in sorted({(e["from"], e["to"]) for e in edges}): lines.append(f"{key[0]} ..> {key[1]}") lines.append("@enduml") return "\n".join(lines) + "\n" if __name__ == "__main__": argv = sys.argv[1:] puml = None if "--puml" in argv: i = argv.index("--puml") puml = pathlib.Path(argv[i + 1]) del argv[i:i + 2] root = pathlib.Path(argv[0]) if argv else pathlib.Path(__file__).parent code = check(root) if puml: puml.write_text(to_plantuml(collect(root)), encoding="utf8") sys.exit(1 if code else 0)

The three rules, with their output on the real tree:

  • R1 direction: which package may import which. The domain importing the UI would be a violation.
  • R2 no cycles: if the domain imports infrastructure and infrastructure imports the domain, neither can be understood, built or tested without the other.
  • R3 public API only: each package's __init__.py lists its public names (__all__), and other packages import from the package, not from its inner modules. That is a module boundary you can see and check: inside domain, files can be reorganised freely.
analysed 11 files, found 7 cross-package imports package dependencies found in the code (from -> to : first place): application -> domain library\application\desk.py:5 (library.domain) infrastructure -> domain library\infrastructure\jsonfile.py:8 (library.domain) ui -> application library\ui\cli.py:11 (library.application) ui -> domain library\ui\cli.py:12 (library.domain) ui -> infrastructure library\ui\cli.py:13 (library.infrastructure) R1 direction: R2 no cycles: R3 public API only: result: clean

The Check Can Fail

A rule checker that has never reported a violation has not been shown to work. I copied the library, added two shortcuts a hurried developer might add (the domain importing the infrastructure to build a default storage; the UI importing the domain's inner module), and ran it:

analysed 11 files, found 8 cross-package imports package dependencies found in the code (from -> to : first place): R1 direction: VIOLATION domain may not depend on infrastructure: library\domain\model.py:3 imports library.infrastructure R2 no cycles: VIOLATION cycle: domain -> infrastructure -> domain R3 public API only: VIOLATION library\ui\cli.py:12 reaches inside domain (library.domain.model); import library.domain instead result: 3 violation(s)

All three problems are reported with file and line. And here is why R2 matters: the same tampered copy does not merely look untidy, it does not run:

$ py -c "import library.ui.cli" (the tree with the shortcut in domain/model.py) from library.infrastructure.jsonfile import JsonCatalogue File "C:\lwdj\ooad\ch9\bad\library\infrastructure\jsonfile.py", line 8, in <module> from library.domain import Copy, Loan, Member ImportError: cannot import name 'Copy' from partially initialized module 'library.domain' (most likely due to a circular import) (C:\lwdj\ooad\ch9\bad\library\domain\__init__.py) $ py -c "import library.domain" File "C:\lwdj\ooad\ch9\bad\library\infrastructure\jsonfile.py", line 8, in <module> from library.domain import Copy, Loan, Member ImportError: cannot import name 'Copy' from partially initialized module 'library.domain' (most likely due to a circular import) (C:\lwdj\ooad\ch9\bad\library\domain\__init__.py)

Python sometimes tolerates import cycles, depending on which module is loaded first, so a cycle can lurk until an unrelated change reorders the imports. Do not rely on luck; check.

The Rules as a Test

A rule nobody runs is a hope. test_architecture.py runs the check on the real tree (it must find no violations) and on a deliberately tampered copy (it must find some):

"""The architecture rules as a test, so a dependency in the wrong direction fails the build like any other bug.""" import contextlib import io import pathlib import shutil import tempfile import unittest import deps HERE = pathlib.Path(__file__).parent def violations(root: pathlib.Path) -> int: with contextlib.redirect_stdout(io.StringIO()): return deps.check(root, show_edges=False) class Architecture(unittest.TestCase): def test_the_library_follows_its_dependency_rules(self): self.assertEqual(violations(HERE), 0) def test_the_check_can_fail(self): """Tested on a copy with a deliberate bad import, so the real tree is never touched.""" with tempfile.TemporaryDirectory() as d: root = pathlib.Path(d) shutil.copytree(HERE / "library", root / "library") model = root / "library" / "domain" / "model.py" model.write_text("from library.ui import cli\n" + model.read_text(encoding="utf8"), encoding="utf8") self.assertGreaterEqual(violations(root), 1) if __name__ == "__main__": unittest.main()
---------------------------------------------------------------------- Ran 2 tests in 0.123s OK

Known gap, left for Exercise 1: a package that is not listed in the rules is not checked at all. A new package that imports the UI passes as "clean" until someone writes its rules down.

Keeping Dependencies Acyclic

  • Invert the dependency. If A needs B and B needs A, declare an interface in A's package for what A needs and let B provide it (the Catalogue pattern).
  • Move the shared piece down. If two packages need the same type, put it in a package both may depend on (the domain).
  • Merge packages that cannot be understood apart: a cycle may be telling you they are one thing.
  • Check automatically, as above, so a cycle fails the build the day it is introduced.

Deployment Diagrams

A deployment diagram shows where software runs: nodes (machines or execution environments, drawn as 3D boxes) and the artifacts placed on them (programs, files), with lines for how nodes communicate. It answers "what has to be installed where?". The library's, as I would propose it for a small branch:

Desk PC (Windows or Linux)Python 3.14 runtimeBackup drivelibrary.json(the data file)library package(library/...)library.json copies(dated)reads and writescopied nightly(proposed, not built)

PlantUML. A proposal; nothing was deployed. The program and its data file are on the desk PC; the nightly copy to a backup drive is marked "proposed, not built".

@startuml scale 1.3 node "Desk PC (Windows or Linux)" as PC { node "Python 3.14 runtime" as Py { artifact "library package\n(library/...)" as Pkg } artifact "library.json\n(the data file)" as Data } node "Backup drive" as Backup { artifact "library.json copies\n(dated)" as Copies } Pkg ..> Data : reads and writes Data ..> Copies : copied nightly\n(proposed, not built) @enduml

Drawing the deployment found a real problem, because the obvious next question is "what if there are two desks?". The JSON catalogue reads the whole file when it starts and rewrites it after every change. Two desks sharing one file, both started before either lent anything:

"""Two desks sharing one data file: what does the JSON catalogue do when both write?""" import os import tempfile from datetime import date from library.application import LoanDesk from library.domain import Copy, Member from library.infrastructure import JsonCatalogue with tempfile.TemporaryDirectory() as d: path = os.path.join(d, "library.json") seed = JsonCatalogue(path) seed.add(member=Member("M1", date(2027, 1, 1), "standard")) seed.add(member=Member("J1", date(2027, 1, 1), "junior")) for code in ("B1", "B2"): seed.add(copy=Copy(code)) seed.save() desk_a = LoanDesk(JsonCatalogue(path)) # desk A loads the file desk_b = LoanDesk(JsonCatalogue(path)) # desk B loads the same file, at the same moment desk_a.borrow("M1", "B1", date(2026, 10, 10)) # A lends B1 and saves desk_b.borrow("J1", "B2", date(2026, 10, 10)) # B lends B2 and saves, from its older picture of the world final = JsonCatalogue(path) print("after both desks have saved, the file says:") for code in ("B1", "B2"): print(f" copy {code}: on loan = {final.copy(code).on_loan}") print(" M1's loans:", [l.barcode for l in final.member("M1").loans], "| J1's loans:", [l.barcode for l in final.member("J1").loans])
after both desks have saved, the file says: copy B1: on loan = False copy B2: on loan = True M1's loans: [] | J1's loans: ['B2']

Desk A's loan vanished: copy B1 is not on loan and member M1 has no loans, though A saw its loan recorded. Desk B saved from an older picture of the world and overwrote A's change, with no error. The JSON component is fine for one desk and is a correctness bug for two. The deployment diagram has to say "one desk PC", or the storage component has to be replaced (a database that handles concurrent writers) behind the same Catalogue interface, which is exactly what the component diagram makes cheap. I did not build that replacement.

Object Diagrams

An object diagram is a snapshot: specific instances, with their actual field values, and the links between them at one moment. Where a class diagram says "a member has any number of loans", an object diagram shows Ada has these two. It is for examples: explaining a tricky situation, or checking that your model can represent one. The script below writes one from live objects after a short session (two loans for M1, one returned three days late so that a fine was charged, one loan for the junior member):

"""Walk live dataclass objects and write a PlantUML object diagram: instances as boxes with field values, object-valued fields as links.""" import dataclasses names: dict[int, str] = {} labels: dict[int, str] = {} def name_of(obj) -> str: if id(obj) not in names: label = {"Member": getattr(obj, "card", ""), "Copy": getattr(obj, "barcode", ""), "Loan": getattr(obj, "barcode", "")}[type(obj).__name__] names[id(obj)] = f"{type(obj).__name__.lower()}_{len(names)}" labels[id(obj)] = f"{label.lower()} : {type(obj).__name__}" return names[id(obj)] def walk(roots) -> str: lines, links, seen = ["@startuml", "scale 1.2", "hide empty members"], [], set() queue = list(roots) while queue: obj = queue.pop(0) if id(obj) in seen: continue seen.add(id(obj)) ident = name_of(obj) lines.append(f'object "{labels[id(obj)]}" as {ident} {{') for f in dataclasses.fields(obj): value = getattr(obj, f.name) children = value if isinstance(value, list) else [value] if children and all(dataclasses.is_dataclass(c) for c in children): for c in children: queue.append(c) links.append((ident, c, f.name)) elif not isinstance(value, list): lines.append(f" {f.name} = {value}") lines.append("}") for src, child, label in links: lines.append(f"{src} --> {name_of(child)} : {label}") lines.append("@enduml") return "\n".join(lines) + "\n"
"""Draw an OBJECT diagram of what is really in memory: run a few operations, then walk the live objects and write PlantUML. Instances become boxes with their field values; fields that hold other objects (or lists of them) become links. """ import pathlib from datetime import date from library.application import LoanDesk from library.domain import Copy, Member from library.infrastructure import InMemoryCatalogue from objectdiagram import walk catalogue = InMemoryCatalogue([Member("M1", date(2027, 1, 1), "standard"), Member("J1", date(2027, 1, 1), "junior")], [Copy("B1"), Copy("B2"), Copy("B3")]) desk = LoanDesk(catalogue) desk.borrow("M1", "B1", date(2026, 10, 10)) desk.borrow("M1", "B2", date(2026, 10, 10)) desk.return_book("B2", date(2026, 11, 3)) # three days late: a fine desk.borrow("J1", "B3", date(2026, 11, 3)) text = walk(list(catalogue.members.values()) + list(catalogue.copies.values())) pathlib.Path(__file__).with_name("diagrams").joinpath("snapshot.object.puml").write_text(text, encoding="utf8") print(text)
m1 : Membercard = M1expires_on = 2027-01-01kind_name = standardfines_pence = 30j1 : Membercard = J1expires_on = 2027-01-01kind_name = juniorfines_pence = 0b1 : Copybarcode = B1on_loan = Trueb2 : Copybarcode = B2on_loan = Falseb3 : Copybarcode = B3on_loan = Trueb1 : Loanbarcode = B1borrowed_on = 2026-10-10due_on = 2026-10-31returned_on = Noneb2 : Loanbarcode = B2borrowed_on = 2026-10-10due_on = 2026-10-31returned_on = 2026-11-03b3 : Loanbarcode = B3borrowed_on = 2026-11-03due_on = 2026-11-24returned_on = Noneloansloansloans

PlantUML, generated from the running objects: nothing typed by hand. M1's fine of 30 pence, the returned loan, and a copy that is back on the shelf are all real values.

Look for what it shows that the class diagram did not make obvious: the loans are linked to their members but not to their copies. A Loan holds the copy's barcode as text, not a reference to the Copy object, so the three copies sit unconnected beside the loans. That is a design fact (chapter 6's choice), and a few reasons it can be good (a loan record that survives a copy being deleted) and bad (nothing stops a loan pointing at a barcode that does not exist). Exercise 3 draws another scenario.

How These Relate to the C4 Model

The C4 model (Simon Brown; from memory, see Software Architecture Fundamentals 9 for the real treatment) describes software at four levels of zoom: context, containers, components and code. The diagrams in this course line up roughly like this:

C4 levelQuestion it answersNearest UML in this course
1 ContextWhat is the system and who and what does it talk to?The use case diagram's scope and actors (chapter 3)
2 ContainersWhat are the separately running, deployable things (programs, databases)?The deployment diagram's nodes and artifacts
3 ComponentsWhat are the major parts inside one container and how do they interact?The component diagram, backed by the package diagram
4 CodeHow is one component built?The class diagram (chapters 5 and 6)

The two are complementary, not rivals: C4 is a simple, consistent way to organise the zoom levels and name the boxes; UML supplies detailed notations (interfaces, states, interactions) C4 deliberately leaves out. Use C4's levels to decide which diagram to draw and UML's notation when a level needs detail.

Usual Mistakes

MistakeWhy it hurtsFix
Components named after files or classesThe diagram repeats the class diagramA component is a unit you could replace; name what it is for
The interface owned by the providerThe consumer depends on what the provider happens to offerDeclare it with the consumer; providers fit it
A package diagram drawn once, from memoryIt stops matching the code at the next importGenerate it from the imports and check rules in a test
Cycles accepted "for now"They get cemented; one can break importsInvert, move down, or merge
A deployment diagram with no failure storyIt hides single points of failure and concurrent writers (the two-desk loss above)Ask "what if there are two?" and "what if it dies?" of every node and artifact
Hand-drawn object diagrams of the happy caseThey prove the model handles only what you imaginedDraw the awkward situation (the limit, the late return) from real objects
Mistakes while preparing this chapter
  • My dependency checker said "clean" while checking nothing. Its first run printed an empty list of dependencies and "result: clean". My argument parser had taken the output file name after --puml as the folder to check, so it analysed zero files. A checker that finds nothing and reports success is worse than no checker. It now prints how many files and imports it analysed, and fails if it finds no files (run on a wrong path it prints "analysed 0 files, found 0 cross-package imports" and "NOTHING WAS ANALYSED"). Correction made in chapter 11: my first guard also failed when there were files but no imports between packages, which is legitimate for independent packages, and chapter 11's exercise 3 ran into it; the script now only fails on zero files. Takeaway: when a check passes, ask what it actually looked at.
  • I assumed an arrow that the code does not have. My rules allow infrastructure to import application (to implement its interface). The generated package diagram shows no such edge: with structural protocols, the storage classes never import the interface. I had drawn the component diagram's "provides" and half-expected a matching import. Takeaway: generate the diagram from the code, and be willing to be surprised by it.
  • A rule list with a hole. Packages not named in the rules are unchecked (above, and Exercise 1). I found it by trying to break my own checker with a new package, and I am leaving the hole in the chapter's script on purpose, with the exercise to close it; a reader should see that a checker's blind spots are a design decision.
  • A tidy-up that was not. The first version of the object-diagram generator numbered its boxes 0, 2, 4, 6, because labels and names shared a dictionary and doubled the counter. Harmless, but the sort of thing a reader trips on. I split them.

Hands-On Exercises

Exercise 1

Add a reporting package to a copy of the library whose code imports the UI (it should not). Show that the checker as written calls the tree clean, then write the package's rules and show the violation, then write the rule that would have caught the omission by itself (a package with no rules is an error).

๐Ÿ“„ View solution
Exercise 2

The bike shop has components Booking, Invoicing, PartsStock and two pricing components, StandardPricing and TradePricing. Invoicing requires a Pricing interface and a PartLookup interface. Draw the component diagram with provided and required interfaces, then write the Python skeleton with each interface declared beside the component that requires it, and tests that both pricing components plug in.

๐Ÿ“„ View solution
Exercise 3

Generate an object diagram of this situation from live objects: a junior member who already has three loans (the limit) asks for a fourth copy and is refused. Say what the diagram shows and what it cannot show.

๐Ÿ“„ View solution

Chapter 9 Quick Reference

  • Component: a replaceable unit with a boundary; provided interface (lollipop), required interface (socket); declare the interface with the consumer, so providers fit it
  • Package: a grouping of code; arrows = imports; generate the diagram from the code; rules: allowed directions, no cycles, public API only (__all__)
  • Check the rules as a test; a checker must be seen to fail, and must report how much it analysed; unlisted packages are a blind spot unless you forbid them
  • A cycle is not only untidy: here it broke the imports outright
  • Deployment: nodes and artifacts; ask "what if there are two?": two desks sharing the JSON file silently lost a loan
  • Object diagram: a snapshot of real instances and links; generate it from live objects, and draw the awkward case
  • C4 levels (context, containers, components, code) pick which diagram to draw; UML gives the detailed notation for a level
Coming next
Object-Oriented Design with UML 10 (From UML to Code and Back): mapping diagrams to Python (dataclass, ABC, Protocol, composition, enums), what each relationship becomes in code, a note on the same design in Java (labelled not run here), reverse engineering with pyreverse and what it shows and omits, and keeping diagrams honest.