A function can hide how data is stored and still leak the storage technology through every error it returns. When that happens, callers become coupled to details the abstraction was supposed to contain.
Suppose an order service asks a repository to load an order. The repository promises to find orders; it does not promise that callers understand SQL drivers, HTTP clients, file formats, or whichever mechanism happens to sit underneath it. If a missing order appears as a driver-specific NoRows exception today and an HTTP 404 exception after a migration, callers must change even though the repository’s meaning did not.
Error translation solves this problem at an abstraction boundary. The boundary converts implementation-specific failures into errors expressed in the vocabulary its callers understand. This article shows how to decide what to translate, what not to translate, and how to preserve the original failure for diagnosis.
Treat errors as part of the abstraction
An abstraction is more than a successful return value. It also defines the failures a caller is expected to handle.
Consider this small interface:
OrderRepository.find(orderId) -> OrderA caller naturally needs to distinguish at least two outcomes:
order = repository.find(orderId)
if order was not found:
show "order does not exist"
else:
continue processing orderThe caller needs the concept order not found. It usually does not need to know whether that outcome came from an empty database result, a missing document, or a 404 from another service.
That gives a useful mental model:
Translate a failure when its low-level representation is an implementation detail but its meaning belongs to the boundary’s contract.
The translation should remove accidental coupling, not remove information.
Translate meaning, not every exception
Imagine the repository currently uses a database adapter:
function find(orderId):
try:
row = database.queryOne(orderId)
return mapOrder(row)
catch DatabaseNoRow:
throw OrderNotFound(orderId)This is a simplified teaching example. The important point is the mapping, not the exception syntax.
DatabaseNoRow describes what the database adapter observed. OrderNotFound describes what the repository operation means to its caller. If the implementation later reads from a remote service, the repository can map that service’s equivalent missing-result signal to the same OrderNotFound error. Callers remain stable because the repository contract remains stable.
Do not extend this into a rule that every low-level exception must become a custom error. Some failures have no useful higher-level meaning yet. If a database connection disappears, inventing OrderRepositoryProblem may add a new name without helping any caller make a decision.
Translate when the boundary can add stable meaning. Otherwise, propagate or wrap the failure according to the language and application’s error-handling conventions.
Preserve the original cause
Translation becomes harmful when it destroys diagnostic evidence.
This version loses useful context:
catch DatabaseError:
throw RepositoryError("could not load order")A timeout, authentication failure, malformed query, and unavailable server now look identical. The message says where the operation failed, but not why.
When the language supports exception chaining, error wrapping, causes, or equivalent structured metadata, keep the original failure attached:
catch DatabaseTimeout as cause:
throw RepositoryUnavailable("could not load order", cause)The caller can reason about RepositoryUnavailable while logs and debugging tools can still expose the underlying timeout. The exact mechanism is language-specific; the engineering principle is not: change the error vocabulary without discarding the evidence that explains the failure.
Be careful with user-facing responses. Preserving an internal cause for diagnostics does not mean returning database messages, stack traces, file paths, or other implementation details to an external client. Diagnostic context and public error representation serve different audiences.
Map only failures you understand
Broad catch-and-translate blocks are a common mistake:
try:
return database.queryOne(orderId)
catch any error:
throw OrderNotFound(orderId)This code claims that every failure means the order is absent. A network timeout would therefore become a false OrderNotFound. The caller might tell a user that an existing order does not exist, or take irreversible action based on incorrect information.
Catch the narrow condition whose meaning you can justify:
try:
return database.queryOne(orderId)
catch DatabaseNoRow as cause:
throw OrderNotFound(orderId, cause)Other failures continue through the normal failure path.
This distinction matters because error translation is a semantic operation. You are not merely renaming an exception. You are asserting that one observed failure has a particular meaning at a higher level. If that assertion is wrong, the abstraction lies to its callers.
Put translation where knowledge meets
A good translation point knows both sides of the mapping:
- what the lower-level component can report; and
- what the higher-level contract promises callers.
That point is often an adapter, repository, gateway, or other boundary component.
Suppose a payment workflow uses a provider adapter:
PaymentGateway.charge(request) -> PaymentResultThe provider SDK might report CardDeclinedByIssuer. The application’s gateway contract might expose PaymentDeclined. The adapter is a natural translation point because it understands the provider response and the application’s payment vocabulary.
By contrast, translating CardDeclinedByIssuer in a distant UI controller forces the controller to understand provider-specific details. Translating it deep inside a generic HTTP client is also too early because that client does not know that the request represents a payment.
Place the mapping at the narrowest boundary that has enough context to state the higher-level meaning correctly.
Keep the error vocabulary small and actionable
A boundary can also become difficult to use if it exposes dozens of error types that callers cannot meaningfully distinguish.
Ask what a caller can do differently for each error. For an order lookup, a useful contract might distinguish:
OrderNotFound -> caller may stop or return a missing-resource response
RepositoryUnavailable -> caller may retry later or report temporary failureSplitting RepositoryUnavailable into separate public errors for socket reset, DNS failure, connection-pool exhaustion, and database failover may simply leak infrastructure details again. Those distinctions can remain in the preserved cause and operational telemetry unless callers genuinely need different behavior.
The right granularity depends on the contract. A diagnostic tool may need fine-grained failures. A business workflow may need only a few stable categories. Design errors around decisions callers must make, not around every failure the implementation can produce.
Do not translate away programming defects
Not every exception represents an expected operational outcome.
A null dereference, failed internal assertion, impossible state, or similar programming defect should not automatically become a normal domain error such as OrderNotFound. Doing so can make a defect look like an expected business condition and allow execution to continue under false assumptions.
The exact distinction between recoverable errors and programming defects varies by language and system design. The practical rule is to avoid broad translation that converts unknown failures into expected outcomes. Translate conditions whose semantics are known; let unexpected failures remain visible through the system’s normal fault-handling path.
Avoid translating the same error repeatedly
Layered systems can create another failure mode: every layer catches an error, changes its name, and adds little meaning.
DatabaseTimeout
-> RepositoryFailure
-> ServiceFailure
-> ApplicationFailureIf all three higher-level errors mean only “something failed,” the extra layers increase code and obscure the useful cause.
A translation earns its place when it creates a more stable or more meaningful contract. If the next layer would expose the same semantics, it can often let the existing error pass through. Boundaries do not need unique error classes merely because they are boundaries.
This also keeps tests focused. Tests can verify meaningful mappings such as “missing storage record becomes OrderNotFound” instead of asserting a long chain of mechanically renamed failures.
Test the mapping at the boundary
Translation logic is small, but mistakes can change application behavior. Boundary tests should cover the decisions the mapping creates.
For the repository example, useful cases are:
stored order -> returns Order
missing row -> OrderNotFound
storage timeout -> not OrderNotFoundThe third case is especially important. It proves that the translation is narrow enough and prevents a broad catch from turning infrastructure failures into false absence.
If preserving the cause is part of your diagnostic convention, test that as well where the language makes it practical. Avoid tests that depend on irrelevant driver message text; those details are exactly what the boundary is intended to isolate.
Know when direct propagation is simpler
Error translation has a maintenance cost. It introduces types, mappings, tests, and documentation. That cost is justified when it protects a real abstraction or gives callers a stable decision to make.
Direct propagation can be simpler when a component is only a thin internal wrapper, the lower-level API is already part of the intended contract, or no caller benefits from a different error vocabulary. A one-function script that directly uses a file API usually does not need a hierarchy of application-specific file errors.
The question is not whether custom errors look architecturally clean. Ask whether callers should depend on the lower-level failure. If that dependency is acceptable and intentional, translation may add no value.
Conclusion
Errors are part of an interface. A boundary that hides implementation details on the success path but leaks them on the failure path is only partially abstracted.
Translate errors when a low-level failure has a stable higher-level meaning that callers need. Make the mapping narrow, preserve the original cause for diagnosis, and expose only distinctions that support real caller decisions. Do not turn unknown failures or programming defects into expected outcomes, and do not add translation layers that merely rename the same problem.
Used this way, error translation keeps callers coupled to the meaning of an operation rather than to the mechanism that currently implements it.