Skip to content

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 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 errorsif (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