Lesson 0006 · Core Designer · Module 2
Design Around Reasons to Change
Responsibilities are fuzzy; change sources are facts. Structure your modules around the forces that will actually edit them.
Mission tie-in: "design around reasons to change" is the upgrade from lesson 0002's SRP. It turns a principle into a method you can apply before writing a line.
Knowledge: the change source is the unit
Every future edit to your codebase comes from a finite list of forces: tax law changes, a new payment provider, a new report format, a re-branding. Call each one a reason to change. Design says: code that changes for the same reason lives together; code that changes for different reasons lives apart.
Consider an invoice class:
class Invoice:
def __init__(self, lines: list[Line], vat_rate: Decimal) -> None:
self.lines = lines
self.vat_rate = vat_rate
def total(self) -> Money:
subtotal = sum(line.price for line in self.lines)
return Money(subtotal * (1 + self.vat_rate))
def to_html(self) -> str:
rows = "".join(f"<tr><td>{l.name}</td></tr>" for l in self.lines)
return f"<table>{rows}</table>"
def to_json(self) -> dict:
return {"lines": [{"name": l.name, "price": str(l.price)} for l in self.lines]}
Two reasons to change are welded together: how invoices are calculated (tax rules, discounts) and how invoices are rendered (HTML tweaks, new JSON fields for a mobile client). When the mobile client needs a new field, you edit the class that owns the tax calculation — and risk the tax rules.
The stable design separates them:
class Invoice:
def __init__(self, lines: list[Line], vat_rate: Decimal) -> None:
self.lines = lines
self.vat_rate = vat_rate
def total(self) -> Money: # reason: pricing rules
subtotal = sum(line.price for line in self.lines)
return Money(subtotal * (1 + self.vat_rate))
class InvoiceRenderer:
def __init__(self, invoice: Invoice) -> None: ...
def to_html(self) -> str: ... # reason: web presentation
def to_json(self) -> dict: ... # reason: API contract
Now a pricing change edits Invoice and nothing else; a rendering change edits InvoiceRenderer and nothing else. Note what you did not do: you didn't count methods, and you didn't split for symmetry. You named the change sources — the whole method.
"Responsibility" is unfalsifiable. Two competent engineers can argue for an hour about whether rendering is part of an invoice's responsibility, and neither can be shown wrong, because the word has no measurement attached. "Reason to change" is a different kind of claim: it is a statement about the future edits to a file, and its proxy — which files have historically been edited together — is sitting in your version control system right now.
That gives the principle a mechanism. Your module boundaries should match the joint distribution of edits. Two files that change together in most commits have a boundary between them that nobody wanted: every change pays two reviews, two merges, and an ordering constraint. One file whose commits fall into two disjoint clusters — different authors, different motivations, no overlap — has a boundary missing: every change to one cluster puts the other inside its blast radius, and the two groups collide in the same diff.
Both failures cost, and they cost differently. A boundary in the wrong place charges coordination: extra reviewers, release ordering, cross-team scheduling. A missing boundary charges blast radius: unrelated tests, merge conflicts, and ambiguous ownership when something breaks. When you hear a team say "we can't ship X until Y's change lands", you are hearing the first. When you hear "nobody wants to touch that file", you are hearing the second.
Where it stops applying. History only describes the past, and it has three specific failure modes. A greenfield module has no history at all, so any split is a guess — and since it is much easier to split a module that grew too broad than to reunite two that were separated early and evolved incompatible interfaces, the honest default in an unfamiliar domain is fewer, larger modules until evidence arrives. Co-change can also be an artefact of process rather than design: one team on one release train will commit things together whether or not they belong together. And a file that changed for two reasons last year may be about to stop changing for one of them, because that integration is being retired — history tells you what happened, not what is coming.
How a senior designer finds the line
A 250,000-line commerce codebase, four teams. invoice.py is the file everyone complains about.
They start with the co-change data, not the class diagram. git log --name-only --since=1.year over the last thirty commits that touched invoice.py shows two clusters that barely overlap: seven commits also touching tax_tables.py, all authored by the finance-integration team; twenty-two also touching templates/, all authored by the growth team. Two disjoint editor populations on one file. That is the measurement, and it took two minutes.
Then the question: has any single commit needed both? If tax changes and template changes have never landed together, the file is holding two things that have no reason to be in the same room. If they routinely land together, the coupling is real and splitting them would just move the cost into coordination.
Then they reject the split everyone proposes. The reflex refactor is to layer it: InvoiceModel, InvoiceService, InvoiceRepository. This is the most common wrong answer in the industry, and it is wrong for a precise reason: layering slices along mechanism — data, orchestration, storage — while the forces that issue edits run along subject matter. After the layer split, a VAT rate change touches the model (the rate), the service (the calculation order), and the repository (the stored breakdown); a template change touches the service and the model too. The average edit went from one file to three, the review from one team to two, and the file everybody complained about has become three files everybody complains about. The tell: after a split, count the files an average edit touches. If it went up, the axis was wrong.
The split that works puts tax calculation in one module with a finance owner and presentation in another with a growth owner. Now tax pull requests touch one file and need one reviewer who knows tax; template pull requests touch one file and need one reviewer who knows the brand. The two teams stop appearing in each other's diffs, and — the part that actually shows up in delivery metrics — stop waiting on each other's review queues.
The consequence over a year. Before: median invoice pull request touches three files, needs two reviewers from different teams, and waits on whichever is busier. After: one file, one reviewer, same day. Nothing about the business logic changed. The entire improvement came from putting the boundary where the edits already were.
| You see this | What it costs | The move |
|---|---|---|
| git log --format=%s -- billing.py | The commit subjects name this file’s change sources out loud. | Group the subjects; each distinct world is a module. |
| Invoice.to_html() beside Invoice.total() | A branding tweak edits the file that owns the tax rules. | Move rendering out; the calculation stops being touched. |
| one class edited by three teams | Three worlds, one file — every change becomes a negotiation. | Split along the team boundary (lesson 0033). |
| a helpers.py nobody claims | Its change source was never named, so nobody owns its edits. | Ask who edits it and why; that answer is its home. |
When this is the wrong move
Do not design around change sources you have invented. In a domain you have worked in for two weeks, the list of "reasons to change" you can produce is a list of guesses, and each guess that turns out wrong leaves a boundary you now have to maintain and eventually move — which is more expensive than the coupling you were avoiding. Keep it in one module, let it get uncomfortable, and split when the history tells you where. Equally, do not split a file whose two "reasons" are really one reason wearing two hats: a rename that touches both the calculation and the label is one change source, not two, and separating them means every rename becomes two commits. And a module with a single owner, a low change rate, and no cross-team traffic is not worth restructuring however many jobs it appears to do — the arithmetic in lesson 0001 multiplies by a change frequency near zero.
- A reason to change is the same as an actor or a role.
- Actors are a useful proxy — Clean Architecture uses them — but the thing you actually want is co-change. Two stakeholders who always request the same edit at the same time are one reason; one stakeholder who asks for two unrelated kinds of change is two. Ask what arrives together, not who asked.
- Splitting into layers is applying this principle.
- Layering is a different axis, and a legitimate one — it controls dependency direction. But it does not separate change sources, and applied as if it did, it multiplies the number of files each change source touches. Use layers for the arrows; use change sources for the boundaries.
- Every module should have exactly one reason to change.
- The workable target is weaker and more useful: no ordinary edit should have to cross a boundary it did not need to cross. A module with one broad change source that touches many things is fine. A module with two narrow ones that never coincide is not.
- Git history gives you the answer.
- It gives you evidence, which still needs a designer. History cannot see the integration you are about to retire, the regulation arriving next quarter, or the fact that last year's churn was one migration that will never repeat. Read it as a hypothesis generator, then check the hypothesis against what you know is coming.
Skill: name the change sources
The invoice class above should be split because:
A class with many methods and one change source is:
Splitting the invoice class into model, service, and repository classes mainly:
Practice on your own code
Pick your most-edited class and run the measurement rather than the intuition: git log --name-only --since=1.year -- <that file>, then group the commits by author and by subject. If they fall into disjoint clusters, you have found a split that would already have paid for itself. If they do not, you have learned something more valuable — the file is fine and your discomfort was about its size.
In the lab: the returns-resolution service ships with a commit history designed to be read this way. The phase-1 checkpoint asks you to derive the change sources of its largest module from that history, propose the boundary, and say explicitly which layered split you are rejecting and why.
Reveal: a worked split
A ReportGenerator that both queries the warehouse and formats PDFs. Edit history: new warehouse schema (twice) and new branding (once). Two worlds. Split into WarehouseReportQuery (owned by the warehouse team's contract) and ReportFormatter (owned by design). Branding changes stop touching query code.
- Explain to a junior, in two sentences, why "one reason to change" is a better test than "one responsibility" — without using either phrase.
- Find one file in your own repository this week whose history splits into two clusters, and name the cost: how many files an average edit touches, and how many reviewers it needs.
- Predict what happens in a year if the two clusters keep sharing a file — who stops touching it, and which change eventually gets made in the wrong place because of that.
Your win
You can now enumerate a module's reasons to change from its edit history — and split along those lines instead of along method counts or symmetry.
Read and watch deeper
- Clean Architecture, Martin — ch. 7 (SRP as "one reason to change") and ch. 8 (Open-Closed: design for extension along the change source).
- A Philosophy of Software Design, Ousterhout — ch. 2 "The Nature of Complexity": complexity defined as "anything that makes change hard".
- Refactoring, Fowler — ch. 3 "Bad Smells in Code": smells are symptoms; change sources are the diagnosis. "Divergent Change" and "Shotgun Surgery" are this lesson's two failure modes, named.
- Your Code as a Crime Scene, Adam Tornhill — the chapters on hotspots and temporal coupling: how to compute the co-change analysis above from a repository, and what the numbers do and do not license you to conclude.
- Watch: ArjanCodes YouTube — search "single responsibility".
- Reference: Design Principles — SRP; and the glossary on reason to change.
Bring a class you edit often to your agent-teacher and co-list its change sources — disagreement is where the insight lives.