What an Abstraction Costs
Indirection, vocabulary, learning cost and leakage — charged to every future reader, forever, whether or not the flexibility is ever used. Do not abstract by default.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
What does adding an abstraction cost, and who pays it?
A reviewer asks for an interface in front of a class with one implementation, "so it can be swapped later". The author cannot name what would swap it. The review is blocked.
An abstraction is cheap. It is one file, it does not run in production, and it might be useful later. There is no reason not to add it — the downside is bounded and the upside is open-ended.
The cost is not the file; it is the hop. Every reader tracing a call now goes interface → implementation, and with one implementation that hop conveys no information at all (Local Reasoning).
- The cost is not the file; it is the hop. Every reader tracing a call now goes interface → implementation, and with one implementation that hop conveys no information at all (Local Reasoning).
- It is a permanent tax paid by everyone, and a benefit received by nobody unless the second implementation actually arrives, which — at 140 interfaces and four implementations — is a testable claim this team has already lost (Speculative Generality).
- It changes what tests look like. Once an interface exists, mocking it becomes the obvious move, and the suite drifts toward asserting interactions rather than behaviour (Mocking).
- It makes the codebase look more flexible than it is. 140 interfaces suggest a system designed for variation; the variation was never observed, so the structure encodes a prediction that turned out false and nobody notices (Extensibility).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The team has a convention that new services get interfaces, adopted two years ago and never revisited (Revisit Triggers).
- The codebase already has 140 interfaces, of which four have more than one implementation.
- Deleting an interface later is possible and never happens, because removal always needs more justification than addition.
- Whatever is added must not make it harder to establish what code actually runs — a reader must always be able to get from a call to its implementation (Local Reasoning).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Whoever proposes an abstraction owns naming what a caller stops needing to know, and what specific change it makes cheap (The Cost of Change).
- The reviewer owns asking for that, rather than asking for an interface.
- The team owns the convention. "New services get interfaces" is a decision that was made once and now makes itself, which is exactly what a revisit trigger is for (Decision Records).
- The decision boundary is evidence: has variation been observed, or is it being predicted? Observed variation justifies an abstraction; predicted variation justifies a note (The Rule of Three).
- The reversibility boundary matters as much. An abstraction inside one module can be removed in an afternoon; one that every caller in the codebase depends on cannot (Reversible and Irreversible Decisions).
- Abstractions that are genuinely hard to retrofit — a persistence seam, an id scheme, a clock — sit outside this argument and are worth building early (Time as a Dependency).
The four bills, and who pays each
The costs of an abstraction are easy to underweight because none of them is paid by the person adding it. They are paid later, by other people, in small amounts, which is the classic shape of a decision that gets made too often.
Writing them out is a two-minute exercise, and in most blocked reviews it ends the argument — usually by making it clear that the abstraction is cheap and worth it, sometimes by making it clear that nobody can name what it hides.
- Four of the five rows are continuous and one is conditional. That asymmetry is the argument, not any individual bill.
- The first row alone justifies deleting single-implementation interfaces that hide nothing: a hop that conveys no information is pure cost (Over-Decomposition).
- Leakage never goes away, which is why the model has to be worth the leak rather than free of it (Leaky Abstractions).
| Bill | What it actually is | Who pays it | When it stops |
|---|---|---|---|
| Indirection | One more hop between a call and the code that runs. With one implementation, the hop carries no information. | Every reader, every time, including the author in six months | Never, while it exists |
| Vocabulary | A new word — Gateway, Policy, Port — that means something specific here and must be learned and kept consistent. | Every new joiner, and every design conversation that has to place a new idea relative to it | Never |
| Learning cost | You cannot change the concrete code without first understanding the abstraction and what else implements it. | Anyone making their first change in this area | Never |
| Leakage | The model does not perfectly match what is underneath, so the details show through under load, failure or an unusual case. | Whoever is on call the first time it matters (Leaky Abstractions) | Never — leakage is a property of the model, not a bug to fix |
| The benefit | A caller may ignore something, and a variant can be added without touching callers. | Received only if the variation arrives | Received once, if at all |
Pricing an abstraction that is never used
The interesting comparison is not "abstraction versus no abstraction on the change it was built for". It is what the ordinary, unrelated changes cost in the branch where the anticipated variation never arrives — which is the branch that actually happened 136 times out of 140 in this codebase.
The numbers below are small on purpose. Nobody abandons an abstraction because one change cost one extra file; the point is that this is the per-change cost and there are hundreds of changes.
Add a field to the notification payload — a routine product request, unrelated to any variation the interface was built to allow.
Five files for one field, and three of them exist only because the interface does. Every reader tracing "what happens when we notify" pays one hop to learn there is exactly one answer.
Two files. A reader goes from the call straight to the code that runs.
When to pay anyway
The default is concrete, and the default has exceptions worth naming precisely, because a rule with unstated exceptions gets applied where it should not.
Every exception below shares one property: retrofitting is much more expensive than building it now, and not because of typing effort — because retrofitting touches call sites, stored data or every test at once.
What does a caller stop needing to know, and what evidence is there that it varies?
when Card and wallet payments; Postgres and the in-memory store used by half the suite.
cost Add it. The shared shape is evidence rather than a guess, and the interface can be derived from two cases instead of one (The Rule of Three).
when A second payment provider with a signed contract and a date.
cost Add it, and shape it from what you know of both. Accept that it will still need adjusting when the second one is real.
when Nobody can name the swap or the date.
cost Do not add it. This is the 136-out-of-140 case, and the interface will be shaped by the only implementation that exists anyway (Speculative Generality).
when Time, randomness, identity generation, persistence, tenancy — things that reach every row of stored data or every test.
cost Add a minimal version now, even with one implementation. The justification is not likely variation; it is that retrofitting touches everything at once (Time as a Dependency).
when The concrete dependency is slow, networked, or has no usable local mode.
cost Add it, and be explicit that testability is the reason — because that reason has different implications: keep the interface narrow to what tests need, and do not grow it toward the vendor's API (Test Doubles, Precisely).
when A plugin API, a public library boundary, a documented extension point.
cost Not a bet at all — design it as a contract, with the versioning and deprecation obligations that implies (Plugin Architecture).
How to build it
Most important first.
- Default to concrete. Call the class. Add the abstraction when there is a second case or a specific, named, likely change (YAGNI, With Its Bill Attached).
- When proposing one, write down the four bills — indirection, vocabulary, learning, leakage — and say who pays each. It is a two-minute exercise that ends most of these arguments.
- Prefer abstractions that let a reader *stop reading*. That is the one benefit that pays immediately rather than conditionally (Information Hiding).
- If it is added speculatively anyway, record the trigger that would justify removing it, so someone is allowed to (Revisit Triggers).
- Count them occasionally. The ratio of interfaces to implementations is a blunt but honest measure of how well the team's predictions have gone (Speculative Generality).
- Make removal socially acceptable. A team where deleting an abstraction is normal will over-abstract far less than one where it reads as regression (Tone, Disagreement and Receiving Review).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Next change, with an unnecessary abstraction: every edit crosses one more file, every test needs one more double, and every new joiner spends a few minutes discovering the interface has one implementation. Small numbers, multiplied by every change and every person, forever.
- Next change, without one, in the branch where variation does arrive: you pay a refactor — typically hours to a day for a well-contained class — plus the feature, under whatever deadline pressure exists then.
- The arithmetic is unequal in a way that decides most cases: the certain tax is small and continuous, the deferred cost is larger and conditional. Multiply the deferred cost by an honest probability and compare. Most teams never do this even roughly, and doing it roughly beats not doing it (The Cost of Change).
- Defaulting to concrete means occasionally paying a refactor under deadline pressure, and that cost is real rather than theoretical.
- Demanding a named change before allowing an abstraction adds friction for experienced engineers who are usually right, and some of that friction is pure waste.
- Counting interfaces against implementations is crude: some interfaces exist for testability alone, which is a legitimate second implementation that this count misses (Test Doubles, Precisely).
What can go wrong
- The interface exists, the second implementation arrives, and it does not fit — because the interface was shaped by the one implementation that existed when it was written (Premature Abstraction).
- The indirection hides where the work happens, so a performance problem is diagnosed a layer away from its cause (Cost-Aware Interfaces).
- The abstraction accumulates optional parameters and flags as callers diverge, until reading it is harder than reading the concrete code would have been (Boolean Parameters).
- The mitigation fails when "write down the four bills" becomes a template filled in to justify a decision already made — which is how any heuristic stops working (Architecture Decision Records).
- An abstraction inverts a dependency, which is its point — but inversion is only valuable if something on the other side genuinely varies (Dependency Inversion).
- Every caller that adopts it becomes a reason it cannot be removed, so cost grows with adoption while benefit does not (API Stability).
- Speculative abstractions create a dependency on a hypothetical requirement, which is the least useful dependency direction available (Volatile Dependencies).
- "So never add interfaces." Add them when there are two implementations, when the seam is expensive to retrofit, or when a fake genuinely makes the test suite simpler. Those are three good reasons and "later" is not one (Dependency Inversion).
- "The cost is one file." The cost is a hop on every read, a word in every conversation, and a fork in every future design discussion about where behaviour goes.
- "Flexibility is free until it is used." It is charged from the moment it exists, in comprehension, and the charge is invisible because it is spread across everyone (The Complexity Budget).
- "Our convention says interfaces, so this is settled." A convention is a decision that stopped being examined. That is exactly the thing worth re-examining (Revisit Triggers).
- speculative-generality
Testing it, and how it ages
- A useful abstraction makes tests simpler: a ten-line fake replaces an elaborate setup. If tests got more complicated when it was introduced, that is evidence about the abstraction (Testing as Design Feedback).
- Watch for tests that assert an interaction with the interface rather than an outcome. That is the leading indicator of an abstraction that models nothing (Mocking).
- Before removing one, put a test at the outer boundary so the removal is verifiably behaviour-preserving (Characterization Tests).
- Unused abstractions do not decay quietly — they attract. Once an interface exists, new code implements it rather than questioning it, so a bad guess propagates.
- The best time to add an abstraction is when the second case exists, because then the shared shape is evidence rather than prediction (The Rule of Three).
- Abstractions built for retrofit-expensive concerns — time, identity, persistence, tenancy — age well even when unused, which is the honest exception to everything above (Randomness as a Dependency).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALIndirection, vocabulary and learning costs are properties of humans reading code, so they apply everywhere; what varies is the size of each bill, not whether it exists.
- LANGUAGE-SPECIFICIn Go or Rust an abstraction can be a small interface or trait declared by the consumer, so the vocabulary cost is local and the indirection often disappears at compile time; in a language where the same thing means a class, a registration and a container entry, the same design decision costs several times as much to adopt and to remove. The advice is identical and the threshold for paying it is not.
- CONTESTEDThe strongest opposing view holds that "abstract by default" is right for large, long-lived systems: the retrofit cost of a missing seam grows superlinearly with the number of call sites, and in practice a team under delivery pressure will never go back and add the seam, so the only version that ever gets built is the one built up front. On that reading the diffuse indirection tax is a genuinely small price for keeping options open, and the codebases people cite as over-abstracted are suffering from bad abstractions rather than from too many. The counter offered here is empirical rather than theoretical — the 140-to-4 ratio — and it concedes that for the specific class of retrofit-expensive seams the opposing view is simply correct.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — "a fake makes the suite simpler" is a legitimate reason to add an interface, and it implies a narrower interface than portability would.
- — Programming Languages & Runtime Internals — whether indirection survives to runtime (virtual dispatch, monomorphisation, inlining) decides whether the indirection bill is only about reading or also about performance.