You have wrapped a third-party service behind an interface, yet a provider change still forced edits across many callers. What went wrong, and how do you diagnose and fix a protective boundary that failed?
answer
- stable in name, not in meaning
- leaks: types, errors, shape, capability flags, escape hatch
- semantic leak = idempotency, ordering, consistency
- contract tests run against every implementation
- own the vocabulary; translate at the edge
basics
~20 sThe interface probably wasn't stable: it exposed the provider's types, errors, or behaviour. If callers must know which implementation is behind the interface — for retries, error codes, ordering or quirks — the variation leaked and nothing was truly protected.
solid answer
~50 sAn interface only protects if it hides the **semantics**, not just the class name. Typical leaks: provider types or DTOs appear in signatures; provider exceptions/error codes propagate; the contract mirrors the provider's method set one-for-one, so a new provider forces new methods; behavioural properties differ silently (timeouts, retries, idempotency, ordering, pagination, consistency, transaction scope) and callers compensate; capability flags such as `supportsPartialRefund()` push the branch back to the client; or the concrete type is reachable via a downcast or an escape hatch. Diagnose by asking whether a caller's code or tests would need to change if you swapped implementations — if yes, name exactly what it depends on. Fix by defining the contract in **your** domain vocabulary (not the vendor's), translating errors into your own taxonomy, deciding retry/timeout policy inside the adapter, and adding a contract test suite every implementation must pass so behavioural differences fail at build time rather than in production.
code
pseudocode · 17 lines// LEAKY — stable in name only
interface PaymentGateway:
charge(StripeChargeRequest) -> StripeCharge // type leak
// throws StripeApiException // error leak
supportsPartialRefund() -> bool // capability leak
nativeClient() -> StripeClient // escape hatch
// SEALED — domain vocabulary, owned errors, policy inside
interface PaymentGateway:
authorize(Money, Instrument, IdempotencyKey) -> AuthResult
// fails with: Rejected | Retryable | InvalidRequest | ProviderUnavailable
// adapter owns retries, timeouts, pagination, vendor mapping
// Contract test suite run against EVERY implementation + the fake:
// - same idempotency key twice => one authorization
// - vendor 'card declined' => Rejected (never a vendor exception)
// - vendor timeout => Retryablego deeper
Say the interface probably exposed the provider's types or exceptions, so callers still depended on the provider; the fix is to define your own types and translate at the boundary.
Enumerate the leak categories — types, errors, shape, capability flags, escape hatches — and describe the swap thought-experiment as the diagnostic.
Emphasise semantic leaks (idempotency, ordering, consistency, retries), locate policy inside the adapter, and pin the contract with a test suite every implementation must pass.
Decide when portability is worth paying for at all; where it isn't, make the coupling explicit and confined rather than faking a portable contract, and treat the internal contract as a versioned asset with a deprecation policy.
## Why this happens **Protected Variations** promises that variation behind a stable interface cannot ripple outward. The promise is void when the interface is stable in *name* but not in *meaning*. A **leaky abstraction** (Joel Spolsky's term) is one whose underlying details surface through it despite the wrapper. ## The seven common leaks 1. **Type leak.** The vendor's request/response objects, enums or SDK classes appear in method signatures. Any vendor upgrade recompiles and re-edits every caller. Fix: define your own domain types and map at the boundary. 2. **Error leak.** Vendor exceptions or HTTP status codes escape. Callers write `catch (StripeCardException)`. Fix: translate into a small, owned taxonomy — e.g. `Rejected`, `Retryable`, `InvalidRequest`, `ProviderUnavailable` — and document which are safe to retry. 3. **Shape leak.** The interface is a one-for-one transcription of the vendor's API. Adding a second provider requires either new methods or unimplementable ones (`UnsupportedOperationException`), which violates the Liskov Substitution Principle — a subtype must be usable wherever the supertype is expected, without callers checking which one they have. 4. **Behavioural / semantic leak.** Signatures match, meanings don't: one provider is idempotent and one is not; one is eventually consistent; one paginates at 100, one at 1000; one throws on duplicate submission, one silently succeeds; timeouts and rate limits differ. Callers add compensating code and are now coupled to the implementation, invisibly. This is the leak most people miss. 5. **Capability leak.** `if (gateway.supportsPartialRefund())` — the variation has been *moved into the client*, restoring exactly the conditional you tried to remove. 6. **Escape-hatch leak.** A `getNativeClient()` accessor or a downcast "just for this one feature". Once used, the boundary is decorative. 7. **Configuration/lifecycle leak.** Callers must set provider-specific options, initialise in a particular order, or know about connection pools and credentials. ## Diagnosing an existing boundary Run a **substitution thought-experiment**: if I replaced the implementation tomorrow, what breaks? - Search callers for the provider's name, its types, its error classes, its constants. - Search for downcasts to the concrete type and for `instanceof`-style checks. - Look for `supports*`/`canDo*` predicates on the interface. - Read the tests: do caller tests stub *provider-shaped* behaviour (specific error codes, specific pagination limits)? Tests are where semantic coupling is most visible. - Check retry/timeout/backoff logic: is it in callers (leak) or inside the adapter (contained)? - Ask whether the interface's method names use your domain's words or the vendor's. A sharper structural check: count how many *other* files change in the same commits as the adapter. If adapter commits routinely drag caller commits along, the boundary isn't holding. ## Fixing it 1. **Own the vocabulary.** The interface belongs to the *consumer* side and is expressed in domain terms (`authorize`, `capture`, `refund`), not vendor terms. This is the ownership clause of the Dependency Inversion Principle. 2. **Translate everything at the edge.** Types in, types out, errors, units, time zones, identifiers. The adapter is the only place vendor words are allowed to appear. This is a Domain-Driven Design **anti-corruption layer**. 3. **Decide policy inside.** Retries, timeouts, backoff, circuit-breaking, idempotency keys, pagination iteration — the adapter presents one behaviour regardless of provider. 4. **Pin semantics with contract tests.** Write one test suite against the interface and run it against *every* implementation, including a fake. Assert the behaviour the contract claims: idempotency, error mapping, ordering, empty results, duplicate submissions, partial failure. Now a provider that behaves differently fails at build time. Without such a suite, "stable interface" is an assertion, not a fact. 5. **Keep the interface narrow.** Only the operations clients actually need. A narrow contract is far easier to keep honest across implementations than an exhaustive one. 6. **Accept that some things can't be hidden.** Latency, cost, rate limits, eventual consistency and failure modes are physics, not encapsulation. Where they can't be hidden, make them *explicit and uniform* in the contract (e.g. every call may fail with `ProviderUnavailable`; every listing is a stream, not a list) rather than pretending they don't exist. ## When to accept the leak If a vendor's differentiating capability is the whole reason you chose it, wrapping it behind a portable lowest-common-denominator interface throws away the value you paid for. Then the right move is a deliberate one: **don't** pretend to be portable; couple openly, isolate the coupling to as few modules as possible, and record the decision. Fake portability is more dangerous than acknowledged coupling, because it produces confidence without protection.
- What is a contract test suite, and why is it essential for this kind of boundary?One test suite written against the interface and executed against every implementation, including the in-memory fake used by callers' tests. It converts the interface's *semantic* promises — idempotency, error mapping, ordering, empty-result handling — into build-time checks, so a provider that behaves differently fails immediately instead of surprising production.
- When is it right to *not* wrap a third-party dependency?When the dependency is stable, ubiquitous and effectively a standard; when the wrapper would only rename its API without translating anything; or when the vendor's differentiating capability is precisely why it was chosen — in which case a portable lowest-common-denominator interface destroys the value. Then couple openly and confine the coupling to few modules.
- How does the Liskov Substitution Principle relate to a leaking boundary?LSP says a subtype must be usable wherever the supertype is expected. If one implementation throws 'unsupported', silently ignores a parameter, or has weaker guarantees, callers must know which one they hold — substitutability is broken, and the variation has escaped the boundary.
saying these in an interview costs you the question
- Believing that introducing an interface is by itself sufficient protection, regardless of what the signatures and error types expose.
- Overlooking behavioural leaks — idempotency, ordering, consistency, pagination, timeouts and rate limits differ even when signatures match perfectly.
- Adding `supports*` capability flags to the interface and calling the design portable, when callers must branch on them.
- Providing a `getNativeClient()` escape hatch 'temporarily' and then treating the boundary as intact.
- Wrapping a vendor with a pass-through layer that renames methods but translates no types, errors or semantics.
- Insisting on portability across providers when the vendor's unique capability is the reason it was selected — fake portability costs value and delivers no protection.