Engineering

The interface with one implementation, and the three years it cost

An abstraction written for a second implementation that never arrived is pure indirection.

MobileByteSensei 8 min read
practicesarchitecturecode-review

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.

An abstraction derived from a single implementation, so its shape mirrors that implementation with generic names — and a genuinely different second provider, asynchronous and payout-shaped, failing to fit so the abstraction is rewritten anyway; against the cost already paid in four indirection layers, seven files per change, and a scoping estimate built on flexibility that did not exist

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.

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".

Follow by RSS