ObservabilityGENERALLANGUAGE-SPECIFICFRAMEWORK-SPECIFIC

Error Boundaries: Three Translations, Not One

A driver error becomes an application error becomes an API response — and each translation adds context while removing internals.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Where should an error be caught, and what should it look like at each layer it passes through?

The requirement

When an order fails to save, the operator needs enough detail to diagnose it, and the customer needs a sentence they can act on. Right now both get the same string, and it is a Postgres error.

The obvious build

Catch errors as high up as possible — one global error handler at the top of the stack. Everything below just throws, and the top translates. Fewer catch blocks, less noise.

Why it breaks

By the time the exception reaches the top, all the context is gone. You know a unique constraint was violated; you no longer know it was the idempotency key, not the email.

How it breaks in production
  • By the time the exception reaches the top, all the context is gone. You know a unique constraint was violated; you no longer know it was the idempotency key, not the email.
  • The global handler has to guess. It sees a driver exception and cannot tell whether the caller sent bad data or the database is degraded, so it returns 500 for both (An Error Taxonomy That Maps Cause to Response).
  • Retry logic ends up at the wrong altitude: the top-level handler cannot retry a single query, so a transient deadlock becomes a user-visible failure that a two-line retry at the repository would have absorbed.
  • The reverse failure is equally bad — catching in every function and re-wrapping, so a single failure produces a nine-level chain of "failed to X: failed to Y: failed to Z" and the actual cause is at the far end.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • There are three genuinely distinct representations of the same failure, and they exist at three different altitudes.
  • Low-level error — what the driver, socket, filesystem or parser produced. ECONNREFUSED, SQLSTATE 40001, JSON.parse throwing. It is precise about mechanism and knows nothing about meaning.
  • Application error — what it means to your domain. InventoryUnavailable, OrderAlreadyCancelled, PaymentProviderUnavailable. It knows the business meaning and carries the low-level cause attached rather than flattened into a string.
  • API error response — what the caller is told. A status, a stable code, a human-readable message, a correlation id, and nothing else (The Error Model: Structure Over Apology owns the shape).
  • Each boundary is a place where you add context and remove internals. The repository adds "which query, which entity". The service adds "which business rule". The transport removes everything that describes your infrastructure.
  • A boundary is also where the decision "handle or propagate" is made. Handling means the caller above never learns it happened — a retry that succeeded, a cache miss that fell through to origin, an optional enrichment that failed.

The same failure, three times

Follow one concrete failure down the layers. The database refuses an insert because a unique index on (tenant_id, order_ref) already has that row. At the driver, that is a code. At the service, it is a business fact. At the API, it is a sentence and a status.

Notice what each step adds and what it drops. The service knows something the driver cannot: that this constraint means a duplicate submission rather than a data-entry mistake. The transport knows something the service does not care about: that this caller is external and must not learn the index name.

Duplicate order, from driver to caller
  1. 1
    Driver

    Raises SQLSTATE 23505 with constraint name orders_tenant_ref_uniq and the offending values.

    fails by Being caught as a generic exception, discarding the SQLSTATE that carried the whole category.

  2. 2
    Repository boundary

    Maps 23505 on that specific constraint to DuplicateOrderRef, attaching the driver error as cause.

    fails by Mapping every 23505 to one error type, so a duplicate email and a duplicate order ref become indistinguishable.

  3. 3
    Service boundary

    Decides what a duplicate means here — returns the existing order for an idempotent retry, or raises conflict.

    fails by Treating it as an internal error because "the insert failed", turning a designed case into a bug report.

  4. 4
    Transport boundary

    Maps conflict to 409, emits { code, message, correlationId }, logs the full chain server-side.

    fails by Serializing err.message, which still contains the constraint name and table name (Not Leaking Your Internals).

  5. 5
    Client

    Reads code: 'duplicate_order_ref', stops retrying, shows the existing order.

    fails by Branching on the message string because no stable code was provided.

Wrapping versus translating

The distinction sounds pedantic until you try to write a client. A wrapped error preserves the original text and loses the type. A translated error assigns a type and keeps the original as an attached cause, available to logs and invisible to callers.

The practical test: can code above this boundary make a decision from the error without parsing a string? If not, the boundary translated nothing.

Two ways to handle a failed insert
Wrap and rethrow
try {
  await db.insert(order)
} catch (e) {
  throw new Error('failed to create order: ' + (e as Error).message)
}
// caller sees: "failed to create order: duplicate key value
//   violates unique constraint \"orders_tenant_ref_uniq\""
Translate, keep the cause
try {
  await db.insert(order)
} catch (e) {
  if (isUniqueViolation(e, 'orders_tenant_ref_uniq')) {
    throw new AppError('conflict', 'duplicate_order_ref',
      'An order with this reference already exists.', e)
  }
  if (isSerializationFailure(e)) throw new RetryableError(e)
  throw new AppError('internal', 'order_write_failed', 'Could not create order.', e)
}

The second form lets the layer above branch on category and code without string parsing, lets the transport decide 409 without knowing about Postgres, and keeps the constraint name in the log where it is useful and out of the response where it is disclosure.

Handle or propagate is a business decision

Every boundary answers one more question: does the caller above need to know? Absorbing a failure is legitimate and common — a retried deadlock, a cache that missed, an analytics call that failed. Absorbing it silently is what turns a degraded feature into a mystery.

The rule that survives contact with production: an error you absorb must still produce a signal. If nothing is emitted, nobody learns the enrichment service has been down for six weeks.

This layer caught an error. Now what?

Does the caller above need to change behaviour because of this?

Handle silently, emit a metric

when A retry succeeded, or an optional path failed and a degraded result is still correct.

cost Real degradation becomes invisible unless you graph the absorbed rate.

Handle, log at warn, continue degraded

when A non-essential dependency failed — recommendations, enrichment, analytics.

cost Callers get a response that is quietly less complete than it looks; the contract should say so (Partial Failure: When 3 of 5 Succeed).

Translate and propagate

when The caller must make a different decision — conflict, validation, dependency down.

cost One more error type to define and map.

Propagate untouched

when This layer adds nothing: it is a thin pass-through with no extra context.

cost None, provided some boundary above genuinely does translate it.

Catch and return a default

when Almost never for writes. Occasionally for reads with a genuine safe default.

cost Indistinguishable from success. This is the single most common way outages stay hidden.

How to build it

Most important first.

  • Put the first boundary where the low-level detail still exists: the repository or the HTTP client wrapper. This is the only place that can distinguish a deadlock from a constraint violation (The Repository Layer).
  • Translate, do not just wrap. new AppError('conflict', 'duplicate_order_ref', ..., { cause: err }) keeps the original for logs and produces a category for the mapper.
  • Preserve the cause chain — every modern runtime has a native mechanism (Error.cause in JS, raise ... from ... in Python, %w in Go). Losing it is why "we cannot reproduce it" happens.
  • Have exactly one boundary that produces the API response, and make it the outermost middleware so nothing bypasses it (The Error Boundary).
  • Decide handle-or-propagate per boundary explicitly. Swallowing an error without at least a log line is how a feature silently stops working for a month.
  • Give background jobs and consumers their own outermost boundary. They have no response to write, so their boundary decides ack, retry or dead-letter instead (Dead-Letter Queues).

What can go wrong

Failure modes
  • Catch-log-rethrow at every level, producing four log lines for one failure and an error count that is four times the truth.
  • Catching and returning null, so the caller cannot tell "no such record" from "the database was unreachable" — the two most different outcomes in the system, made identical.
  • A boundary that itself throws: an error formatter that dereferences a field the error does not have, turning a 409 into an unhandled crash inside the error handler.
  • Async errors escaping the boundary entirely — an unawaited promise or a callback that throws off-stack lands in a process-level handler that has no request context (The Middleware Pipeline).
  • Wrapping so aggressively that the category is re-derived at each level and eventually re-derived wrong.
What can race
  • A deadlock or serialization failure detected at the repository boundary is a race surfacing as an error. Retrying there is correct; retrying it three levels up, after the transaction has been abandoned, is not (Deadlocks in Application Code).
Security
  • The outermost boundary is the last place to strip internals. If it forwards err.message from a driver, every SQL fragment and hostname in that message goes to the caller (Not Leaking Your Internals).
  • Log the full cause chain server-side including the correlation id, and return only the correlation id to the caller. That preserves debuggability without disclosure.
  • A boundary must fail closed. If the mapper cannot classify an error, the answer is 500 with no detail — never "pass it through unmodified so we can see what it was".
  • Errors raised during authorization must not be downgraded by an over-eager boundary. A thrown ForbiddenError accidentally caught by a service-level catch that returns a default value is a silent authorization bypass (Where the Check Belongs).
Misreads
  • "Catch everything at the top" — that is one boundary, and it is the one with the least information. Top-level is necessary and not sufficient.
  • "Wrapping an error is translating it." Wrapping preserves; translating assigns meaning. throw new Error('failed to save order: ' + e.message) does neither well — it destroys the type and keeps the internals.
  • "The boundary should decide what to log." The boundary decides the *category*; the logging policy is separate, or every new boundary invents its own log format.
  • "If I catch it, I handled it." Catching and continuing with a default is a business decision. Make it one deliberately, or it is a silent bug.

Operating it

How you see it in production
  • Count errors at the outermost boundary only. That is the number that matches what callers experienced; inner counts are attempts, which is a different metric.
  • Log at the boundary that has the most context — usually the service layer — and mark inner logs as debug so they are available but not duplicated.
  • Emit the cause chain as structured fields (error.type, error.code, error.cause.type), not as a concatenated string, so you can group by cause (Structured Logging).
  • A rising ratio of handled-and-retried errors to surfaced errors is an early warning: the system is absorbing more failure than it used to, and the absorption has a limit.
What changes at 10x and 100x
  • At 10x, catch-log-rethrow becomes a real cost — logging is I/O, and an error path that writes four lines per failure gets slower exactly when the system is already struggling (The Log Bill and What It Is Buying).
  • At 100x, the error path can become the hot path during an incident. Building stack traces is expensive in most runtimes, so an outage that produces stack traces for every request adds CPU load to a service that is already failing.
  • Retry-at-the-boundary composes multiplicatively. A retry at the repository under a retry at the client under a retry at the SDK is 27 attempts for one user action (Retries).
What this costs
  • Three boundaries means three types of error object and the code to convert between them. On a small service that is genuinely more ceremony than value — one boundary plus a category field may be the right size.
  • Preserving cause chains keeps references alive longer, and in a high-error scenario that is retained memory. It is nearly always worth it, but it is not free.
  • A single outermost boundary is easy to reason about and easy to bypass accidentally: any code path that writes a response directly escapes it.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe three-altitude model holds for any layered service, including ones with no HTTP surface — the outermost representation is just an ack/nack rather than a status.
  • LANGUAGE-SPECIFICExceptions propagate implicitly (Java, Python, JS), so the risk is a boundary you forgot. Go returns errors explicitly, so the risk is the opposite — a boundary that discards with _ and one that wraps at every single call site. Rust's ? sits between the two, propagating by default but forcing a type conversion at each layer.
  • FRAMEWORK-SPECIFICExpress needs errors passed to next(err) or thrown synchronously to reach error middleware, so an unawaited async throw escapes it; FastAPI and Spring register exception handlers by type and catch async paths uniformly. The boundary exists in both; what silently bypasses it differs.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — how exceptions unwind, what a stack trace costs to construct, and why that cost matters on an error path under load.