A repository operation that hides SQL lets a raw driver failure — a SQLSTATE code, a socket timeout — escape to a business-level caller. Explain why that is an abstraction-level defect, and how different languages' error models push you to fix it.
answer
- failure type is part of the contract
- translate at the boundary, keep the cause
- Go %w plus errors.Is/As = opaque by default, transparent on request
- Rust ? uses From; thiserror for libs, anyhow for apps
- Erlang: no translation — crash to the supervisor
basics
~20 sThe caller must now understand the layer it was insulated from, and its own callers inherit that coupling. Java translates and wraps, Go wraps with %w and reopens deliberately via errors.As, Rust converts through From at the ? boundary, Python chains with raise-from, Erlang crashes to a supervisor instead.
solid answer
~50 sA failure type is part of an interface, so an unwrapped driver error makes the driver part of your contract. - **Java**: checked exceptions make the level shift compile-visible — either `throws SQLException` appears in the signature or you translate; Spring's DataAccessException hierarchy exists to map vendor SQLSTATEs onto level-appropriate types. Price: unchecked wrapping is easy, and then the level leaks anyway. - **Go**: `fmt.Errorf("...: %w", err)` preserves the cause while the message states the level, and `errors.Is/As` lets a caller deliberately reopen the lower layer. Price: entirely voluntary. - **Rust**: `?` applies a `From` conversion, so the boundary conversion is type-checked; libraries use thiserror enums, applications use anyhow's opaque error plus context. Price: an enum with a variant per driver is the leak with better syntax. - **Python**: `raise DomainError from exc` keeps `__cause__` for the traceback while the raised type stays level-appropriate. - **Erlang**: refuses translation for unexpected faults — crash, and let a supervisor at the right level decide.
code
rust · 6 linesimpl From<PgError> for RepoError { /* map SQLSTATE to a domain case */ }
fn load(id: Uuid) -> Result<Customer, RepoError> {
let row = db.query_one(SQL, &[&id])?; // From applies here; PgError never escapes
Ok(row.into())
}go deeper
Know that callers should see errors in the vocabulary of the interface they called, and that the original cause must be kept for diagnosis.
Show the translate-and-wrap pattern in at least two languages and explain why the failure type belongs to the contract.
Design the failure vocabulary from what callers can act on, choose the boundary where translation happens, and explain how Go's errors.As or Rust's typed enums offer deliberate transparency without making it the default.
Set the policy across services: which failure categories are contractual, how retryability is signalled end to end, and when the Erlang answer — do not translate, crash to a supervisor that has the context — is the better architecture.
## Why this is a levels problem, not an error-handling problem An interface is a promise about the vocabulary a caller must understand. A repository that offers `findCustomer(id)` promises that customers, not connections, are the vocabulary. When a driver exception escapes, the caller is suddenly required to know about SQLSTATE 40001, connection pools and socket timeouts to write a correct `catch`. Worse, it becomes structurally coupled: if you swap the storage engine, every caller that pattern-matched on the old failure type breaks. The failure type is as much part of the contract as the return type, and mixing levels there is exactly the mixed-abstraction smell — a routine that speaks policy in its name and mechanism in its failures. The fix has a name in the literature: exception translation — catch what the lower layer throws, raise something in your own vocabulary, and keep the original as the cause so operators can still diagnose. The interesting part is that each language's error model pushes you toward or away from doing it. ## Five models, five pressures **Java's checked exceptions** make the level shift visible at compile time: either `SQLException` appears in your signature — announcing that the abstraction is not really hiding storage — or you translate it. That is real design pressure, and it is why Spring's data access layer maps vendor-specific SQLSTATE codes onto a portable hierarchy such as DuplicateKeyException and OptimisticLockingFailureException: callers express "the key already exists" without naming a database. The failure mode is equally famous: wrapping in an unchecked exception and rethrowing satisfies the compiler while the low-level type still travels upward inside. **Go** makes errors ordinary values, so the level decision is a line of code: `fmt.Errorf("load customer %s: %w", id, err)` states the current level in the message while `%w` retains the cause. The retained cause is deliberate: `errors.Is(err, sql.ErrNoRows)` and `errors.As` let a caller *choose* to look through the abstraction. That is an unusual and honest design — the abstraction is opaque by default and transparent on request — but nothing forces the wrap, so a returned bare driver error compiles fine. **Rust** puts the conversion in the type system. `?` invokes `From`, so writing `fn load(id: Uuid) -> Result<Customer, RepoError>` obliges you to define `From<PgError> for RepoError`; the boundary conversion is checked rather than remembered. The ecosystem then splits by role: `thiserror` for libraries, where each failure is an enumerated variant callers can match, and `anyhow` for applications, where the error is opaque and carries context strings. The trap is an enum with one variant per underlying driver error, which type-checks beautifully while re-exporting the lower level verbatim. **Python** allows the same discipline with explicit provenance: `raise RepositoryError("customer lookup failed") from exc` sets `__cause__`, so the traceback shows both levels while the raised class stays in the domain vocabulary. Omit `from` and Python still attaches `__context__` implicitly during exception handling — the leak shows in the traceback even when you did not intend it, which is an argument for translating explicitly rather than hoping. **Erlang and Elixir** reject the premise for unexpected faults. Expected outcomes are values (`{:ok, row}` / `{:error, :not_found}`) that are already in the caller's vocabulary; genuinely unexpected faults are not translated at all — the process crashes and a supervisor at a level that can decide restarts it. "Let it crash" is not fatalism; it is placing the recovery decision at the abstraction level that has enough context to make it, instead of forcing every intermediate layer to invent a translation it does not care about. ## Practical guidance Define the failure vocabulary of an interface deliberately, with as few cases as callers can actually act on — not-found, conflict, transient, invalid — and translate at the boundary that owns the implementation detail. Always retain the cause (`%w`, `from exc`, an inner exception, a `source()`), because diagnosis needs the low level even when the contract must not name it. Never let "transient, retry me" become invisible: it is a domain-relevant distinction that vanishes when everything is flattened into one opaque error. And resist the opposite error — translating at every one of six layers so a single failure acquires six wrappers and no information; translate where the vocabulary genuinely changes.
- Go's %w deliberately keeps the lower-level error reachable through errors.Is and errors.As. Doesn't that defeat the translation?No — it separates the default from the option. The wrapped error presents your level in its message and type assertions fail by default, so ordinary callers never learn about the driver. A caller that genuinely needs the distinction, such as a retry layer that must recognise a transient serialization failure, opts in explicitly. The leak becomes a documented request rather than an accident.
- Where is the wrong place to translate errors, and what does over-translation look like?Translating at every layer regardless of whether the vocabulary changed: six wrappers around one timeout, each adding a sentence and losing type information, so the operator reads a stack of prose and the retry layer can no longer classify the failure. Translate where the abstraction genuinely changes — typically the boundary of a module that owns an implementation detail — and pass through unchanged elsewhere.
saying these in an interview costs you the question
- Catching a low-level failure and rethrowing it unchanged in a wrapper of your own type while discarding the cause
- Assuming a single generic application error is enough, erasing the retryable-versus-permanent distinction callers need
- Believing wrapping in an unchecked exception in Java solves the levels problem — the compiler stops complaining while the low-level type still travels
- Translating identically at every layer so one failure acquires several wrappers and no added information