Error Handling#
flowchart LR
Op[Operation]
OK([Success])
ERR([Failure])
RES([Result / Either])
Op --> OK
Op --> ERR
OK --> RES
ERR --> RES
RES --> Caller
classDef p fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef s fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef ok fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef err fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
class Op s;
class Caller p;
class OK,RES ok;
class ERR err;
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class Op,OK,ERR,RES service;
Failures are inevitable. Make them explicit in the type system (Result / Either / Option) or explicit in the API (typed exceptions). Hidden failures = production fires.
Three styles, three trade-offs#
| Style | Examples | Pros | Cons |
|---|---|---|---|
| Return codes | C errno, POSIX |
simple, no exceptions infra | callers forget to check |
| Exceptions | Java, Python, C++, C# | propagate without boilerplate | hidden control flow; checked-vs-unchecked debates |
| Result / Either types | Rust Result, Haskell Either, Kotlin Result, Go (err, val) |
explicit in signature; safe to compose | verbose without sugar |
Choosing within a project#
flowchart TB
Q[Is the failure recoverable<br/>by the caller?]
Q -->|yes, often| RES[Use Result / Either / Option<br/>so caller MUST handle it]
Q -->|no, programmer error| RTE[Throw an unchecked exception<br/>bug, fail fast]
Q -->|no, environmental| CHK[Throw a checked exception or<br/>a Result with rich error info]
RES --> OK[Caller pattern-matches]
CHK --> OK
classDef p fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef s fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
class Q p;
class RES,RTE,CHK,OK s;
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class Q,RES,RTE,CHK,OK service;
Patterns#
Result type (Rust-ish)#
enum Result<T, E> { Ok(T), Err(E) }
fn parse_id(s: &str) -> Result<u64, ParseError> { ... }
match parse_id("42") {
Ok(id) => use_id(id),
Err(e) => log(e),
}
Exception with cause chain#
try {
saveOrder(order);
} catch (SQLException root) {
throw new OrderPersistenceException("could not save order " + order.id(), root);
}
Always keep the root cause - never log-and-swallow.
Domain errors as types#
sealed class TransferError {
object InsufficientFunds : TransferError()
object FrozenAccount : TransferError()
data class RailUnavailable(val rail: String) : TransferError()
}
The compiler tells you when a new error case isn't handled.
Anti-patterns#
- Empty catch - silently swallowing an exception.
catch Throwableat random layers - only the top-level boundary handler should be that broad.- Returning
nullto indicate failure - caller forgets; NullPointerException later. - Stringly-typed errors -
if (err == "no_funds")is a refactor nightmare; use enums / sealed classes. - Translating exceptions without context - keep
causechains intact.
Error boundaries in this site#
| Boundary | Where |
|---|---|
| Public HTTP/gRPC handler | top of every service - map domain errors to status codes + JSON Problem+Details |
| Saga step | catch & emit a compensating event |
| Background job | catch, mark job failed with reason, schedule retry |
| Idempotency layer | persist error state so retries return the same result |
| Webhook delivery | mark attempt failed, send to DLQ after N tries |
Glossary & fundamentals#
Concepts referenced in this design. Each row links to its canonical page; the tag column shows whether it is a high-level (HLD) or low-level (LLD) concept.
| Tag | Concept | What it is | Page |
|---|---|---|---|
HLD |
Pub/Sub & message brokers | topics, consumer groups, delivery semantics | pub-sub-pattern |
HLD |
Distributed transactions | 2PC, TCC, sagas, outbox/inbox | distributed-transactions |
HLD |
Idempotency & retries | safe re-execution, backoff + jitter | idempotency-retries |
LLD |
Error handling | exceptions vs Result, error boundaries | error-handling |
Quick reference#
Rules of thumb#
- Surface errors at API boundaries. Internal helpers can throw; public APIs return typed errors.
- Distinguish recoverable from bug.
NullPointerException≠ "card declined". - Preserve causes. Log + wrap with context, but don't lose the original.
- One mapper at the edge. Map domain errors to HTTP/gRPC statuses in one place.
- No silent retries. Every retry decision is logged and observable.
Naming#
NotFoundException,ValidationException,ConflictException- domain layer.OrderRepositoryException- infra layer.IllegalStateException,IllegalArgumentException- bug indicators.
Test what you ignore#
For every error path, write at least one negative test. The hidden bugs hide there.
Refs#
- Effective Java - Items 70-77 (exceptions).
- Rust book - "Error handling" chapter.
- Joe Duffy: "The Error Model" (Midori retrospective).
- Joel Spolsky: "Exceptions are bad" - read with an open mind.
FAQ#
When should I use Result types instead of exceptions?#
Use Result or Either when failures are expected business outcomes, like validation errors or not-found cases. Reserve exceptions for truly exceptional bugs and infrastructure faults.
Are exceptions slower than Result types?#
Throwing an exception is far slower than returning a value because the runtime must capture a stack trace. Hot paths that fail often benefit from Result types.
What is the difference between checked and unchecked exceptions?#
Checked exceptions force the caller to handle or declare them at compile time. Unchecked exceptions can propagate freely and are typically used for programming errors.
How do I handle errors across service boundaries?#
Translate internal errors into a stable contract like RFC 7807 Problem Details with a code, message, and trace ID. Never leak stack traces to external clients.
Should every function return a Result type?#
No. Use Result where failure is part of the function's contract. Pure helpers and impossible-to-fail code stay plain to keep call sites readable.
Related Topics#
- Async Models: error propagation across async boundaries and structured concurrency
- Concurrency Primitives: thread-safe error signaling with shared state
- Resilience Patterns: circuit breakers, retries, and bulkheads that act on error signals
Further reading#
Curated, high-credibility sources for going deeper on this topic.