7 min read

The Cost of an Abstraction You Added Just in Case

Speculative abstraction arrives as a reasonable question in code review and bills everyone daily. What it actually costs, why the refactor you're avoiding is nearly free, which abstractions are worth building up front, and how to delete the ones that aren't.

ArchitectureAndroidKotlinCode QualityRefactoring
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — An interface with one implementation called XxxImpl is a bet on a future you can't see, paid for by every person who reads the code afterwards. It costs two files per behaviour, a broken call graph, and — worst — it anchors the wrong shape, so when the second implementation finally arrives it doesn't fit. Meanwhile the refactor you're avoiding is an IDE keystroke. Build abstractions when you have two real cases in hand, not one imagined. And deleting a speculative abstraction is nearly always safe, because there's only one implementation and therefore no behaviour to preserve.

How it gets in

Nobody proposes speculative abstraction on its merits. It arrives as a question in review:

"What if we need to swap this out later?"

The question is cheap to ask, sounds prudent, and is socially awkward to refuse — declining it means arguing that flexibility is bad. So the interface gets added, the reviewer approves, and everyone feels responsible.

The asymmetry is the problem: the cost of asking is one sentence; the cost of complying is permanent.

What it actually costs

Two files to understand one behaviour

Every reader who wants to know what PaymentClient.charge() does presses "go to definition" and lands on an interface — a signature and no answer. Then they find the implementation. Then they come back.

That's four seconds. Multiply by every engineer, every time, for the life of the code. It's small enough never to be worth complaining about individually, which is exactly why it accumulates unchallenged.

Worse for the newcomer: an interface implies that multiple implementations exist. They'll go looking for the other ones. Finding none, they'll assume they missed something. You've made your codebase say something untrue about itself.

The call graph stops working

interface PaymentClient { suspend fun charge(m: Money): Result }
class PaymentClientImpl @Inject constructor(...) : PaymentClient

Now "find usages" on PaymentClientImpl.charge returns nothing. Every question about who calls what has an extra hop, and static analysis, refactoring tools and your own reading all get one step worse. On a large codebase this compounds.

It anchors the wrong shape

This is the expensive one, and it's the opposite of what the abstraction was for.

You designed the interface with one implementation in view, so it encodes that implementation's assumptions: its error type, its synchronicity, its parameter granularity. Eighteen months later the real second case arrives — a different provider, with a different flow and a webhook — and it doesn't fit.

Now you choose between two bad options: bend the new case into the old shape (leaky, and both are worse), or change the interface (which touches every call site — precisely the refactor the abstraction was meant to prevent).

The abstraction didn't just fail to pay off. It made the eventual change harder than no abstraction would have. A single concrete class with clear call sites is straightforward to split when you finally know the axis to split on.

It's a tax everyone pays so one person can skip a refactor

That's the honest accounting. And the refactor being avoided:

// Right-click → Refactor → Extract Interface.
// Fifteen seconds. Every IDE, for twenty years.
class PaymentClient { … }

Refactoring later is a certainty you can act on when you have information. Abstracting now is a bet on a future you can't see. Prefer the certainty.

The Android-specific variants

The pattern shows up in recognisable local forms:

  • BaseViewModel, BaseFragment, BaseActivity. Inheritance for code sharing, which locks every screen into one lifecycle story. A BaseViewModel accumulates one member per feature that needed something special until it's the union of every screen's requirements.
  • A Mapper<A, B> interface with one mapper. It's a function. Make it a function.
  • Result<T> wrappers around a wrapper — three layers where one sealed class would do.
  • A repository interface per repository, generated reflexively. Some deserve one. Most are a single data source with an interface bolted on because the layer diagram in the design doc had a box.
  • Config objects with one configuration. SyncConfig(intervalMinutes = 15) used exactly once.
  • A plugin architecture for a thing with one plugin. Usually the most expensive of the set.

What is worth abstracting up front

The rule isn't "never abstract". It's "abstract when you have both sides in hand".

Two real implementations, existing today. Not planned. Not likely. Present in the repository.

A genuine platform boundary. expect/actual in Kotlin Multiplatform, or anything that behaves differently on TV versus phone. The variation isn't hypothetical — the platforms already differ.

A seam you need for tests. Clock, network, file system, random. Legitimate — but consider whether a hand-written fake beats an interface plus a mocking framework. Usually it does: a FakePaymentClient that records calls is more readable than a mock setup and it survives refactoring.

A published API boundary. If another team compiles against it, the interface is the contract and it's doing real work.

Everything else: wait. Two occurrences can be coincidence; three is a pattern.

How to delete one

The good news, and it's substantial: speculative abstractions are among the safest things to remove, because there's exactly one implementation and therefore no behaviour to preserve.

  1. Inline the interface. IDE: Inline on the interface, or rename PaymentClientImpl → PaymentClient and delete the old interface.
  2. Fix the DI binding — @Binds becomes a constructor injection, or disappears entirely.
  3. Update the tests. If they used a mock of the interface, this is the moment to replace it with a fake or a real instance. The tests usually get better.
  4. Commit it alone. No behaviour changes in the same commit, so a revert is trivial and review is a glance.

Do this once on a module and count the files that disappear. It's usually more than you'd guess, and the module gets easier to read immediately.

The counter-argument, taken seriously

"Adding the interface later means touching every call site."

True, and it's a mechanical, IDE-assisted, compiler-verified change that fails loudly if it's wrong. Compare that to the failure mode of the speculative version: an interface shaped around the wrong assumptions, discovered eighteen months later, changed under deadline pressure by someone who doesn't know why it exists.

One of those risks is bounded. The other isn't.

The rule

Wrong abstractions cost more than duplication, and they're much harder to see. Duplication announces itself — you notice the third copy. A bad interface looks like architecture, gets defended in review, and outlives everyone who remembers why it's there. Build the abstraction when the second real case shows up and tells you where the seam actually is.

Keep reading