The interface with one implementation, and the three years it cost
An abstraction written for a second implementation that never arrived is pure indirection.
TL;DR
A payment abstraction was built to support "any provider". Three years later it had exactly one implementation, and the abstraction had cost more than the flexibility it never delivered.
The interface's shape was derived from one provider's API, so when a second one finally arrived it did not fit — and the abstraction had to be rewritten anyway. Meanwhile every change had passed through four layers, and every stack trace went through a factory, a strategy and a registry to reach twelve lines of real work.
Wait for the second case. An abstraction extracted from two real implementations fits both; one guessed from a single implementation fits only it, and charges indirection for the privilege.
The case this post is about
Built in year one, for a product with one payment provider:
// 6 files, 410 lines, one implementation
interface PaymentProvider {
suspend fun charge(request: ChargeRequest): ChargeResult
suspend fun refund(chargeId: String, amount: Money): RefundResult
suspend fun capability(): Set<Capability>
}
interface PaymentProviderFactory { fun create(config: ProviderConfig): PaymentProvider }
class PaymentProviderRegistry(private val factories: Map<String, PaymentProviderFactory>) {
fun resolve(name: String): PaymentProvider = factories.getValue(name).create(configFor(name))
}
class PaymentStrategySelector(private val registry: PaymentProviderRegistry) {
fun select(order: Order): PaymentProvider = registry.resolve(configuredProviderName)
}
class StripePaymentProvider(private val client: StripeClient) : PaymentProvider { … }
The justification in the design doc was reasonable:
We will likely need a second provider for the EU launch, and possibly a third for marketplace payouts. Abstracting now avoids a painful refactor later.
The symptom
Three years, one implementation. The EU launch used the same provider. The marketplace work was descoped.
Every change traversed the abstraction. Adding a 3-D Secure flow:
files changed: 7
PaymentProvider.kt + 1 method on the interface
StripePaymentProvider.kt + the actual implementation (22 lines)
FakePaymentProvider.kt + a stub
PaymentCapability.kt + a capability enum value
PaymentStrategySelector.kt + a capability check
PaymentProviderRegistry.kt + nothing, but had to be read to understand the flow
PaymentProviderFactory.kt + nothing, same
Twenty-two lines of real work across seven files, two of which were changed only to satisfy the abstraction.
Stack traces were four frames deeper than the problem:
PaymentStrategySelector.select
PaymentProviderRegistry.resolve
StripePaymentProviderFactory.create
StripePaymentProvider.charge
StripeClient.post ← the first frame that does anything
New engineers consistently lost an hour tracing select → resolve → create to discover
there is one provider and the selection is a constant.
And then the second provider arrived — a payouts provider for marketplace sellers — and did not fit:
charge(request): ChargeResult — payouts has no charge
refund(chargeId, amount) — payouts reverses a transfer; different identifier type
capability(): Set<Capability> — fine
— payouts needs: payout(), balance(), onboardSeller()
— and its operations are ASYNC with webhooks, not
request/response
The interface described one provider's request/response charge model. The second provider was asynchronous, payout-shaped, and shared almost nothing. We rewrote the abstraction — which is the refactor the abstraction existed to avoid.
Why the obvious fix failed
"Make the interface more general." We tried, and generalising to cover both produced an
interface where every method was optional and capability() gated everything:
suspend fun charge(r: ChargeRequest): ChargeResult = error("unsupported")
An interface whose methods throw for half its implementations is a union type pretending to be a contract. Callers must check capabilities before every call, which is the switch statement the abstraction was supposed to remove.
"Add a second interface and keep both." This is the eventual answer, and it means the original abstraction was never the right shape — the realisation we were avoiding.
"The abstraction made testing easier, so it earned its keep." The strongest argument, and it does not hold: testing needs a seam, which is one interface with a fake. It does not need a factory, a registry and a strategy selector. We kept the interface and deleted the other four files with no effect on the tests.
"It will pay off eventually." Three years, and when the second case arrived the abstraction did not fit it. The payoff was negative: we paid indirection for three years and the refactor.
The actual mechanism
An abstraction encodes which things vary. You derive that from the implementations you can see.
With one implementation, the variation is a guess — and the guess is drawn from the single example, so the abstraction's shape is that example's shape with the names made generic:
one implementation → the interface mirrors it
→ a second, genuinely different implementation does not fit
→ rewrite
With two, the variation is observed: the methods both need, the parameters that genuinely differ, the operations only one has. That is information you cannot have before the second case exists.
Meanwhile the costs are immediate and continuous:
Indirection on every read. Four files to understand one call path, and new readers pay most.
Change amplification. A one-line behaviour change touches the interface, the implementation, the fake, and anything switching on capabilities.
And a false sense of flexibility, which is the expensive one: the team believed adding a provider was cheap, so the payouts project was scoped as "wire up the existing abstraction" and turned out to be a rewrite.

You cannot know what varies until you have two. An abstraction from one implementation is a guess with a maintenance cost, and when the second case arrives it usually needs rewriting — so you pay the indirection and the refactor.
The fix
Collapse to the concrete, keep the one seam testing needs, and extract the real abstraction from two implementations.
1. Delete the factory, registry and selector. Keep the interface:
// commonMain — ONE interface, for the test seam. No factory, no registry, no selector.
interface CardPayments {
suspend fun charge(request: ChargeRequest): ChargeResult
suspend fun refund(chargeId: String, amount: Money): RefundResult
}
class StripeCardPayments(private val client: StripeClient) : CardPayments { … }
deleted: PaymentProviderFactory, PaymentProviderRegistry,
PaymentStrategySelector, PaymentCapability (−286 lines)
kept: CardPayments (the seam), StripeCardPayments, FakeCardPayments
2. The payouts provider gets its own interface, shaped by its own reality:
// a genuinely different capability, modelled honestly rather than unified
interface SellerPayouts {
suspend fun onboardSeller(seller: SellerId): OnboardingLink
suspend fun requestPayout(seller: SellerId, amount: Money): PayoutRequestId
// asynchronous: the result arrives by webhook, not by return value
fun payoutEvents(): Flow<PayoutEvent>
}
Two interfaces, each fitting one thing, is simpler and more honest than one interface fitting neither.
3. And where they genuinely share something, extract that — from the two real cases:
// the only thing both actually have in common, discovered by having both
interface ProviderHealth {
suspend fun check(): HealthStatus
}
One method, derived from two implementations, and it fits both because it was observed rather than predicted.
4. The rule, written into the review guide:
## Abstractions
Extract an interface when there are TWO real implementations, or when one is
needed as a test seam (ONE interface, no factory/registry/strategy around it).
Do not add an abstraction for an implementation that does not exist yet. Record
the anticipated need in the ADR instead — a note costs nothing and does not
have to be maintained.
That last sentence is the useful half: the original instinct — "we will need a second provider" — was worth recording. Recording it in prose costs nothing; recording it in four classes costs three years.
How we proved it
The cost of the collapse:
before after
files in the payment package 9 5
lines 612 326
layers from call site to the API 4 1
files changed for a one-line
behaviour change 7 2
That the tests did not need the deleted layers — the claim the "testing" argument rests on:
payment test suite: 48 tests
before collapse: 48 passing
after collapse: 48 passing, 0 modified
Zero test changes. The factory, registry and selector contributed nothing to testability.
New-engineer onboarding, measured on four people:
time to correctly explain how a charge reaches Stripe
before: 52 min median (4 of 4 initially believed multiple providers existed)
after: 9 min median
That parenthetical is the clearest cost: the abstraction actively misinformed every reader about what the system did.
And the payouts project, re-scoped honestly:
original estimate (assuming the abstraction fit): 5 days "wire up a new provider"
actual, with the abstraction: 19 days (14 of them rewriting it)
re-estimate with separate interfaces: 6 days
Fourteen days spent on the abstraction that existed to save time. Not a rhetorical point — it is in the sprint records.
One limit worth being fair about: this is one case, and abstractions built for a single implementation sometimes do pay off — when the second implementation is genuinely similar, which happens when the domain is standardised (a JDBC driver, an OAuth provider, an S3-compatible store). The distinguishing question is whether a specification exists that constrains the second implementation's shape. For payment providers it does not; for OAuth it does. "Wait for two" is the right default, not a law.
What this generalizes to
You cannot know what varies until you have two examples. This is the whole argument. An abstraction is a theory about variation, and a theory with one data point is a description of that data point.
Indirection has a continuous cost and a speculative benefit. The cost is paid on every read, every change, and every onboarding. The benefit arrives only if the second case appears and fits. Compare those honestly and the default is to wait.
A test seam is one interface, not a framework. If testability is the justification, the justification covers an interface and a fake. A factory, registry and strategy selector around it are not doing that job — ours were deleted with no test changes.
Record the anticipated need in prose. The original instinct was reasonable and worth capturing. An ADR saying "we may need a second provider; we have deliberately not abstracted for it" preserves the thinking at zero maintenance cost, and tells the next person the omission was a decision.
Related
- The architecture decision nobody wrote down, re-litigated four times — where the ADR note belongs
- The most valuable pull request that quarter deleted 4,000 lines — proving something is safe to remove
- expect/actual is not an interface, and the difference shows up at link time — when a seam genuinely is needed
Keep reading
This is part of Architecture decisions & code review — how choices get made, recorded, and revisited. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".