UML and Code

Object-Oriented Design with UML

Course 1 ยท Chapter 10 ยท From UML to Code and Back

A diagram and the code it describes are two descriptions of one thing, and nothing keeps them in step. This chapter looks at the three movements between them: forward (from a diagram to code), reverse (from code to a diagram), and keeping them honest (noticing when they have drifted apart). Each is done for real with Python, and the results include some disappointments, which are the useful part.

What was run, and what was not
Run here: a generator I wrote (about 150 lines) that reads a Mermaid class diagram and writes a Python skeleton; tests of what that skeleton does; pyreverse from pylint 4.1.2 (with astroid 4.3.4, installed in a scratch virtual environment, not globally) run on the library from chapter 9 and on the generated code; a round-trip comparison; and a small drift check. Python 3.14, Mermaid 12.1.0 for rendering. Not run: anything in Java (Java is not installed here), so the Java column below is from general knowledge and was not compiled, and any commercial model-driven tool. The history of model-driven generation and why it faded is also general knowledge, not research.

What Each Diagram Element Becomes

Chapter 5 showed each relationship as running Python. Here they are in one table, with the Java equivalent for readers coming from there. The Java column was not compiled or run; treat it as a guide, not a tested reference.

In the diagramPythonJava (not run)
Class with attributes@dataclass with typed fields (or a plain class with __init__)A class with fields, or a record for an immutable value
OperationA method; the type hints carry the signatureA method
Visibility + - # ~Name conventions (_x, __x); not enforcedpublic private protected and package-private; enforced
Static member@staticmethod, @classmethod, a class attributestatic
Abstract class / operationABC with @abstractmethodabstract class, abstract method
Interface («interface»)typing.Protocol (structural) or an ABC (nominal)interface (nominal: a class must say implements)
Enumerationenum.Enumenum
GeneralisationSubclass: class Standard(Policy)extends
RealisationA class with the right methods (Protocol), or inheriting an ABCimplements
Association, multiplicity 1A field holding the object; required in __init__A field, set in the constructor
0..1Type | None = NoneA nullable field (or Optional)
0..* or 1..*list[Type] with field(default_factory=list)List<Type>
CompositionThe whole creates the parts (add_copy); parts hold no independent referenceSame: the whole creates and owns; final list
DependencyA parameter or local variable; no fieldSame
Constraint (1..*, "at most one open")Code you must write (a check in a method), plus a testSame

For illustration only, and not compiled, the library's Member, MemberKind and Loan would read something like this in Java:

// NOT COMPILED OR RUN: Java is not installed on the machine this course was written on public interface MemberKind { int loanLimit(); int finePerDay(); } public final class Standard implements MemberKind { public int loanLimit() { return 5; } public int finePerDay() { return 10; } } public record Loan(String barcode, java.time.LocalDate borrowedOn, java.time.LocalDate dueOn) {} public class Member { private final String card; private final MemberKind kind; private final java.util.List<Loan> loans = new java.util.ArrayList<>(); // composition: Member owns its Loans public Member(String card, MemberKind kind) { this.card = card; this.kind = kind; } }

The main differences to notice are the ones the table points at: Java enforces visibility and requires interfaces to be declared with implements, while Python's Protocol lets the "provides" arrow of chapter 9 exist only in the design.

Forward: From a Diagram to a Skeleton

The input is a Mermaid class diagram (the notation of chapter 5):

classDiagram class CopyStatus { <<enumeration>> AVAILABLE ON_LOAN WITHDRAWN } class FineCalculator { <<interface>> +fine_for(due_on: date, returned_on: date) int } class DailyRateFine { +pence_per_day: int +fine_for(due_on: date, returned_on: date) int } class Policy { <<abstract>> +limit()* int } class StandardPolicy class JuniorPolicy class Title { +isbn: str +name: str } class Copy { +barcode: str +status: CopyStatus +lend() None } class Member { +name: str +card: str +policy: Policy +borrow(copy: Copy, today: date) Loan } class Loan { +borrowed_on: date +due_on: date } DailyRateFine ..|> FineCalculator StandardPolicy --|> Policy JuniorPolicy --|> Policy Title "1" *-- "1..*" Copy Member "1" --> "0..*" Loan Loan "0..*" --> "1" Copy

A small generator reads it. Its source is the best documentation of what it does and what it cannot:

"""Generate a Python skeleton from a Mermaid class diagram (a small subset of the notation). py uml2py.py design.mmd generated_library.py Understood: <<enumeration>>, <<interface>>, <<abstract>>; attributes 'name: type'; operations 'name(a: t) ret' (a trailing * = abstract); relationships: --|> (generalisation), ..|> (realisation), *-- (composition), --> (association) with multiplicities. What it cannot do is the whole point of the chapter: it generates structure, and no behaviour. """ import re import sys from dataclasses import dataclass, field REL = re.compile(r'^\s*(\w+)\s*(?:"([^"]+)"\s*)?(--\|>|\.\.\|>|\*--|-->)\s*(?:"([^"]+)"\s*)?(\w+)\s*$') @dataclass class Cls: name: str kind: str = "class" # class | enumeration | interface | abstract attrs: list = field(default_factory=list) # (name, type) ops: list = field(default_factory=list) # (name, params, ret, abstract) values: list = field(default_factory=list) base: str | None = None realises: list = field(default_factory=list) parts: list = field(default_factory=list) # (type, multiplicity) composed refs: list = field(default_factory=list) # (type, multiplicity) associated def parse(text: str) -> dict[str, Cls]: classes: dict[str, Cls] = {} current = None for raw in text.splitlines(): line = raw.strip() if not line or line == "classDiagram": continue m = re.match(r"class (\w+)\s*(\{)?$", line) if m: current = classes.setdefault(m.group(1), Cls(m.group(1))) if not m.group(2): current = None continue if line == "}": current = None continue if current is not None: stereo = re.match(r"<<(\w+)>>", line) if stereo: current.kind = stereo.group(1) continue op = re.match(r"\+(\w+)\(([^)]*)\)(\*)?\s*(\w[\w\[\], |]*)?$", line) if op: current.ops.append((op.group(1), op.group(2), op.group(4) or "None", bool(op.group(3)))) continue attr = re.match(r"\+(\w+):\s*(\S+)$", line) if attr: current.attrs.append((attr.group(1), attr.group(2))) continue if current.kind == "enumeration" and re.match(r"^\w+$", line): current.values.append(line) continue raise ValueError(f"cannot read a member line in {current.name}: {line!r}") r = REL.match(line) if r: left, lm, arrow, rm, right = r.groups() classes.setdefault(left, Cls(left)) classes.setdefault(right, Cls(right)) if arrow == "--|>": classes[left].base = right elif arrow == "..|>": classes[left].realises.append(right) elif arrow == "*--": classes[left].parts.append((right, rm or "1")) else: classes[left].refs.append((right, rm or "1")) continue raise ValueError(f"cannot read line: {line!r}") return classes def link_name(target: str, mult: str) -> str: """The field name for a link: CamelCase to snake_case, plural for 'many' links (Copy -> copies, JobPart -> job_parts).""" name = re.sub(r"(?<!^)(?=[A-Z])", "_", target).lower() if mult in ("0..*", "1..*", "*"): name = name[:-1] + "ies" if name.endswith("y") else name + "s" return name def field_type(target: str, mult: str) -> tuple[str, str]: """(annotation, default) for a link to `target` with this multiplicity.""" if mult in ("0..*", "1..*", "*"): return f"list[{target}]", " = field(default_factory=list)" if mult == "0..1": return f"{target} | None", " = None" return target, "" def emit(classes: dict[str, Cls]) -> str: order = [c for c in classes.values() if c.kind == "enumeration"] + [c for c in classes.values() if c.kind == "interface"] placed = {c.name for c in order} rest = [c for c in classes.values() if c.name not in placed] while rest: # bases before subclasses for c in list(rest): if c.base is None or c.base in placed: order.append(c) placed.add(c.name) rest.remove(c) uses_date = any("date" in t for c in classes.values() for _, t in c.attrs) or any("date" in p or "date" in r for c in classes.values() for _, p, r, _ in c.ops) out = ['"""Generated from the class diagram by uml2py.py. Structure only: every operation is a stub."""', "from __future__ import annotations", "", "from abc import ABC, abstractmethod", "from dataclasses import dataclass, field"] if uses_date: out.append("from datetime import date") out += ["from enum import Enum", "from typing import Protocol, runtime_checkable", ""] for c in order: out += ["", ""] if c.kind == "enumeration": out.append(f"class {c.name}(Enum):") out += [f' {v} = "{v}"' for v in c.values] or [" pass"] elif c.kind == "interface": out += ["@runtime_checkable", f"class {c.name}(Protocol):"] out += [f" def {n}(self{', ' + p if p else ''}) -> {r}: ..." for n, p, r, _ in c.ops] or [" pass"] else: bases = [c.base] if c.base else (["ABC"] if c.kind == "abstract" else []) if c.kind != "abstract": out.append("@dataclass") out.append(f"class {c.name}({', '.join(bases)}):" if bases else f"class {c.name}:") body = [] required = [f" {n}: {t}" for n, t in c.attrs] defaulted = [] for target, mult in c.parts + c.refs: ann, default = field_type(target, mult) name = link_name(target, mult) (defaulted if default else required).append(f" {name}: {ann}{default}") if c.kind != "abstract": body += required + defaulted own = {n for n, _, _, _ in c.ops} stubs = list(c.ops) for iface in c.realises: # a realised interface's operations must exist here too stubs += [(n, p, r, a) for n, p, r, a in classes[iface].ops if n not in own] for n, p, r, abstract in stubs: if abstract or c.kind == "abstract" and abstract: body += ["", " @abstractmethod", f" def {n}(self{', ' + p if p else ''}) -> {r}: ..."] else: body += ["", f" def {n}(self{', ' + p if p else ''}) -> {r}:", f' raise NotImplementedError("{c.name}.{n}")'] out += body or [" pass"] return "\n".join(out) + "\n" if __name__ == "__main__": src, dst = sys.argv[1], sys.argv[2] code = emit(parse(open(src, encoding="utf8").read())) open(dst, "w", encoding="utf8", newline="\n").write(code) print(f"wrote {dst}: {len(code.splitlines())} lines")

The skeleton it wrote:

"""Generated from the class diagram by uml2py.py. Structure only: every operation is a stub.""" from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import date from enum import Enum from typing import Protocol, runtime_checkable class CopyStatus(Enum): AVAILABLE = "AVAILABLE" ON_LOAN = "ON_LOAN" WITHDRAWN = "WITHDRAWN" @runtime_checkable class FineCalculator(Protocol): def fine_for(self, due_on: date, returned_on: date) -> int: ... @dataclass class DailyRateFine: pence_per_day: int def fine_for(self, due_on: date, returned_on: date) -> int: raise NotImplementedError("DailyRateFine.fine_for") class Policy(ABC): @abstractmethod def limit(self) -> int: ... @dataclass class StandardPolicy(Policy): pass @dataclass class JuniorPolicy(Policy): pass @dataclass class Title: isbn: str name: str copies: list[Copy] = field(default_factory=list) @dataclass class Copy: barcode: str status: CopyStatus def lend(self) -> None: raise NotImplementedError("Copy.lend") @dataclass class Member: name: str card: str policy: Policy loans: list[Loan] = field(default_factory=list) def borrow(self, copy: Copy, today: date) -> Loan: raise NotImplementedError("Member.borrow") @dataclass class Loan: borrowed_on: date due_on: date copy: Copy

What the Skeleton Keeps, and What It Lost

Nine tests ask the skeleton what the diagram promised. Six check promises the code keeps; three record what the diagram never said (note the second group's names):

"""What does the generated skeleton actually do? Each test states a claim the diagram makes, and checks the code keeps it.""" import unittest from datetime import date import generated_library as g class WhatTheDiagramPromises(unittest.TestCase): def test_enumeration(self): self.assertEqual([s.name for s in g.CopyStatus], ["AVAILABLE", "ON_LOAN", "WITHDRAWN"]) with self.assertRaises(ValueError): g.CopyStatus("LOST") def test_abstract_class_cannot_be_created(self): with self.assertRaises(TypeError): g.Policy() def test_composition_gives_the_whole_a_list_of_parts(self): t = g.Title("111", "Dune") t.copies.append(g.Copy("B1", g.CopyStatus.AVAILABLE)) self.assertEqual(len(t.copies), 1) def test_association_with_exactly_one_is_a_required_field(self): with self.assertRaises(TypeError): g.Loan(date(2026, 10, 10), date(2026, 10, 31)) # the diagram says a Loan has exactly one Copy def test_the_realising_class_has_the_interface_operation(self): self.assertIsInstance(g.DailyRateFine(10), g.FineCalculator) def test_operations_are_stubs(self): with self.assertRaises(NotImplementedError): g.DailyRateFine(10).fine_for(date(2026, 10, 10), date(2026, 10, 12)) class WhatTheDiagramDidNotSay(unittest.TestCase): def test_the_concrete_policies_cannot_be_created_yet(self): """The diagram draws StandardPolicy under Policy but, as UML conventionally does, does not repeat limit() in the subclass box. The generated StandardPolicy therefore inherits an abstract operation it never implements.""" with self.assertRaises(TypeError): g.StandardPolicy() def test_the_generation_gap_way_to_finish_it(self): """Do not edit the generated file (the next generation overwrites it): add behaviour in a hand-written subclass.""" class Standard(g.StandardPolicy): def limit(self) -> int: return 5 self.assertEqual(Standard().limit(), 5) def test_nothing_enforces_the_rules_the_diagram_could_not_say(self): """Multiplicity 1..* (a Title has at least one Copy) is not enforced: a Title can be created with none.""" self.assertEqual(g.Title("111", "Dune").copies, []) if __name__ == "__main__": unittest.main(verbosity=2)
---------------------------------------------------------------------- Ran 9 tests in 0.000s OK
  • Kept: the enumeration rejects an unknown value; the abstract class cannot be created; composition gave Title a list of Copy; a link of multiplicity exactly one is a required field (omitting it raises TypeError); the realising class has the interface's operation; the operations are stubs.
  • Not said, and therefore broken: StandardPolicy and JuniorPolicy cannot be created. The diagram draws them under Policy and, as UML conventionally does, does not repeat the inherited operation limit() in each subclass box, but the code needs each concrete class to implement it. A diagram can leave out what a compiler cannot.
  • Not expressible: multiplicity 1..* (a title has at least one copy) is not enforced; a Title is created with none. The generator cannot turn a constraint into behaviour.

Never Edit the Generated File

Generated code that you edit is overwritten the next time you generate, and generated code that you do not edit stays a stub. The standard way out is the generation gap: keep the generated classes untouched and add behaviour in hand-written subclasses in another file. One test above does exactly that (class Standard(g.StandardPolicy) with limit()). Exercise 1 does it for a whole design.

What the generator cannot do at all is the point: it produced structure, and none of the behaviour (the fines, the limits, the rules). Behaviour is chapters 7 and 8's diagrams plus your head. Forward generation is a head start on boilerplate, not on design.

Reverse: From Code to a Diagram

pyreverse, part of pylint, reads Python source and writes class and package diagrams (I used its Mermaid output; its help lists other output formats, which I did not try). I ran it on the library from chapter 9 (pyreverse -o mmd -p library library) and rendered the Mermaid it wrote:

catalogue

Catalogue

copy(barcode: str) : Copy

member(card: str) : Member

open_loan_for(barcode: str) : tuple[Member, Loan]

save() : None

Copy

barcode : str

on_loan : bool

lend() : None

take_back() : None

InMemoryCatalogue

copies

members

copy(barcode: str) : Copy

member(card: str) : Member

open_loan_for(barcode: str) : tuple[Member, Loan]

save() : None

JsonCatalogue

path : Path

add(member: Member | None, copy: Copy | None) : None

copy(barcode)

member(card)

open_loan_for(barcode)

save() : None

Junior

fine_per_day() : int

loan_limit() : int

Loan

barcode : str

borrowed_on : date

due_on : date

returned_on : date | None

close(today: date) : None

fine(today: date, kind: MemberKind) : int

is_open() : bool

LoanDesk

catalogue

borrow(card: str, barcode: str, today: date) : Loan

return_book(barcode: str, today: date) : int

Member

card : str

expires_on : date

fines_pence : int

kind : MemberKind

kind_name : str

loans : list[Loan]

borrow(copy: Copy, today: date) : Loan

check_can_borrow(today: date) : None

give_back(loan: Loan, copy: Copy, today: date) : int

open_loans() : list[Loan]

MemberKind

fine_per_day() : int

loan_limit() : int

Refusal

Standard

fine_per_day() : int

loan_limit() : int

Mermaid rendered from pyreverse's output on the real code; I changed nothing. It is wide because it is one row of eleven boxes with only two connected groups.

Compared with the diagram I drew by hand for the same design, this is what it shows well:

  • Every class and every method with its types, read from the code (fine(today: date, kind: MemberKind) int): more complete and more exact than my hand drawing.
  • Inheritance (Junior, Standard to MemberKind), and a hollow-diamond link where one class receives another in its constructor (LoanDesk holds a Catalogue).

And what it leaves out or gets wrong. I checked each against the code:

What the code hasWhat pyreverse drew
Member.loans: list[Loan], a member owns its loansThe text loans : list[Loan] in the box, no arrow to Loan: a collection of objects is not drawn as an association
InMemoryCatalogue and JsonCatalogue provide CatalogueNo realisation arrows. They match the Protocol by shape and never name it, so nothing in the source says "implements". Catalogue appears as a plain class, not an interface
InMemoryCatalogue.save() is an empty method that does nothing, on purposeDrawn as save()*: marked abstract, which it is not. The tool treats an empty-bodied method as abstract. The same happened to stubs that raise NotImplementedError in the generated code
Member.kind, a property computed from kind_nameBoth kind : MemberKind and kind_name : str as attributes, with no link to MemberKind
Loan refers to its copy by barcode text (chapter 9's object diagram)Nothing: correctly, there is no link in the code
Refusal, an exception class used everywhereA lone empty box, connected to nothing
Multiplicities, roles, constraints, and the reason for any design choiceNone: the code does not contain them

Its package diagram is also a different thing from chapter 9's. It draws modules as boxes (desk, ports, model, jsonfile and so on) with 14 import arrows, where chapter 9's script reduced the same code to four packages and five arrows with rules attached. Neither is wrong; the second answers an architectural question and the first lists imports.

The Round Trip

The cleanest test of reverse engineering is to go diagram, code, diagram: reverse-engineer the generated code from above and compare the relationships with those in the original design:

"""Diagram -> code -> diagram. Reverse-engineer code with pyreverse and compare the result with the diagram the code was generated from. py roundtrip.py (design.mmd against generated_library.py) """ import pathlib import re import subprocess import uml2py HERE = pathlib.Path(__file__).parent PYREVERSE = HERE.parent / "pylintenv" / "Scripts" / "pyreverse.exe" def expected_relationships(design: dict) -> set: """What the design says, as (kind, a, b). An attribute whose type is another class is not counted: the design wrote it as an attribute.""" out = set() for c in design.values(): if c.base: out.add(("inherits", c.name, c.base)) for i in c.realises: out.add(("realises", c.name, i)) for t, _ in c.parts + c.refs: out.add(("has", c.name, t)) return out def found_relationships(mermaid: str) -> set: out = set() for line in mermaid.splitlines(): m = re.match(r"\s*(\w+) --\|> (\w+)", line) if m: out.add(("inherits", m.group(1), m.group(2))) m = re.match(r"\s*(\w+) --([*o]) (\w+)", line) # 'Part --* Whole' and 'Part --o Whole': the whole is on the right if m: out.add(("has", m.group(3), m.group(1))) m = re.match(r"\s*(\w+) --> (\w+)", line) if m: out.add(("has", m.group(1), m.group(2))) return out def compare(design_path, code_path, workdir) -> str: design = uml2py.parse(pathlib.Path(design_path).read_text(encoding="utf8")) out = pathlib.Path(workdir) out.mkdir(exist_ok=True) subprocess.run([str(PYREVERSE), "-o", "mmd", "-p", "rt", str(code_path)], cwd=out, capture_output=True, check=True) recovered = (out / "classes_rt.mmd").read_text(encoding="utf8") expected, found = expected_relationships(design), found_relationships(recovered) lines = [f"design: {len(design)} classes, {len(expected)} relationships | recovered from the code: {len(found)}"] lines += [f" in the design, NOT recovered : {e[1]} {e[0]} {e[2]}" for e in sorted(expected - found)] lines += [f" recovered, NOT in the design : {f[1]} {f[0]} {f[2]}" for f in sorted(found - expected)] lines += [f" recovered : {e[1]} {e[0]} {e[2]}" for e in sorted(expected & found)] return "\n".join(lines) if __name__ == "__main__": print(compare(HERE / "design.mmd", HERE / "generated_library.py", HERE / "rt"))
design: 10 classes, 6 relationships | recovered from the code: 5 in the design, NOT recovered : Member has Loan in the design, NOT recovered : Title has Copy in the design, NOT recovered : DailyRateFine realises FineCalculator recovered, NOT in the design : Copy has CopyStatus recovered, NOT in the design : Member has Policy recovered : Loan has Copy recovered : JuniorPolicy inherits Policy recovered : StandardPolicy inherits Policy

Three relationships made the whole trip (inheritance twice, and Loan to Copy). Three were lost: Member to Loan and Title to Copy (both collections), and the realisation of FineCalculator. Two appeared that were not in the relationship list: Member to Policy and Copy to CopyStatus. Those two are not errors: my design wrote policy: Policy as an attribute, and pyreverse correctly reads an attribute of another class's type as an association. The difference is notational, and it is a reminder that the same fact can be drawn as an attribute or as an arrow. The lesson: a round trip is lossy. Whatever you draw by hand should not depend on a tool being able to recover it.

Model-Driven Generation, and Why It Faded

From general knowledge, not researched for this course: in the 2000s there was a strong push for model-driven development, where the model, not the code, was the source: tools generated whole applications from UML and you never edited the output (Fowler's "UML as programming language" mode from chapter 1). It faded for mostly practical reasons. Behaviour needed a code-like language anyway, so the model became a clumsy programming language. Generated code was awkward to read and debug. Keeping model and hand-written code in step was painful (the generation-gap problem above, at scale), and the heavy tools got in the way of ordinary tools: version control, diffing, code review, refactoring. What survived are the narrow forms: generating code from interface and schema definitions, ORM models, and API descriptions, where the input is small, precise and has no behaviour. Mine, above, is the same narrow form.

Keeping Diagrams Honest

A diagram that disagrees with the code is worse than none: it is trusted and wrong. The choices, from cheapest up:

  • Draw less, and say when. A small sketch dated at the top, kept as text next to the code, is honest about being a snapshot.
  • Generate it (pyreverse; chapter 9's package diagram; the object diagram), so it cannot drift, at the price of being only what the tool can read.
  • Check it. Chapter 7's conformance test checks a sequence diagram against calls; chapter 9's rules check package dependencies; here is the equivalent for class diagrams, comparing the operations on the hand-drawn chapter 6 diagram with the methods in the code:
"""Has the hand-drawn class diagram drifted from the code? Compare the operations on each class in the diagram with the methods in the code.""" import ast import pathlib import re import sys HERE = pathlib.Path(__file__).parent def diagram_ops(path: pathlib.Path) -> dict[str, set[str]]: ops, current = {}, None for line in path.read_text(encoding="utf8").splitlines(): c = re.match(r"\s*class (\w+)", line) if c: current = c.group(1) ops.setdefault(current, set()) continue o = re.match(r"\s*\+(\w+)\(.*\)", line) if o and current: ops[current].add(o.group(1)) return ops def code_ops(path: pathlib.Path) -> dict[str, set[str]]: out = {} for node in ast.parse(path.read_text(encoding="utf8")).body: if isinstance(node, ast.ClassDef): out[node.name] = {f.name for f in node.body if isinstance(f, ast.FunctionDef) and not f.name.startswith("_")} return out if __name__ == "__main__": diagram, code = diagram_ops(HERE / "version_b.class.mmd"), code_ops(HERE / "library_b.py") print(f"diagram: {len(diagram)} classes | code: {len(code)} classes") for name in sorted(set(diagram) | set(code)): if name not in code: print(f" {name}: in the diagram, no such class in the code") elif name not in diagram: print(f" {name}: in the code, not in the diagram (methods: {sorted(code[name]) or 'none'})") else: gone, undrawn = sorted(diagram[name] - code[name]), sorted(code[name] - diagram[name]) if gone or undrawn: print(f" {name}:" + (f" drawn but not in the code {gone}" if gone else "") + (f" in the code but not drawn {undrawn}" if undrawn else "")) print(" (an empty report would mean the operations match exactly)")
diagram: 8 classes | code: 9 classes Junior: in the code but not drawn ['fine_per_day', 'loan_limit'] Member: in the code but not drawn ['open_loans'] Refusal: in the code, not in the diagram (methods: none) Standard: in the code but not drawn ['fine_per_day', 'loan_limit'] (an empty report would mean the operations match exactly)

It found no wrong operations, but it found that my chapter 6 diagram omitted some: Member.open_loans is not drawn, and the Junior and Standard overrides are not drawn (a deliberate convention, as above), plus Refusal, which I left out as noise. A check can list the differences; deciding which are deliberate is still a human job. The check is more useful when a change breaks something. Here someone renames check_can_be_lent to ensure_lendable in the code and does not touch the diagram:

Copy: drawn but not in the code ['check_can_be_lent']; in the code but not drawn ['ensure_lendable']

That is the signal you want in a build. Run it with the tests, and it fails the day the name changes, not the day someone is misled by the diagram.

What this check cannot catch
It compares names. It will not notice that a drawn operation's meaning changed, that a multiplicity is now wrong, or that an arrow's direction no longer matches the dependencies. For arrows, use the generated diagram or a dependency check; for behaviour, use tests.

Usual Mistakes

MistakeFix
Editing generated codeGeneration gap: subclass in a hand-written file, or generate once and own the result
Expecting reverse engineering to recover the designIt recovers structure, not intent: no multiplicities, roles, constraints, or reasons
Trusting a tool's picture without reading its legendpyreverse's * marked empty methods "abstract"; check against the code
A diagram nobody checksDate it, generate it, or put a drift check in the build
Treating a skeleton as progressBehaviour is the work; a generated skeleton is boilerplate, not design
Leaving the diagram silent on what the code needsIf a concrete subclass must implement an operation, say so, or the next reader (or generator) will not know
Mistakes while preparing this chapter
  • The generator had the plural bug from chapter 4. Its first output named the list of copies copys, the same "add an s" mistake I made in chapter 4's sentences, and I only saw it by reading the generated code. I had not carried the lesson into new code. It now maps a trailing "y" to "ies" and converts CamelCase to snake_case (job_parts). Takeaway: when you have made a mistake in one tool, grep your next tool for it.
  • I assumed pyreverse would draw the collection. I expected an arrow from Member to Loan. It drew only the text of the field type. I would not have noticed if I had not compared the picture with the code line by line. Takeaway: reverse-engineered diagrams are claims too; check them.
  • A refactoring script that broke its own file. I turned roundtrip.py into a function with a line-by-line text transformation and it failed with IndentationError. Rewriting the file by hand took less time than the script had saved. Takeaway: restructure code with an editor, not a string-munging script, and run it straight away.
  • The first design left a hole I did not see until the tests ran. The diagram drew the two concrete policies and no operations in them; the generated classes could not be created. It is in the chapter as a finding, but it was a surprise to me, and a good argument for generating code from a design early.

Hands-On Exercises

Exercise 1

Run the generator on the bike shop design in the solution file (an enumeration, an interface with an implementation, a composition and two associations). Leave the generated file alone and finish the behaviour by subclassing in a second file, with tests, including one that shows a required link cannot be omitted.

๐Ÿ“„ View solution
Exercise 2

Reverse-engineer the bike shop code from exercise 1 with pyreverse and compare the relationships with the design. List what was lost and what was gained, and say which losses are the tool's limits and which are limits of the code itself.

๐Ÿ“„ View solution
Exercise 3

Extend the drift idea from operations to attributes: compare the attributes in a class diagram with the fields in the code (allowing for the link fields the generator adds), show that an untouched generated file has no drift, and that a hand edit (renaming one field, adding another) is reported.

๐Ÿ“„ View solution

Chapter 10 Quick Reference

  • Class to dataclass; interface to Protocol (structural) or ABC; abstract to ABC; enumeration to Enum; generalisation to subclass; association to a field by multiplicity (1 required, 0..1 optional, many a list); composition = the whole creates the parts; constraints are code you must write
  • Forward generation gives structure and boilerplate, no behaviour; a diagram may omit what code needs (inherited operations the subclass must implement); never edit generated code, use the generation gap (subclass elsewhere)
  • pyreverse: shows classes, methods, types, inheritance; misses collections as associations, structural realisation, properties; marked an empty method abstract; a round trip is lossy
  • Model-driven generation as "the model is the program" faded; narrow generation (schemas, interfaces, ORM) survived (general knowledge)
  • Keep diagrams honest: draw less and date it, generate it, or check it (conformance, dependency rules, a drift check on names); a check compares names, not meaning
  • Java mapping given here was not compiled
Coming next
Object-Oriented Design with UML 11 (Patterns, Principles and Design Review): showing Strategy, Observer, State, Factory and Composite on a class diagram (linking to Design Patterns, not re-teaching), principles on diagrams, a design review checklist, spotting a god class, a cyclic dependency and an anemic model on a diagram, and refactoring the library on the diagram first and then in code.