Lesson 0028 · Master Designer · Module 1
Abstraction vs Duplication
DRY (lesson 0002) says don't repeat knowledge. The Master's version: duplication is often the cheaper rent — the wrong abstraction is a mortgage you pay forever.
Mission tie-in: the second Complexity Trade-Off — where the principles of Phase I become something you balance instead of obey.
Knowledge: two kinds of rent
Duplication costs you on every change — edit N copies. Abstraction costs you on every read — everyone must understand the shape, and if the shape is wrong, every consumer pays. The trade is between these two rents.
The two forces: one source of truth (abstraction) against freedom to diverge (duplication). The unit of decision: the occurrence — this pair of call sites, and whether they share a reason to change (lesson 0006). Not the codebase, not the module: a codebase-wide "we don't repeat ourselves" is a policy, and policies cannot see reasons to change.
An abstraction is a claim that N call sites will change together forever. When the claim is true it converts N edits into one. When it is false it converts N independent edits into one negotiated edit — and negotiation grows worse than linearly with consumers, because each divergence adds a branch every consumer must read past and every test must cover. That asymmetry is the whole lesson: duplication's cost is linear in copies and paid only when you change; a wrong abstraction's cost is superlinear in consumers and paid on every read.
Where "prefer duplication" stops being true. When divergence is not a freedom but a bug. Three copies of an authorization check, a tax rule, a signing parameter or a wire format are not cheap rent — they are three places to forget, and the forgetting is a breach or a corruption, not an inconsistency. Duplication is cheap where divergence is harmless. It also fails across team boundaries, where nobody knows the other copies exist, so "edit N copies" quietly becomes "edit one". And it does not apply at all where the shape was given rather than guessed: a legal spec or a published format is not an abstraction you invented, so the usual risk of guessing wrong is absent.
Why the wrong abstraction is so expensive:
# the abstraction that guessed wrong
class FeeCalculator: # "all fees are one thing" — wrong
RATE = Decimal("0.01")
@classmethod
def fee(cls, amount: Money, kind: str) -> Money:
if kind == "bank":
return amount * cls.RATE
if kind == "wire":
return amount * cls.RATE
raise ValueError(kind)
# now wire fees change (clearing house), and the abstraction must be bent:
def fee(self, amount, kind):
if kind == "wire":
return amount * Decimal("0.02") # a branch, because one RATE was wrong
The two fees were never one knowledge (lesson 0002's false DRY) — the abstraction forced them into one shape, and every future divergence has to fight the shape. The duplicated version would have been two obvious functions, each change contained.
Sandi Metz's rule: duplication is far cheaper than the wrong abstraction. And the corollary — the abstraction isn't wrong if it was earned: three occurrences with the same change pattern, then extract. The rule of three is a rent calculation: by the third occurrence, the duplicated copies' change-rent exceeds the abstraction's read-rent.
Worked example: six copies of one authorization check
A platform team runs six independently deployed services. Each carries its own five-line copy of "can this user see this order?" — deliberately, on the reasoning that the services might diverge. They did diverge. Service four was written before a B2B feature introduced account scoping, and its copy never gained the extra clause.
The senior designer's first move is not to count the copies. It is to ask "if these two copies disagree, what is the worst outcome?" Here the answer is cross-tenant data exposure — and that answer, not the copy count, selects the strategy. This is the boundary condition made concrete: everything Sandi Metz says about the wrong abstraction is true of shapes, and an authorization rule is not a shape, it is a fact that must hold everywhere.
The second question separates the two things people conflate: is this shared knowledge, or merely shared shape? Two fee formulas that happen to look alike are shape. One policy about who may read an order is knowledge. Only knowledge must be single.
The obvious fix — extract a shared helper library and have all six services import it — is rejected, and not because it is wrong in principle. It creates a lock-step release dependency across six independently deployed services: a policy change now needs six coordinated deploys in a specific order, and until the last one lands the fleet disagrees with itself anyway (lesson 0033). Sharing code across a deployment boundary is a different trade than sharing code inside one.
What ships makes the knowledge single without making the deployments joint: the policy becomes a contract rather than a library — one authorization decision the services call, or a policy bundle shipped as data and versioned independently of the code that evaluates it. One source of truth, six independent release trains.
The bill for the seven weeks the drift went unnoticed: 340 B2B orders visible to the wrong account, contractual disclosure obligations to two customers, and a quarter of the account team's time on remediation. The engineering fix took twenty minutes. That ratio is the point — the expensive part was never the code, it was that nobody had asked what divergence would cost before choosing to allow it.
| Copies rarely change together | Copies always change together | |
|---|---|---|
| Divergence is harmless | duplicate — the default | extract when the third appears |
| Divergence is a bug | one source of truth, as data or a contract | extract now; don't wait for three |
Read the table as one rule: the cost of divergence, not the count of copies, chooses the strategy. The rule of three lives in the top row.
Pricing the two rents
Three copies of a validation routine, changed four times a year. Duplication rent: 3 edits per change × 4 changes = 12 edits a year, plus the cost of the copy someone forgets (roughly once a year, found in production).
Abstraction rent: 1 edit per change — if the three always change together. If they change independently, the abstraction converts three independent edits into one negotiated edit, and each divergence adds a branch that all three consumers must then read past.
So the deciding number is not "how many copies" but how often the copies change together. Three copies that always move as one are an abstraction waiting to be extracted. Three copies that move on separate schedules are three facts, and merging them buys 12 cheap edits at the price of every future divergence.
| You see this | What it costs | The move |
|---|---|---|
| a parameter named kind / type / mode | The abstraction is asking callers which one it really is. | Two functions with two honest names. |
| if isinstance(self, …) inside a base class | The shape was wrong and is now being bent from inside. | Split the hierarchy along the real difference. |
| a helper with six optional parameters | One function serving six callers, none of them well. | Inline it back; re-extract what actually repeats. |
| three copies that changed together, twice | The shared shape is evidence now, not a guess. | Extract it — this is what earned looks like. |
- "The rule of three is a law."
- It is a heuristic about evidence, and the evidence is copies that have changed together — not copies that exist. Three copies nobody has touched in two years are three copies, not a pattern waiting to be extracted.
- "Duplication always beats the wrong abstraction."
- Metz's line is about shape. Where the duplicated thing is a correctness invariant — authorization, tax, crypto parameters, a wire format — divergence is not freedom, it is the bug, and the top row of the table above does not apply.
- "Extracting to a shared library removes duplication."
- It relocates it and adds release coupling. Across independently deployed services, the shared library means everyone upgrades in lock-step or the fleet disagrees with itself — you traded a code problem for a coordination problem.
- "Any
kindparameter means split it." - A
kindthat selects between behaviours is the smell. Akindthat is data flowing through — a label written to a record, a tag on a metric — is fine. Read what the branch does, not that a branch exists.
When this is the wrong move
Do not prefer duplication for security and correctness invariants, for externally specified shapes you did not guess, or for code duplicated across a boundary where nobody will ever discover the second copy. In those cases pay the abstraction rent deliberately — and if the boundary is a deployment boundary, pay it as data or a contract rather than as shared code, so that "one truth" does not silently mean "one release train".
Skill: whose rent is cheaper?
Two fees look identical today but are set by different contracts. Extract?
The rule of three earns an extraction when:
The worst position to be in is:
Practice on your own code
Find an abstraction in your codebase that was extracted before its time (a helper with a kind/type flag, a base class with branchy subclasses). Ask: was it earned by three occurrences with one change pattern? If not, sketch the duplication it should have been.
In the lab. The phase-3 checkpoint gives you the returns-resolution service with two near-identical refund calculations and one eligibility check copied into three call paths. You decide each with the divergence-cost question before the copy count, and justify why the two answers differ.
Reveal: a wrong-abstraction rescue
A Notifier base class with send() overridden by Email and SMS — extracted at two occurrences. When a third channel arrived with a different retry semantics, the base class's shape had to bend. Rescue: delete the base, keep two functions (lesson 0016's callables), let each channel evolve alone until a real shared step appears three times.
- Explain to a junior, in two sentences, why two identical-looking functions can be right and two identical-looking permission checks wrong.
- Find one abstraction in your own code this week with a
kindortypeparameter. Name its cost: which caller can no longer change without a negotiation? - Predict what that abstraction looks like in a year at the current rate of new cases: how many branches, and who still understands all of them?
Your win
You can compute the two rents — change-rent of duplication vs read-rent of abstraction — and you know the asymmetry that makes wrong abstractions the expensive mistake.
Read and watch deeper
- "The Wrong Abstraction", Sandi Metz — the essay this lesson is built on.
- The Pragmatic Programmer, Hunt & Thomas — the DRY chapter, read now with its limits visible.
- A Philosophy of Software Design, Ousterhout — ch. 6 "General-Purpose Modules Are Deeper": what an abstraction must earn to be deep.
- Refactoring (2nd ed.), Fowler — ch. 3 "Bad Smells in Code", the Duplicated Code entry, and the rule-of-three note in ch. 2.
- Watch: ArjanCodes YouTube — search "DRY" or "duplication".
- Next: lesson 0029 — Configuration vs Convention.
- Reference: Trade-offs — abstraction vs duplication; glossary — false DRY, rule of three.
Bring a flag-driven helper to your agent-teacher and decide whether it's an earned abstraction.