Error Handling — Detailed#
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 |