Errors are part of a program’s interface. They tell callers that an operation could not produce its promised result, but useful error handling goes further: it preserves enough meaning for the caller to decide what to do next.
Weak error handling tends to fail in two opposite ways. Some code hides failures by returning defaults, logging and continuing, or catching exceptions too broadly. Other code exposes every low-level detail directly, forcing callers to understand implementation choices that should have remained private.
A better design treats failures as information that must cross boundaries deliberately.
Start with the caller’s decision
Before defining an error type or message, ask what the caller can reasonably do when the operation fails.
Typical decisions include:
- retry later;
- ask for different input;
- use an alternative path;
- abort the current operation;
- return a safe failure to an upstream caller;
- alert an operator because automatic recovery is unlikely.
These decisions are more useful than a large catalogue of implementation-specific errors.
Suppose a service needs to load a customer profile. The underlying storage layer might encounter a timeout, a missing record, malformed stored data, or an unavailable dependency. The service does not necessarily need to expose those failures unchanged. Its callers may only need to distinguish between “customer does not exist”, “request can be retried”, and “internal failure”.
Design error boundaries around meaningful caller behaviour, not around every place where something can go wrong.
Separate expected outcomes from exceptional failures
Not every unsuccessful operation is exceptional.
A search that finds no matching item may have completed normally. A parser receiving invalid user input may be expected to reject it. A conditional update that loses a concurrency race may be an ordinary result in the application’s workflow.
Representing expected outcomes explicitly often makes code easier to reason about:
result = find_account(id)
if result is NotFound:
show_missing_account()
else if result is Found:
show_account(result.account)
else:
handle_failure(result.error)The exact representation depends on the language. It might be a result type, a status value, a documented return variant, or a specific exception. The important distinction is semantic: callers should not have to inspect arbitrary text to discover whether a failure is expected.
Reserve exceptional failure mechanisms for situations where the normal contract cannot be completed and ordinary control flow is not sufficient.
Preserve the original cause while adding context
A low-level error often lacks the context needed to diagnose the operation that failed.
Consider this message:
connection timed outIt describes a symptom but not the work being attempted. A more useful failure chain might communicate:
refresh customer summary: load billing profile: connection timed outEach layer adds information it uniquely knows. The transport layer knows that a connection timed out. The repository knows it was loading a billing profile. The application service knows that this happened while refreshing a customer summary.
When the language supports exception chaining or wrapped errors, preserve the original cause rather than replacing it. This keeps diagnostic detail available without requiring every higher layer to understand low-level implementation details.
Avoid adding context that merely repeats the same statement. A chain such as “failed to load: load failed: operation failed” creates noise rather than information.
Translate errors at architectural boundaries
Internal errors should not automatically become public errors.
A storage component might expose RecordMissing, while the application domain uses CustomerNotFound. An HTTP adapter may then translate that domain outcome into an appropriate response. Each representation belongs to a different boundary and serves a different audience.
storage error
|
v
repository translation
|
v
domain/application error
|
v
transport translation
|
v
public responseTranslation prevents callers from depending on implementation details. If a repository later moves from one persistence mechanism to another, its consumers should not need to change merely because the low-level failure vocabulary changed.
Translate only when the boundary adds semantic meaning. Mechanically wrapping every error in a new type creates ceremony without improving the contract.
Classify failures by recovery behaviour
A useful error model often answers questions such as:
- Is the failure temporary or permanent?
- Is the caller responsible for correcting it?
- Is retrying safe?
- Has any externally visible work already happened?
- Does the failure require operator attention?
For example, “temporary” does not automatically mean “retry immediately”. Repeated retries can amplify an outage. A retryable classification should be combined with an appropriate retry policy, backoff, attempt limit, and knowledge of whether repeating the operation is safe.
Likewise, a validation failure should normally identify what the caller can correct rather than masquerading as an internal system failure.
Classification should support a real decision. Do not add flags such as retryable, fatal, or temporary unless their meaning is defined consistently across the system.
Keep error messages for humans and structure for programs
Program logic should not depend on matching error message text.
This is fragile:
if error.message contains "not found":
return empty_resultA wording improvement can silently change behaviour. Instead, expose stable machine-readable structure through error types, codes, result variants, or predicates appropriate to the language.
Human-readable messages still matter. They should explain the failure clearly and include relevant operational context. But message text and machine classification serve different purposes.
A useful rule is:
Programs branch on structure; people read messages.
Keeping those responsibilities separate allows messages to improve without breaking callers.
Catch errors where you can add value
Catching a failure is useful when the current layer can do something meaningful with it. That may include:
- recovering with a valid fallback;
- translating it into the layer’s own contract;
- adding context that would otherwise be lost;
- releasing or compensating for partially completed work;
- recording information at the system boundary before returning a failure.
Catching an error merely to log it and rethrow it can produce duplicate logs at several layers. Catching every possible exception and returning a generic success value is worse because it destroys the failure signal entirely.
Let failures propagate until a layer has a reason to handle them.
This also keeps local code simpler. A function does not need a catch block simply because something inside it can fail.
Do not confuse logging with handling
Logging records an event. Handling changes what the program does about that event.
Code such as this has not necessarily handled the failure:
try operation
catch error:
log(error)
continueContinuing is correct only if the surrounding contract permits the operation to be skipped safely. Otherwise the log entry has converted a visible failure into hidden incorrect behaviour.
Choose where failures are logged so that each significant failure is normally recorded once with enough context. Lower layers can attach structured diagnostic data, while an application or process boundary decides whether the final failure should be emitted to operational logs.
Avoid logging sensitive values merely because they are available in an error context. Error diagnostics should follow the same data-handling rules as other telemetry.
Protect partial operations explicitly
Failures become harder when an operation performs several side effects.
Imagine a workflow that reserves inventory, charges a payment method, and schedules delivery. If the third step fails, returning an error does not undo the first two steps.
Error handling therefore has to account for state transitions, not just exception syntax. Depending on the system, a safe design may use:
- transactions for changes that can commit atomically;
- idempotent operations so retries do not duplicate effects;
- compensating actions for already completed steps;
- durable workflow state so recovery can resume safely;
- explicit statuses for partially completed work.
An error value can describe a failure, but it cannot by itself restore consistency.
When designing a multi-step operation, document what may already have happened when each failure is returned. That information is essential for safe recovery.
Test failure behaviour as part of the contract
Success-path tests are not enough for code with meaningful failure semantics.
Tests should verify important properties such as:
- invalid input produces the expected caller-visible outcome;
- low-level failures are translated correctly at boundaries;
- original causes remain available where diagnostics require them;
- retryable and permanent failures are not confused;
- partial work is compensated or represented accurately;
- sensitive implementation details do not leak through public interfaces.
Prefer tests based on stable semantics rather than exact message wording unless the message itself is a user-facing contract.
Failure tests are especially valuable during refactoring because they protect behaviour that is easy to overlook when only successful execution is exercised.
Keep the error vocabulary small
Large systems can accumulate hundreds of error types and codes that differ technically but lead to the same caller action. This increases cognitive load without necessarily increasing precision.
Introduce a distinct error category when at least one caller needs to treat it differently. If two failures always have the same meaning and recovery behaviour at a boundary, they may not need separate public representations there.
Conversely, do not collapse failures that require different actions into one generic error simply to keep the model small. “Invalid request” and “dependency unavailable” should not look identical to a caller that can correct the first but should retry or report the second.
The right vocabulary is the smallest one that preserves meaningful decisions.
Review errors from the outside in
When reviewing an error-handling design, trace representative failures from their origin to the outermost caller.
For each one, ask:
- Where does the failure originate?
- Which layer first understands its business or operational meaning?
- Where should it be translated?
- What context must remain available for diagnosis?
- What action can each caller take?
- Could retrying repeat a side effect?
- Where should the final failure be logged or reported?
- Does any public response expose internal or sensitive detail?
This outside-in review often reveals duplicated handling, missing context, accidental coupling, and unsafe retry assumptions more clearly than examining individual catch blocks.
Good error handling is not about preventing failures from appearing. It is about making failures explicit, preserving the information needed to understand them, and giving each boundary a clear contract for recovery or reporting. When errors are designed with the same care as successful results, systems become easier to debug, safer to change, and more predictable under stress.