When you write an Adapter and the adaptee cannot fully satisfy the target interface — a missing capability, different error model, or different concurrency model — what are your options, and what makes an adapter "leaky"?
answer
- Emulate / narrow / capability-query / refuse
- Unsupported-operation throw = LSP violation
- Translate errors, not just signatures
- Units, nulls, ordering, timezones leak
- Contract tests across all adapters
basics
~20 sOptions: emulate the missing behavior inside the adapter, expose a capability check so callers can ask first, narrow the target interface so nobody has to lie, or reject the adaptee. Throwing "unsupported" silently is the worst choice.
solid answer
~50 sFour honest responses. **Emulate** — implement the gap in the adapter (buffer bytes to fake push-back/seek, page through results to fake a full list, sort in memory). Costs memory, latency and correctness edge cases, but preserves the contract. **Narrow the target** — apply interface segregation so the port only demands what every realistic adaptee can do; the fat method moves to a separate optional interface. **Capability query** — the target declares `supports(x)`/optional sub-interfaces and clients branch, which is honest but pushes complexity onto every caller. **Refuse** — fail fast at construction rather than at the 400th call. Throwing "unsupported operation" from a method the interface promises is a Liskov violation: clients coded to the target break on this implementation. "Leaky" means adaptee details escape: vendor exception types or DTOs crossing the boundary, error codes clients must special-case, blocking calls over an async adaptee (thread exhaustion), differing null/absence, ordering, units, time-zone, idempotency or thread-safety guarantees. The adapter must translate *semantics*, not just signatures.
code
pseudocode · 24 lines// Target contract: fetchAll returns every record, throws only StoreError
interface RecordStore { fetchAll(query): List<Record>; }
class PagedApiAsRecordStore implements RecordStore {
fetchAll(query) {
val out = mutableList()
var cursor = null
try {
do { // EMULATE: paging -> full list
val page = sdk.search(query, cursor) // adaptee is paged
out += page.items.map(::toRecord) // translate DTO -> domain
cursor = page.next
if (out.size > MAX) throw StoreError.TooLarge() // honest guard
} while (cursor != null)
} catch (e: SdkHttpError) { // TRANSLATE the error model
throw when (e.status) {
404 -> StoreError.NotFound(cause = e)
429, in 500..599 -> StoreError.Unavailable(retryable = true, cause = e)
else -> StoreError.Unexpected(cause = e) // never leak SdkHttpError
}
}
return out
}
}go deeper
Say the adapter must convert data and errors, not just method names, and that if the adaptee can't do something you should fail clearly rather than pretend.
List the four options (emulate, narrow, capability query, refuse) with a cost each, and give a concrete error-translation example.
Bring in LSP, concurrency/blocking hazards, resource lifecycle and paging, and contract tests as the enforcement mechanism.
Discuss port design as an organizational contract: interface segregation, versioning a port across many adapters, bulkheads and timeouts per dependency, observability of unmapped errors, and when the honest answer is 'this adaptee cannot satisfy the port'.
## The real difficulty of adapters Matching method *names* is trivial. What breaks in production is the **semantic gap** — the adaptee promises something subtly different from what the target promises. An adapter is only correct if a client written against the target's contract cannot tell which adaptee is underneath. That is the **Liskov Substitution Principle** applied to a wrapper: no strengthened preconditions, no weakened postconditions, no surprise failure modes. ## Kinds of mismatch and how to handle each ### 1. Missing capability Target has `seek(position)`; adaptee is a forward-only stream. | Option | What it costs | |---|---| | **Emulate** — buffer consumed bytes so backward seeks replay | Memory grows with the window; unbounded seeks impossible; adapter now has state and needs its own tests | | **Narrow the target** (Interface Segregation) — split `Seekable` out of `ByteSource`; clients that need seeking accept `Seekable` | Best long-term shape; requires you to own and be able to change the target | | **Capability query** — `if (source.isSeekable())` or optional sub-interface + type test | Honest, but every call site grows a branch, and untested branches rot | | **Refuse at construction** — the adapter simply cannot be built for this adaptee | Fails fast, clearest, but reduces which adaptees are usable | | **Throw at call time** with no way to check first | Worst: an LSP violation the client cannot defend against | A hard truth: an "optional operation" in a widely-implemented interface (the classic immutable-collection `add()` that throws) is a known design smell — it moves a compile-time question to run time. ### 2. Error model Adaptee throws `SdkTimeout`, returns `-1`, sets a global `lastError`, or resolves a rejected promise. The target defines its own vocabulary (`PaymentUnavailable`, `NotFound`, result types). **Translate exhaustively and default safely**: map known errors, wrap unknown ones in a target-level failure that preserves the original as a cause for logs, and never let an adaptee type appear in a signature or propagate uncaught. Distinguish *retryable* from *terminal* — losing that distinction is a common production bug. ### 3. Concurrency and blocking - Async adaptee behind a synchronous target → the adapter blocks a thread per call; on a bounded pool this deadlocks or collapses throughput. - Blocking adaptee behind an async target → you must hand off to a dedicated pool, or you stall an event loop. - **Thread-safety**: if the target promises safe concurrent use and the adaptee isn't, the adapter must add synchronization or pooling (and document it). Adapter instances that cache adaptee state are a classic accidental race. - **Cancellation and timeouts**: does the adaptee honor them? If not, a target-level timeout that just abandons the call leaks a running operation. ### 4. Data and semantics Units (cents vs. decimal, metres vs. feet), precision/rounding, encodings, time zones and clock source, null vs. empty vs. "absent", inclusive vs. exclusive ranges, 0- vs. 1-based indexing, ordering guarantees (target says stable, adaptee doesn't), duplicates, case sensitivity, and identity/equality. Each is a real outage in some system's history. The famous unit-conversion class of failure is exactly an adapter-boundary defect. ### 5. Cardinality and shape Target wants a full list, adaptee pages → the adapter either materializes everything (memory blowup) or returns a lazy sequence (then the target must permit laziness, and resource lifetime becomes the client's problem). Target wants one call, adaptee needs open/use/close → who owns the lifecycle? Prefer making the target's contract explicit about resource ownership rather than hiding a leak. ### 6. Idempotency and side effects If the target promises retry-safety and the adaptee's operation isn't idempotent, the adapter must supply idempotency keys or refuse to retry. A retry decorator layered over such an adapter silently double-charges. ## What "leaky" means, concretely An adapter is leaky when a client of the target must know which adaptee is behind it. Signs: - vendor exception/DTO/enum types in the target's signatures or thrown across it; - documented caveats like "when using the X backend, call `flush()` first"; - clients special-casing error strings or codes; - performance characteristics so different that callers must branch (in-memory vs. network) with no way to know; - configuration of the adaptee bleeding into target-level APIs. ## Practical safeguards 1. **Contract tests / consumer-driven tests**: one abstract test suite written against the *target's* contract, executed against every adapter — the single most effective tool for keeping adapters honest. 2. **Property/round-trip tests** for the conversion functions themselves (parse∘format = identity). 3. **Keep the adapter thin**: translation only; put retry, caching, metrics in separate decorators over the target so they're shared and independently testable. 4. **Fail fast at wiring time** where possible (validate credentials, capabilities, versions in the constructor). 5. **Log the untranslatable** with the original cause, and add a metric for "unmapped adaptee error" so silent gaps surface. 6. **Version the port** deliberately: adding a method to the target is a breaking change for every adapter — prefer a new optional interface.
- How do you keep several adapters for the same target honest over time?Write one abstract contract-test suite against the target's documented behavior — error cases, ordering, null/empty semantics, idempotency — and run it as a subclass per adapter, plus an in-memory fake. Any adapter that quietly weakens a guarantee fails the shared suite instead of failing in production.
- Is throwing an 'unsupported operation' error from an adapter ever acceptable?Only when the target contract explicitly declares the operation optional *and* provides a way to check support before calling, and when the failure is loud, immediate and testable. Even then, splitting the interface is usually better — it turns a run-time surprise into a compile-time fact.
- An adapter wraps a synchronous, blocking SDK but your target interface is asynchronous. What's the risk and the fix?Blocking a shared event-loop or bounded pool thread starves unrelated work and can deadlock. Fix: dispatch adaptee calls to a dedicated, bounded, named thread pool sized for that dependency (bulkhead), propagate cancellation and timeouts, and surface saturation as a metric — never block the caller's scheduler thread.
An interpreter for a language that has no word for a concept in the original. They can paraphrase at length (emulate), stop and warn you the concept doesn't exist (capability query), agree in advance to avoid that topic (narrow the interface), or decline the assignment (refuse). What they must not do is stay silent and let you believe the point was conveyed.
saying these in an interview costs you the question
- Treating adapters as pure signature mapping and ignoring units, null semantics, ordering and error models.
- Letting vendor exception types or DTOs cross the boundary — the client then depends on the adaptee anyway.
- Throwing 'not supported' from a target method with no capability check, then calling it a valid adapter.
- Blocking on an async adaptee (or vice versa) without a bulkhead, then blaming the framework for thread exhaustion.
- Stuffing retry, caching and metrics into the adapter so the translation logic can't be tested in isolation.
- Assuming a retry wrapper is safe over a non-idempotent adaptee operation.