Every Abstraction You Add Is a Debt Someone Else Has to Understand
by Arif Ikhsanudin, Backend Developer
The Cost That Doesn't Show in Your PR
Adding an abstraction layer feels like simplification. The calling code gets shorter. The implementation details disappear. The interface expresses intent at a higher level. Your PR looks cleaner.
What doesn't show in your PR is the bill: every developer who reads this codebase from now on has to understand the abstraction. They have to know what the abstraction does, where it's defined, what it hides, and when they need to look beneath it. That cost is paid repeatedly, by multiple people, across the lifetime of the codebase.
The abstraction you added in two hours may accumulate hundreds of hours of comprehension cost over a two-year codebase lifetime, depending on how many engineers encounter it and how often. When the abstraction is genuinely simplifying, this is a good trade. When it's adding indirection without reducing complexity, it's a hidden tax.
The Compounding Problem in Layered Systems
Individual abstractions are manageable. Systems with many abstraction layers compound the problem. In a layered architecture, a request might flow through:
HTTP Handler → Controller → Service → Repository → QueryBuilder → JDBC
Each layer is an abstraction. Each layer requires the developer to hold the contract of that layer in mind while reading the layer below it. Six layers means six mental models, all active simultaneously when debugging an issue that crosses layers.
This isn't an argument against layered architecture. It's an argument for keeping the number of layers proportional to the complexity they're actually managing. Three layers with clear responsibilities are better than six layers where the middle three exist primarily for theoretical flexibility.
What Justifies an Abstraction
The question to answer before adding an abstraction: what specific cognitive load am I removing, and does the cost of understanding the abstraction exceed the benefit of not knowing what it hides?
Justified abstraction: A PaymentProcessor interface with multiple implementations (Stripe, PayPal, Braintree) that are actually interchangeable in production. The caller genuinely doesn't need to know which provider is active. The abstraction removes real coupling between payment provider choice and the code that initiates payments.
Questionable abstraction: A Repository that wraps every database operation in a single codebase that has always used PostgreSQL and has no plans to change. The repository provides testability (legitimate), but also adds an indirection layer that every future developer has to traverse to understand what database operations are actually happening.
Unjustified abstraction: A StringHelper utility class with one method, used in one place, that wraps a trivially understandable string operation. This provides no benefit and adds one more file and one more layer of indirection to understand.
The Indirection Inversion
There's a useful distinction between abstraction that reduces complexity and indirection that merely relocates it. A well-named function that packages a complex algorithm is an abstraction — it hides complexity behind a clear interface and the complexity doesn't need to be understood unless the function is being modified. A factory that creates an object using five lines of code that could have been inline is indirection — the complexity hasn't been hidden, it's just in a different file.
Indirection forces the reader to navigate the codebase to follow the logic. In a large, active codebase, this navigation tax is significant. Every goto-definition that leads to another goto-definition is a tax on comprehension.
The Counter-Argument Worth Acknowledging
There are legitimate arguments for abstractions beyond immediate complexity reduction:
- Testability: abstractions over external dependencies allow test doubles
- Extension points: well-defined interfaces make adding new implementations easier
- Boundary enforcement: explicit interfaces between modules prevent inadvertent coupling
These are valid. The point is not that abstractions are bad — it's that they have costs, and those costs should be weighed against the benefits explicitly. The default should not be "add an abstraction when in doubt." The default should be the simplest design that meets the requirements, with abstractions added when they provide specific, named benefits that outweigh their comprehension cost.
The Practical Takeaway
For the next abstraction you consider adding, write down: what specific complexity does this remove for the caller? What does the caller no longer need to know? Then estimate: how many times will a developer need to understand this abstraction over the next year? If the removal of complexity per encounter is less than the cost of understanding the abstraction, keep the concrete implementation. If it's greater, add the abstraction — and document what it hides and why that information is irrelevant to callers.