skip to content

What does "define exception classes in terms of the caller's needs" mean, and how do you use wrapping to apply it to third-party libraries?

level: middleimportance: must knowfreq 66%

answer

  1. Exceptions are API: design for the handler, not the source
  2. Wrapper/adapter = one catch, library named in one file
  3. Always pass the cause — chain or lose the trace
  4. Split types only when a caller would branch
  5. Boundary translates in (library->domain) and out (domain->HTTP)

basics

~20 s

Design exception types around how callers will handle failures, not around where they came from. If a caller treats ten library exceptions identically, wrap them in one of your own exception types thrown from a thin adapter, so the calling code has one catch block and no dependency on the library.

solid answer

~50 s

Callers usually care about *what to do next*, not about which library failed. If a third-party client can throw a dozen unrelated exception types but your code retries or reports for all of them, exposing all twelve is noise: every caller repeats twelve catch clauses and becomes coupled to that library. The fix is a thin **wrapper/adapter** around the third-party API that catches its exceptions and rethrows one domain exception, preserving the original as the *cause* so nothing is lost for debugging. Benefits: one catch block; the library is swappable (the adapter is the only place that mentions it); you can fake the wrapper in tests to simulate failures; the exception's name and payload now speak your domain (`PaymentDeclined(orderId)`, not `SocketTimeoutException`). Granularity follows handling, not causes: create a distinct exception type only when some caller would branch on it. Otherwise one type with structured fields is enough.

code

pseudocode · 19 lines
pseudocode
// caller before: coupled to the library, five identical branches
try { port.open(); }
catch (DeviceResponseException e) { log(e); report(); }
catch (UnlockedException e)       { log(e); report(); }
catch (GMXError e)                { log(e); report(); }

// wrapper owns the library and speaks the domain
class LocalPort {
    void open() {
        try { acme.open(); }
        catch (DeviceResponseException | UnlockedException | GMXError e) {
            throw new PortDeviceFailure("port " + id, e);   // cause preserved
        }
    }
}

// caller after
try { localPort.open(); }
catch (PortDeviceFailure e) { log(e); report(); }

go deeper

for a junior

Say: group exceptions by what the caller will do about them; wrap library exceptions in your own type and keep the original as the cause so the stack trace survives.

for a middle

Add the decoupling and testability arguments, show the wrapper class, and explain granularity driven by caller branching (e.g. retryable vs terminal).

for a senior

Position wrapping at architectural boundaries (adapters in, transport mapping out), discuss checked-exception ripple, over-wrapping, stable error codes for clients, and log-once discipline.

for a principal

Treat the error model as a versioned public contract: a shared taxonomy and code registry across services, retryability semantics that drive client behaviour, consistent transport mapping, PII-safe messages, and correlation ids threading the chain for observability.

## The rule Exceptions are part of an API's contract. Like any contract element, they should be designed for the *consumer*. The practical test is: **would a caller write different code for exception A than for exception B?** If no, they should be one type. A classic example: a device/port/HTTP client library that can throw `DeviceResponseException`, `UnlockedException`, `GMXError`, `SocketTimeoutException`, `SSLHandshakeException`… and the calling code does the same thing for all of them — log and report failure. Exposing all five means every caller writes five catch clauses (or a lazy `catch (Exception)`), and every caller now imports the library's types. ## Wrapping — the mechanism Put a thin class between your code and the library. It catches the library's exceptions and throws one of yours: ``` class LocalPort { // wrapper / adapter private final ACMEPort inner; void open() { try { inner.open(); } catch (DeviceResponseException | UnlockedException | GMXError e) { throw new PortDeviceFailure(e); // e becomes the CAUSE } } } ``` Callers now write one catch. Four distinct wins: 1. **Decoupling.** The library's types appear in exactly one file. Swapping the library, or upgrading it across a breaking change, touches that file only. This is dependency inversion applied to failures. 2. **Testability.** You can substitute a fake `LocalPort` that throws `PortDeviceFailure` on demand — you don't have to make a real socket time out. 3. **Vocabulary.** `PaymentDeclined`, `InventoryUnavailable`, `PortDeviceFailure` are domain concepts. Callers in the domain layer shouldn't be reading `SQLException` — that leaks the persistence choice upward. 4. **Enrichment.** The wrapper is where you attach context the library never had: which order, which tenant, which attempt number. ## Preserving the cause — non-negotiable Always pass the original exception as the cause (`new MyException(msg, e)`, `raise ... from e`, `%w` wrapping, `innerException`). Losing it destroys the stack trace and turns a five-minute diagnosis into an hour of guessing. "Exception chaining"/"cause chain" is the term; logs then show `Caused by: …` down to the root. And never log-and-rethrow the same exception at every level — you get the same failure five times in the log with five different messages. Log once, at the boundary that finally handles it; wrap (adding context) as it passes through layers. ## How many exception types? Drive granularity from handling, not from causes: - One catch-all domain exception per module is often enough (`OrderProcessingException`) with structured fields (`code`, `orderId`, `retryable`). - Split out a type when a caller genuinely branches: `InsufficientFunds` (show a message to the user) vs `PaymentGatewayUnavailable` (retry later) — different code, so different types. - A useful axis is *retryable vs terminal*: transient infrastructure failures can be retried; business rejections cannot. Many teams encode this as two base types or a boolean on the base type. - A shallow hierarchy with a common base lets a caller catch broadly *or* narrowly as it chooses. ## Where the wrapping happens In a layered / ports-and-adapters architecture, wrapping belongs at the **boundary**: the adapter that owns the third-party dependency. Domain code should be able to compile without the library on the classpath. At the *outer* boundary (HTTP handler, message consumer, CLI main) the process reverses: domain exceptions are translated *outward* into transport representations — status codes, problem+json bodies, gRPC codes, exit codes — in one place, so the mapping is consistent and secrets/stack traces never reach the client. ## Trade-offs and failure modes - **Over-wrapping.** Wrapping at every layer with no added information produces a five-deep cause chain that says nothing. Wrap when you cross a boundary or add context, not per method. - **Loss of specificity.** If you collapse everything into one type and a caller *did* need to distinguish, you've hidden information. Keep a code/field or split the type. Do not force callers to string-match the message. - **Checked-exception languages.** Wrapping is also how you avoid a checked exception in a low-level signature rippling through every intermediate method (an Open/Closed violation): the boundary converts it to an unchecked domain exception. - **Don't swallow while wrapping.** `catch (e) { throw new MyException("failed"); }` without the cause is the most common way teams destroy their own debuggability.

  • Doesn't wrapping hide information the caller might need?
    Only if you drop the cause or collapse cases that callers actually branch on. Keep the original as the cause for diagnosis, and expose the decision-relevant distinction as separate types or as a structured field (e.g. retryable/terminal, a stable error code). The goal is fewer *handling* categories, not less information.
  • Where do domain exceptions get turned into HTTP responses?
    In one place per entry point — an exception-mapping handler at the boundary that maps exception type to status code and a safe body (problem+json or a house error envelope), logs once with a correlation id, and never leaks stack traces or internal messages to the client. Scattering that mapping through controllers guarantees inconsistency.
  • How does this interact with checked exceptions?
    A checked exception in a low-level signature propagates its declaration through every intermediate method, so adding one is a breaking change up the whole chain — an Open/Closed violation. Wrapping at the boundary into an unchecked domain exception contains it. Reserve checked exceptions for failures the immediate caller can realistically recover from.

A hospital triage desk: patients arrive with hundreds of different underlying conditions, but the desk classifies them into a handful of categories that determine what happens next — emergency, wait, go home. The original diagnosis is written on the chart (the cause chain) but the routing decision uses the small set.

saying these in an interview costs you the question

  • Wrapping without passing the cause — `throw new MyException("failed")` and the trace is gone
  • Log-and-rethrow at every layer, producing the same failure five times in the log
  • One exception class per underlying cause, so callers need a dozen catch clauses that do the same thing
  • `catch (Exception e)` at every level as a substitute for designing the error model
  • Letting SQLException / HttpClient exceptions travel into domain or UI code
  • Callers parsing exception messages with string matching because no code/field was provided

context