Software Design Reference Master Designer · Complexity Trade-Offs

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.

Reading ≈ 9 minPractice ≈ 45 min

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 principle behind the principle

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 togetherCopies always change together
Divergence is harmlessduplicate — the defaultextract when the third appears
Divergence is a bugone source of truth, as data or a contractextract 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.

† The asymmetry: you can always add the abstraction later when the third copy appears. You can rarely remove a wrong abstraction — every consumer's code now depends on its shape.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
a parameter named kind / type / modeThe abstraction is asking callers which one it really is.Two functions with two honest names.
if isinstance(self, …) inside a base classThe shape was wrong and is now being bent from inside.Split the hierarchy along the real difference.
a helper with six optional parametersOne function serving six callers, none of them well.Inline it back; re-extract what actually repeats.
three copies that changed together, twiceThe shared shape is evidence now, not a guess.Extract it — this is what earned looks like.
What people get wrong after reading this
"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 kind parameter means split it."
A kind that selects between behaviours is the smell. A kind that 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.

Teach it back
  1. Explain to a junior, in two sentences, why two identical-looking functions can be right and two identical-looking permission checks wrong.
  2. Find one abstraction in your own code this week with a kind or type parameter. Name its cost: which caller can no longer change without a negotiation?
  3. 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

Bring a flag-driven helper to your agent-teacher and decide whether it's an earned abstraction.