A class diagram shows what exists. The harder design question is who does what: when the librarian returns a book late, which class works out the fine? Get the answer wrong and you get a diagram that looks fine and code where one class knows everything and the rest are bags of data. This chapter gives you a way to decide (CRC cards), a set of reasoning rules (GRASP), a name for the failure (the anemic domain model), and a measured comparison of the same behaviour written both ways.
What was run, and what was not; and how far the "measurements" go
I wrote the library's borrow and return behaviour twice in Python 3.14: version A (data classes plus one service holding all the rules) and version B (responsibilities assigned with GRASP). One suite of 26 behaviour tests runs against both and passes on both. Then I measured both with small scripts I wrote for this chapter, applied the same two change requests to each, and tried a deliberately incomplete change.
The caveats matter. I wrote both versions, so version A reflects my idea of a typical procedural design, not a proven average; the program is tiny (about 70 against 95 lines); my metrics are crude counts, not validated measures of quality; and one result below goes against the design B, which I report.
From general knowledge, not re-read: that GRASP is from Craig Larman's Applying UML and Patterns (nine patterns; this chapter covers seven), that CRC cards come from Kent Beck and Ward Cunningham, and that "anemic domain model" is Martin Fowler's term.
CRC Cards: Deciding Before Drawing
A CRC card is an index card for one class with three things on it: the Class name, its Responsibilities (things it knows or does, in plain words), and its Collaborators (the other classes it needs to do them). You lay the cards on a table and role-play a use case: "A member hands over a book. Who receives it?" Each time you say "and then someone must...", you either find a card that should do it or you write a new card. The physical limit (a card has room for a few lines) is the feature: a class with too many responsibilities will not fit, and that is a design signal.
Here are the cards for the library's borrow and return use cases, written before I compared them to the code:
Class
Responsibilities
Collaborators
LoanDesk
Receive "borrow" and "return" requests; find the member, copy and loan involved; hand the work to them
Catalogue, Member, Copy, Loan
Catalogue
Find a member by card; a copy by barcode; the open loan for a copy
Member, Copy, Loan
Member
Know card, expiry, fines, loans, kind; decide whether they may borrow; create their Loans; take a book back and add any fine
Loan, Copy, MemberKind
Loan
Know barcode and dates; say whether it is open; say how many days overdue; work out the fine for a kind of member
MemberKind
Copy
Know barcode and whether on loan; say whether it can be lent
(none)
MemberKind
Give the loan limit; give the fine per day
(none)
Cards are a thinking tool, but I wanted to check mine against something. The script below lists the class names each class's code actually mentions, and compares:
"""CRC cards for version B, written by hand first; then compared with the class names each class's code actually mentions."""
import ast
import pathlib
CARDS = {
"LoanDesk": {
"responsibilities": ["receive 'borrow' and 'return' requests", "find the member, copy and loan involved", "hand the work to them"],
"collaborators": {"Catalogue", "Member", "Copy", "Loan"},
},
"Catalogue": {
"responsibilities": ["find a member by card", "find a copy by barcode", "find the open loan for a copy"],
"collaborators": {"Member", "Copy", "Loan"},
},
"Member": {
"responsibilities": ["know card, expiry, fines, loans, kind", "decide whether they may borrow", "create their Loans", "take a book back and add any fine"],
"collaborators": {"Loan", "Copy", "MemberKind"},
},
"Loan": {
"responsibilities": ["know barcode and dates", "say whether it is open", "say how many days overdue", "work out the fine for a kind of member"],
"collaborators": {"MemberKind"},
},
"Copy": {"responsibilities": ["know barcode and whether on loan", "say whether it can be lent"], "collaborators": set()},
"MemberKind": {"responsibilities": ["give the loan limit", "give the fine per day"], "collaborators": set()},
}
def named_classes(path: str) -> dict[str, set[str]]:
tree = ast.parse(pathlib.Path(path).read_text(encoding="utf8"))
classes = {n.name for n in tree.body if isinstance(n, ast.ClassDef)}
out = {}
for node in tree.body:
if isinstance(node, ast.ClassDef):
names = {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and n.id in classes and n.id != node.name}
for base in node.bases:
names.discard(getattr(base, "id", ""))
out[node.name] = names - {"Refusal"}
return out
if __name__ == "__main__":
code = named_classes(str(pathlib.Path(__file__).with_name("library_b.py")))
for name, card in CARDS.items():
on_card, in_code = card["collaborators"], code.get(name, set())
print(f"{name}")
print(f" card says collaborates with : {sorted(on_card) or '-'}")
print(f" code names : {sorted(in_code) or '-'}")
only_card, only_code = sorted(on_card - in_code), sorted(in_code - on_card)
if only_card:
print(f" on the card, never named in the code: {only_card}")
if only_code:
print(f" named in the code, missing from the card: {only_code}")
LoanDesk
card says collaborates with : ['Catalogue', 'Copy', 'Loan', 'Member']
code names : ['Catalogue', 'Loan']
on the card, never named in the code: ['Copy', 'Member']
Catalogue
card says collaborates with : ['Copy', 'Loan', 'Member']
code names : ['Copy', 'Loan', 'Member']
Member
card says collaborates with : ['Copy', 'Loan', 'MemberKind']
code names : ['Copy', 'Loan', 'MemberKind']
Loan
card says collaborates with : ['MemberKind']
code names : ['MemberKind']
Copy
card says collaborates with : -
code names : -
MemberKind
card says collaborates with : -
code names : -
One mismatch, and it is instructive. My card says LoanDesk collaborates with Member and Copy, but its code never names either: it gets them from the catalogue and calls them in a chain (self.catalogue.member(card).borrow(...)). Both statements are true. The card describes who it works with at run time; the code shows which names it depends on. The second kind, the names a class depends on, is what makes a class costly to change. Also note that the script found only a difference of names: it cannot tell whether a responsibility is on the right card. That is still a judgement.
The Problem Both Versions Solve
The behaviour (from chapter 3's use cases, simplified): borrow checks the card is valid, no unpaid fines, under the loan limit (5 for standard members, 3 for juniors), and the copy is free, then records a 21-day loan; return finds the open loan, works out any fine (10p a day for standard members, 5p for juniors), records it, and frees the copy. Chapter 4's lesson is applied: items are identified by barcode (a Copy), not ISBN.
Version A: One Service, Passive Data
"""Version A: data classes plus one service that holds all the rules (the 'anemic domain model' style)."""
from dataclasses import dataclass, field
from datetime import date, timedelta
class Refusal(Exception):
pass
@dataclass
class Member:
card: str
expires_on: date
kind: str = "standard" # "standard" or "junior"
fines_pence: int = 0
loans: list = field(default_factory=list)
@dataclass
class Copy:
barcode: str
on_loan: bool = False
@dataclass
class Loan:
card: str
barcode: str
borrowed_on: date
due_on: date
returned_on: date | None = None
class LibraryService:
def __init__(self, members, copies):
self.members = {m.card: m for m in members}
self.copies = {c.barcode: c for c in copies}
self.loans: list[Loan] = []
def borrow(self, card: str, barcode: str, today: date) -> Loan:
member = self.members.get(card)
if member is None or member.expires_on < today:
raise Refusal("membership is not valid")
if member.fines_pence > 0:
raise Refusal("unpaid fines")
if member.kind == "standard":
limit = 5
elif member.kind == "junior":
limit = 3
else:
raise ValueError(member.kind)
open_loans = [l for l in self.loans if l.card == card and l.returned_on is None]
if len(open_loans) >= limit:
raise Refusal("loan limit reached")
copy = self.copies.get(barcode)
if copy is None:
raise Refusal("no such copy")
if copy.on_loan:
raise Refusal("already on loan")
loan = Loan(card, barcode, today, today + timedelta(days=21))
copy.on_loan = True
self.loans.append(loan)
member.loans.append(loan)
return loan
def return_book(self, barcode: str, today: date) -> int:
loan = next((l for l in self.loans if l.barcode == barcode and l.returned_on is None), None)
if loan is None:
raise Refusal("that copy is not on loan")
member = self.members[loan.card]
days_late = max(0, (today - loan.due_on).days)
if member.kind == "standard":
rate = 10
elif member.kind == "junior":
rate = 5
else:
raise ValueError(member.kind)
fine = days_late * rate
member.fines_pence += fine
loan.returned_on = today
self.copies[barcode].on_loan = False
return fine
Version A. Three classes of data, and one service that reads and changes all of them.
This is not an absurd design; it is how many first drafts look, and it has virtues: everything is in one place, and you can read the whole behaviour top to bottom. Its problem has a name.
The Anemic Domain Model
An anemic domain model (Fowler's term, from memory) has classes for the domain concepts, but they are only data with getters and setters, while all the behaviour lives in "service" classes that manipulate them. It looks object-oriented (there is a Member class) but works like procedural code over records. Version A is anemic: Member, Copy and Loan have no methods at all. The costs, to be checked against the measurements below:
The rules about a member are not with the member. To learn when a member may borrow, you read the service, not Member.
The service knows everything about everyone's fields, so any change to a data class can break it, and it grows with every rule.
Nothing protects the data's own invariants. Any code can set fines_pence or on_loan to anything.
A caution in the other direction: services are not evil. Something has to coordinate a use case across several objects (that is the Controller below), and some logic belongs to no single object. The smell is when all the behaviour is there and the domain classes are empty.
The GRASP Patterns That Matter
GRASP stands for General Responsibility Assignment Software Patterns (from memory of Larman's book). They are not design patterns in the Gang of Four sense; they are questions to ask when giving a responsibility to a class. Seven of the nine, each with where it shows up in version B:
Pattern
The question and the answer
In version B
Information Expert
Who has the information needed? Give the responsibility to that class
Loan.days_overdue and Loan.fine: the loan holds the due date. Member.check_can_borrow: the member holds expiry, fines, loans
Creator
Who should create an object of class X? The class that contains or records X, closely uses it, or has the data to initialise it
Member.borrow creates the Loan: the member records its loans (the composition in chapter 5's notation)
Controller
Who receives a request from outside the domain (a screen, a message)? A class that represents the use case or the system, and delegates to domain objects
LoanDesk: receives borrow and return, holds no rule
Low Coupling
Assign responsibilities so that classes depend on few others
The domain classes Loan and Copy name no other domain class (measured below)
High Cohesion
Keep a class's responsibilities closely related and focused
Each class's card fits on a card; Copy only answers questions about a copy
Polymorphism
When behaviour varies by type, give each type an operation, rather than testing the type
MemberKind with Standard and Junior: the limit and the fine rate. No if kind == ... anywhere
Pure Fabrication
When no domain concept is the right home, invent a class that is not in the domain, to keep the others cohesive
Catalogue (lookup) and arguably LoanDesk: neither is a library concept; they exist so that Member and Loan need not know how to find each other
The other two (Indirection and Protected Variations) are about inserting an intermediate object or interface to isolate a part that is likely to change. Polymorphism and Pure Fabrication here are doing a version of that job.
These overlap with SOLID: high cohesion is close to the single-responsibility principle, and polymorphism instead of type tests is close to open-closed; see Clean Code, SOLID & Refactoring 6 and 7, which this course does not repeat. That mapping is mine, not an official one.
Version B: Responsibilities Assigned
"""Version B: the same behaviour, with responsibilities given to the classes that hold the information (GRASP)."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date, timedelta
class Refusal(Exception):
pass
# --- Polymorphism: what varies by kind of member lives in one class per kind -----------------------------------------
class MemberKind(ABC):
@abstractmethod
def loan_limit(self) -> int: ...
@abstractmethod
def fine_per_day(self) -> int: ...
class Standard(MemberKind):
def loan_limit(self) -> int:
return 5
def fine_per_day(self) -> int:
return 10
class Junior(MemberKind):
def loan_limit(self) -> int:
return 3
def fine_per_day(self) -> int:
return 5
KINDS = {"standard": Standard(), "junior": Junior()}
# --- Information Expert: each class does the work for which it holds the data ------------------------------------------
@dataclass
class Copy:
barcode: str
on_loan: bool = False
def check_can_be_lent(self) -> None:
if self.on_loan:
raise Refusal("already on loan")
@dataclass
class Loan:
barcode: str
borrowed_on: date
due_on: date
returned_on: date | None = None
def is_open(self) -> bool:
return self.returned_on is None
def days_overdue(self, today: date) -> int:
return max(0, (today - self.due_on).days)
def fine(self, today: date, kind: MemberKind) -> int:
return self.days_overdue(today) * kind.fine_per_day()
@dataclass
class Member:
card: str
expires_on: date
kind: MemberKind
fines_pence: int = 0
loans: list[Loan] = field(default_factory=list)
def open_loans(self) -> list[Loan]:
return [l for l in self.loans if l.is_open()]
def check_can_borrow(self, today: date) -> None:
if self.expires_on < today:
raise Refusal("membership is not valid")
if self.fines_pence > 0:
raise Refusal("unpaid fines")
if len(self.open_loans()) >= self.kind.loan_limit():
raise Refusal("loan limit reached")
def borrow(self, copy: Copy, today: date) -> Loan: # Creator: the Member records its Loans, so the Member creates them
self.check_can_borrow(today)
copy.check_can_be_lent()
loan = Loan(copy.barcode, today, today + timedelta(days=21))
copy.on_loan = True
self.loans.append(loan)
return loan
def give_back(self, loan: Loan, copy: Copy, today: date) -> int:
fine = loan.fine(today, self.kind)
self.fines_pence += fine
loan.returned_on = today
copy.on_loan = False
return fine
# --- Pure Fabrication: not a library concept, but it keeps lookup out of the domain classes (low coupling) ---------------
class Catalogue:
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")
# --- Controller: receives the use case's request and delegates; it holds no business rule --------------------------------
class LoanDesk:
def __init__(self, members, copies):
self.catalogue = Catalogue(members, copies)
def borrow(self, card: str, barcode: str, today: date) -> Loan:
return self.catalogue.member(card).borrow(self.catalogue.copy(barcode), today)
def return_book(self, barcode: str, today: date) -> int:
member, loan = self.catalogue.open_loan_for(barcode)
return member.give_back(loan, self.catalogue.copy(barcode), today)
Version B. More classes, each smaller. The request enters at LoanDesk, which hands off; the rules sit with the objects that hold the data.
Do They Behave the Same?
Before comparing structure, check the behaviour is identical. One suite of 26 tests (13 behaviours, each run through a small adapter against A and against B):
"""One behaviour suite, run against BOTH versions through a small adapter, to show they do the same thing."""
import unittest
from datetime import date
import library_a as a
import library_b as b
TODAY = date(2026, 10, 10)
LATER = date(2026, 11, 3) # 3 days after a loan taken on TODAY falls due (21 days -> 2026-10-31)
def make_a(members, barcodes):
ms = [a.Member(card, exp, kind=kind, fines_pence=fines) for card, exp, kind, fines in members]
return a.LibraryService(ms, [a.Copy(c) for c in barcodes]), a.Refusal
def make_b(members, barcodes):
ms = [b.Member(card, exp, b.KINDS[kind], fines_pence=fines) for card, exp, kind, fines in members]
return b.LoanDesk(ms, [b.Copy(c) for c in barcodes]), b.Refusal
class Behaviour:
make = None
def world(self, kind="standard", fines=0, expires=date(2027, 1, 1), barcodes=("B1", "B2", "B3", "B4", "B5", "B6")):
return self.make([("M1", expires, kind, fines)], barcodes)
def test_borrow_sets_due_date(self):
desk, _ = self.world()
self.assertEqual(desk.borrow("M1", "B1", TODAY).due_on, date(2026, 10, 31))
def test_expired_membership_refused(self):
desk, Refusal = self.world(expires=date(2026, 10, 9))
with self.assertRaises(Refusal):
desk.borrow("M1", "B1", TODAY)
def test_unknown_card_refused(self):
desk, Refusal = self.world()
with self.assertRaises(Refusal):
desk.borrow("NOPE", "B1", TODAY)
def test_fines_block_borrowing(self):
desk, Refusal = self.world(fines=10)
with self.assertRaises(Refusal):
desk.borrow("M1", "B1", TODAY)
def test_standard_limit_is_five(self):
desk, Refusal = self.world()
for code in ("B1", "B2", "B3", "B4", "B5"):
desk.borrow("M1", code, TODAY)
with self.assertRaises(Refusal):
desk.borrow("M1", "B6", TODAY)
def test_junior_limit_is_three(self):
desk, Refusal = self.world(kind="junior")
for code in ("B1", "B2", "B3"):
desk.borrow("M1", code, TODAY)
with self.assertRaises(Refusal):
desk.borrow("M1", "B4", TODAY)
def test_copy_on_loan_refused(self):
desk, Refusal = self.world()
desk.borrow("M1", "B1", TODAY)
with self.assertRaises(Refusal):
desk.borrow("M1", "B1", TODAY)
def test_unknown_copy_refused(self):
desk, Refusal = self.world()
with self.assertRaises(Refusal):
desk.borrow("M1", "ZZZ", TODAY)
def test_return_on_time_no_fine(self):
desk, _ = self.world()
desk.borrow("M1", "B1", TODAY)
self.assertEqual(desk.return_book("B1", date(2026, 10, 31)), 0)
def test_standard_fine_is_10p_a_day(self):
desk, _ = self.world()
desk.borrow("M1", "B1", TODAY)
self.assertEqual(desk.return_book("B1", LATER), 30)
def test_junior_fine_is_5p_a_day(self):
desk, _ = self.world(kind="junior")
desk.borrow("M1", "B1", TODAY)
self.assertEqual(desk.return_book("B1", LATER), 15)
def test_return_frees_the_copy_and_unknown_return_refused(self):
desk, Refusal = self.world()
desk.borrow("M1", "B1", TODAY)
desk.return_book("B1", date(2026, 10, 31))
desk.borrow("M1", "B1", date(2026, 11, 1)) # available again
with self.assertRaises(Refusal):
desk.return_book("B2", TODAY)
def test_a_fine_then_blocks_the_next_loan(self):
desk, Refusal = self.world()
desk.borrow("M1", "B1", TODAY)
desk.return_book("B1", LATER)
with self.assertRaises(Refusal):
desk.borrow("M1", "B2", LATER)
class VersionA(Behaviour, unittest.TestCase):
make = staticmethod(make_a)
class VersionB(Behaviour, unittest.TestCase):
make = staticmethod(make_b)
if __name__ == "__main__":
unittest.main(verbosity=1)
Ran 26 tests in 0.001s
OK
A passing suite shows the versions agree on these 13 behaviours; it could be too weak. So I tested the tests: I broke B on purpose (junior fine 6p instead of 5p) and A on purpose (fines no longer block borrowing), and ran the same suite:
unmodified: 0 of 26 tests fail []
B with junior fine 6p: 1 of 26 tests fail ['VersionB.test_junior_fine_is_5p_a_day']
A with fines ignored: 2 of 26 tests fail ['VersionA.test_a_fine_then_blocks_the_next_loan', 'VersionA.test_fines_block_borrowing']
restored: 0 of 26 tests fail []
Each bug was caught, by one and two tests respectively. That is reasonable evidence that the suite is looking at the right things, not that it is complete.
What I Measured
"""Three crude, honest measurements of a module of classes.
named distinct OTHER domain classes whose names the class mentions (a rough coupling count)
reach-in reads or writes of another class's data fields (x.field where field belongs to another class): Information Expert smell
kind-tests comparisons of a '.kind' attribute: branching on a type tag, which polymorphism would replace
Exceptions (classes ending in Refusal or deriving from Exception) are not counted as domain classes.
These are my own simple measures, written for this chapter. They count; they do not judge.
"""
import ast
import pathlib
import sys
def analyse(path: str) -> list[dict]:
tree = ast.parse(pathlib.Path(path).read_text(encoding="utf8"))
classes = {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)}
domain = {name for name, node in classes.items() if not name.endswith("Refusal") and not any(
isinstance(b, ast.Name) and b.id in ("Exception", "ABC") for b in node.bases) and not name.startswith("MemberKind")}
def fields(node: ast.ClassDef) -> set[str]:
out = {s.target.id for s in node.body if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name)}
for fn in node.body:
if isinstance(fn, ast.FunctionDef) and fn.name == "__init__":
for n in ast.walk(fn):
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name) and n.value.id == "self" and isinstance(n.ctx, ast.Store):
out.add(n.attr)
return out
field_owner: dict[str, set[str]] = {}
for name in domain:
for f in fields(classes[name]):
field_owner.setdefault(f, set()).add(name)
rows = []
for name, node in classes.items():
if name not in domain and not name.startswith("Standard") and not name.startswith("Junior"):
continue
own = fields(node)
named, reach, chains = set(), 0, 0
sites = []
for n in ast.walk(node):
if isinstance(n, ast.Name) and n.id in domain and n.id != name:
named.add(n.id)
if isinstance(n, ast.Attribute) and not (isinstance(n.value, ast.Name) and n.value.id == "self"):
owners = field_owner.get(n.attr, set())
if owners and n.attr not in own and name not in owners:
reach += 1
sites.append((n.lineno, ast.unparse(n)))
if isinstance(n, ast.Compare) and any(isinstance(x, ast.Attribute) and x.attr == "kind" for x in [n.left, *n.comparators]):
chains += 1
methods = [f.name for f in node.body if isinstance(f, ast.FunctionDef)]
rows.append({"class": name, "methods": len(methods), "named": len(named), "reach_in": reach, "chains": chains, "sites": sites})
return rows
def show(title: str, path: str, detail: bool = False) -> None:
rows = analyse(path)
print(title)
print(f" {'class':<14}{'methods':>8}{'named':>7}{'reach-in':>10}{'kind-tests':>11}")
for r in rows:
print(f" {r['class']:<14}{r['methods']:>8}{r['named']:>7}{r['reach_in']:>10}{r['chains']:>11}")
if detail:
for r in rows:
for line, text in r["sites"]:
print(f" reach-in in {r['class']}, line {line}: {text}")
print(f" {'TOTAL':<14}{sum(r['methods'] for r in rows):>8}{'':>7}{sum(r['reach_in'] for r in rows):>10}{sum(r['chains'] for r in rows):>11}")
if __name__ == "__main__":
for arg in [a for a in sys.argv[1:] if not a.startswith('--')]:
show(arg, arg, detail="--detail" in sys.argv)
print()
library_a.py
class methods named reach-in kind-tests
Member 0 0 0 0
Copy 0 0 0 0
Loan 0 0 0 0
LibraryService 3 1 21 4
TOTAL 3 21 4
library_b.py
class methods named reach-in kind-tests
Standard 2 0 0 0
Junior 2 0 0 0
Copy 1 0 0 0
Loan 3 0 0 0
Member 4 2 4 0
Catalogue 4 3 3 0
LoanDesk 3 2 0 0
TOTAL 19 7 0
What the table says, and does not say:
Reach-in counts places where a class reads or writes another class's data. Version A's service does it 21 times; version B 7. This is the Information Expert measure: in A the service reaches through everyone; in B most of the questions are answered by the object that holds the data.
Kind-tests counts comparisons of a type tag (.kind). A has 4 (two if-chains, one in each method); B has none, because MemberKind answers instead.
Methods and size go the other way. A has 3 methods and about 69 non-blank, non-comment lines; B has 19 methods and about 95. Version B is more code and more classes. That is the price, and for a program this small it may not be worth paying; the comparison favours B as the rules grow, not at 70 lines.
The seven reach-ins that remain in B are worth reading rather than ignoring. Three (m.card, c.barcode, l.barcode in Catalogue) read identifying values to build or search an index, which is hard to avoid. Four are in Member: it writes copy.on_loan twice, reads copy.barcode, and sets loan.returned_on. Those are real Information Expert lapses: a Member should not set another object's fields. Exercise 3 fixes them and measures again.
Two Change Requests, Applied to Both
CR1: a new "senior" member kind with limit 8 and fine 5p a day. CR2: fines are capped at 500p per loan for everyone. I applied each by script to both versions (so the edits are exact and countable) and diffed the source:
"""Apply the same two change requests to both versions, and measure what each change touched.
CR1: a new 'senior' member kind: loan limit 8, fine 5p a day.
CR2: fines are capped at 500p per loan, for every kind of member.
"""
import ast
import difflib
import pathlib
HERE = pathlib.Path(__file__).parent
def replace_once(text: str, old: str, new: str) -> str:
assert text.count(old) == 1, f"expected exactly one match for: {old!r} (found {text.count(old)})"
return text.replace(old, new)
def make_a(cr: set[str]) -> str:
text = (HERE / "library_a.py").read_text(encoding="utf8")
if "cr1" in cr:
text = replace_once(text, ' elif member.kind == "junior":\n limit = 3\n',
' elif member.kind == "junior":\n limit = 3\n elif member.kind == "senior":\n limit = 8\n')
text = replace_once(text, ' elif member.kind == "junior":\n rate = 5\n',
' elif member.kind == "junior":\n rate = 5\n elif member.kind == "senior":\n rate = 5\n')
if "cr2" in cr:
text = replace_once(text, " fine = days_late * rate\n", " fine = min(days_late * rate, 500)\n")
return text
def make_b(cr: set[str]) -> str:
text = (HERE / "library_b.py").read_text(encoding="utf8")
if "cr1" in cr:
text = replace_once(text, 'KINDS = {"standard": Standard(), "junior": Junior()}',
'class Senior(MemberKind):\n def loan_limit(self) -> int:\n return 8\n\n def fine_per_day(self) -> int:\n return 5\n\n\n'
'KINDS = {"standard": Standard(), "junior": Junior(), "senior": Senior()}')
if "cr2" in cr:
text = replace_once(text, " return self.days_overdue(today) * kind.fine_per_day()\n",
" return min(self.days_overdue(today) * kind.fine_per_day(), 500)\n")
return text
def functions(source: str) -> dict[str, str]:
"""Qualified name -> source text of every function and class-level statement group, to see which ones changed."""
tree = ast.parse(source)
out = {}
for node in tree.body:
if isinstance(node, ast.ClassDef):
out[node.name] = ast.dump(ast.Module(body=[b for b in node.body if not isinstance(b, ast.FunctionDef)], type_ignores=[]))
for fn in node.body:
if isinstance(fn, ast.FunctionDef):
out[f"{node.name}.{fn.name}"] = ast.dump(fn)
elif isinstance(node, ast.Assign):
out[ast.unparse(node.targets[0])] = ast.dump(node)
return out
def measure(before: str, after: str) -> dict:
diff = list(difflib.unified_diff(before.splitlines(), after.splitlines(), lineterm="", n=0))
removed = sum(1 for l in diff if l.startswith("-") and not l.startswith("---"))
added = sum(1 for l in diff if l.startswith("+") and not l.startswith("+++"))
f0, f1 = functions(before), functions(after)
edited = sorted(k for k in f0 if k in f1 and f0[k] != f1[k])
new = sorted(k for k in f1 if k not in f0)
return {"removed": removed, "added": added, "edited": edited, "new": new}
if __name__ == "__main__":
for name, maker, orig in (("A", make_a, "library_a.py"), ("B", make_b, "library_b.py")):
before = (HERE / orig).read_text(encoding="utf8")
for label, cr in (("CR1 senior kind", {"cr1"}), ("CR2 fine cap", {"cr2"})):
m = measure(before, maker(cr))
print(f"Version {name}, {label}: existing lines changed/removed {m['removed']}, lines added {m['added']}")
print(f" existing functions/classes edited: {m['edited'] or 'none'}")
print(f" new functions/classes: {m['new'] or 'none'}")
(HERE / f"library_{name.lower()}_cr.py").write_text(maker({"cr1", "cr2"}), encoding="utf8", newline="\n")
Version A, CR1 senior kind: existing lines changed/removed 0, lines added 4
existing functions/classes edited: ['LibraryService.borrow', 'LibraryService.return_book']
new functions/classes: none
Version A, CR2 fine cap: existing lines changed/removed 1, lines added 1
existing functions/classes edited: ['LibraryService.return_book']
new functions/classes: none
Version B, CR1 senior kind: existing lines changed/removed 1, lines added 9
existing functions/classes edited: ['KINDS']
new functions/classes: ['Senior', 'Senior.fine_per_day', 'Senior.loan_limit']
Version B, CR2 fine cap: existing lines changed/removed 1, lines added 1
existing functions/classes edited: ['Loan.fine']
new functions/classes: none
Read this honestly: the line counts do not favour B. CR1 in A is four added lines in two existing functions; in B it is nine added lines (a new class) and one changed line (the registry). CR2 is one changed line in each. What differs is where the change goes and what can go wrong:
In A, CR1 means editing the logic of two existing functions; each must be remembered.
In B, CR1 adds a class and leaves existing logic untouched (the one changed line is a registry entry).
CR2 is a single place in both, because the fine was a single calculation in both. Polymorphism did nothing for it, and it should not be expected to.
Both modified versions pass the behaviour suite for the new rules (8 tests, run on each):
----------------------------------------------------------------------
Ran 8 tests in 0.000s
OK
The Change Made "Almost Completely"
The cost of the A style shows up when one of those places is forgotten. I applied CR1 to A but left out the fine chain, and to B but left out the fine method of the new class:
"""What happens when a change is made 'almost completely': the senior kind is added, but one place is forgotten."""
import importlib
import pathlib
import sys
import types
from datetime import date
HERE = pathlib.Path(__file__).parent
sys.path.insert(0, str(HERE))
import apply_changes # noqa: E402
def load(name: str, source: str) -> types.ModuleType:
module = types.ModuleType(name)
exec(compile(source, name, "exec"), module.__dict__)
return module
# --- Version A: add 'senior' to the limit chain in borrow() but forget the fine chain in return_book() ---
text = apply_changes.make_a({"cr1"})
text = text.replace(' elif member.kind == "senior":\n rate = 5\n', "") # simulate forgetting the second place
a = load("a_partial", text)
svc = a.LibraryService([a.Member("M1", date(2027, 1, 1), kind="senior")], [a.Copy("B0")])
svc.borrow("M1", "B0", date(2026, 10, 10))
print("A: the senior member borrowed a book. Now they bring it back...")
try:
svc.return_book("B0", date(2026, 10, 31))
except Exception as e:
print(f"A: {type(e).__name__}: {e!s} (the loan is still open: {svc.loans[0].returned_on is None}, so the book can never be returned)")
# --- Version B: add Senior but forget one of its two methods ---
text = apply_changes.make_b({"cr1"})
text = text.replace(" def fine_per_day(self) -> int:\n return 5\n\n\nKINDS = {\"standard\"", "\n\nKINDS = {\"standard\"") # remove Senior.fine_per_day
try:
load("b_partial", text)
print("B: loaded without complaint")
except TypeError as e:
print(f"B: TypeError at import time: {e}")
A: the senior member borrowed a book. Now they bring it back...
A: ValueError: senior (the loan is still open: True, so the book can never be returned)
B: TypeError at import time: Can't instantiate abstract class Senior without an implementation for abstract method 'fine_per_day'
In A the mistake is invisible until a senior member returns a book, which then can never be returned (the loan stays open). In B it fails immediately, when the class is defined, because MemberKind declares fine_per_day abstract and Python refuses to create an incomplete Senior. That is the real argument for polymorphism: not shorter code, but a change that cannot be left half done.
(An enumeration with a lookup table in A would also help; this is a comparison of two styles, not of every way to write A.)
Controller, Pure Fabrication and Where Rules Live
Keep the controller thin.LoanDesk.borrow is one line: find the member, find the copy, ask the member to borrow. If a rule appears in the controller, version A is growing back.
A pure fabrication is allowed to be ugly.Catalogue is not a thing a librarian would mention. It is a legitimate answer to "where does lookup go?" when the alternative is giving Member a reference to every other member.
Resist a class per noun. More classes is the cost; a class should earn its place by a cohesive responsibility.
Mistakes while preparing this chapter
The line counts do not favour B, and I reported that. The tempting move is to pick the measure that makes the preferred design look best. The honest one is to decide what you will count, report all of it (including that B is more code), and make the argument the data supports: here, the half-finished change, not the line counts.
My "named classes" metric hid the thing I cared about. It counts only the names a class mentions, so A's service, which never writes Member or Copy but manipulates them constantly, scored one. I kept the column to show the limit and relied on the reach-in count instead.
A metric with the wrong name. The first version of the script labelled its type-tag count "chains", but it counts comparisons: version A has four comparisons in two if-chains. A column heading is a claim; I renamed it "kind-tests".
A card and the code disagreed, and both were right. See the CRC section: run-time collaboration is not compile-time naming. Takeaway: say which one a diagram or card claims.
Leftover scaffolding. My first draft of the exercise 2 test script contained a helper function that did nothing (a half-finished idea for counting edits). It ran fine, which is why I nearly left it. Takeaway: re-read a script you are going to publish for code that cannot matter.
Deciding Who Does What: A Checklist
Write the use case step in plain words ("work out the fine").
Ask who has the information? (Expert). Start there.
Ask who contains or records the thing being created? (Creator).
Does the responsibility vary by type? Put it on the type (Polymorphism), not in an if-chain.
Does it make a class do two unrelated jobs, or depend on many classes? Move it, or invent a helper (Cohesion, Coupling, Pure Fabrication).
Is it a request from outside the domain? Controller: delegate, hold no rule.
Fit the class on a card. If it will not fit, split it.
Hands-On Exercises
Exercise 1
In the bike shop, an invoice is the labour (hours at 30 pounds an hour) plus the parts used (price times quantity). Write the "one function reaches into everything" version and the Information Expert version (JobPart works out its own cost, the job its labour and total), prove they agree on three jobs, and show that a trade discount on parts only touches one class.
The solution file contains shipping-cost code with two if-chains over a method name. Replace the chains with polymorphism, prove the behaviour is unchanged, count the branches before and after, and say what adding a "drone" method costs in each version.
Version B's Member still writes into Copy and Loan. Give those two classes the operations they should own, refactor, show the behaviour suite still passes, and measure the reach-ins again. Which ones remain, and are they acceptable?
CRC card: Class, Responsibilities, Collaborators; role-play a use case; a class that will not fit on a card does too much; a card shows run-time collaboration, which may differ from the names in the code
Anemic domain model: domain classes with data only, all rules in a service; costs: rules far from their data, a service coupled to every field, no invariants protected
Information Expert (who has the data?), Creator (who contains or records it?), Controller (delegate, no rules), Low Coupling, High Cohesion, Polymorphism (no type tests), Pure Fabrication (an invented class to keep others cohesive)
Measured here: reach-in 21 (A) to 7 (B) to 4 (B after exercise 3); kind-tests 4 to 0; but B is more code (95 against 69 lines) and more classes
Polymorphism's real benefit: a change that cannot be left half done (the forgotten place failed at start-up in B, and as a book that could never be returned in A); it does nothing for a rule that is already in one place
Test the tests (mutants), and decide what to measure before looking at the result
Coming next
Object-Oriented Design with UML 7 (Sequence Diagrams): lifelines, messages, return values, activation, the alt, opt, loop, par and ref fragments, going from a use case scenario to a sequence diagram to method signatures, and drawing "borrow a book" for this design and running it.