Skip to content

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 Throwable at random layers - only the top-level boundary handler should be that broad.
  • Returning null to 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 cause chains 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#

  1. Surface errors at API boundaries. Internal helpers can throw; public APIs return typed errors.
  2. Distinguish recoverable from bug. NullPointerException ≠ "card declined".
  3. Preserve causes. Log + wrap with context, but don't lose the original.
  4. One mapper at the edge. Map domain errors to HTTP/gRPC statuses in one place.
  5. 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.

Further reading#

Curated, high-credibility sources for going deeper on this topic.