Back to the map

Module 22 · OOP in practice · Lesson 1 of 4

Exceptions in practice

When to throw, where to catch and what never to do. Checked versus unchecked exceptions, exceptions versus error values, catching at boundaries, wrapping with context, cleanup on every path, and the swallowed exception that hides an outage.

CoreMust-knowMid 12 min Updated Nov 2026

Why it matters

How a codebase handles errors decides how its outages look. A swallowed exception turns a clear failure into silently wrong data discovered days later. An exception caught too low loses the context needed to handle it. An exception that escapes without cleanup leaks connections until the pool is empty. Error handling is the least glamorous part of object-oriented design and the part that most often determines whether a failure is a log line or an incident.

The idea

An exception interrupts normal flow and travels up the call stack until some caller catches it. That makes the happy path readable and forces failures to be dealt with somewhere, at the cost of invisible exit points in every call. Languages split into two families on how errors are represented.

ApproachHow errors travelLanguagesStrengthWeakness
Unchecked exceptionsThrown and propagated implicitlyC#, Python, JavaScript, Kotlin, Java's RuntimeExceptionClean happy pathNothing in the signature says what can fail
Checked exceptionsThrown; the compiler requires callers to catch or declareJava (IOException and friends)Failure is part of the contractBoilerplate; often wrapped or swallowed to silence the compiler
Error valuesReturned as values the caller must inspectGo (value, err), Rust Result, Swift throws with explicit tryEvery failure point is visible; no hidden control flowMore verbose call sites

When to throw

Throw when a method cannot do what its name promises: a violated precondition (withdraw(-5)), a broken invariant, a failed external dependency. Do not throw for expected outcomes that callers handle routinely; "user not found" in a lookup is often better as an empty optional or a result type. Never use exceptions for ordinary control flow such as loop termination; they are slow to construct (they capture a stack trace) and hide the logic.

Where to catch

HTTP handlerboundary: map to 4xx/5xx, log onceOrderServicetranslate or add contextPaymentClientthrows with causePaymentFailed(cause: timeout)CheckoutFailed(order 42, cause)502 to client, one log line with full chainretry transient errors here if safenever catch-and-ignore
Low layers throw with context; middle layers add context or translate; the boundary decides what the caller sees and logs once.

Catch an exception only where you can do something useful with it: retry a transient failure, fall back, translate it into your layer's vocabulary, or report it to the user. Everywhere else, let it propagate. Log at the boundary, once, with the full chain; logging and rethrowing at every layer produces the same stack trace five times.

Wrap with context, keep the cause

When translating an exception, include what you were doing and chain the original as the cause, so the root failure is not lost.

try {
    gateway.charge(order.paymentToken(), order.total());
} catch (GatewayTimeoutException e) {
    throw new CheckoutFailedException("charging order " + order.id(), e); // cause preserved
}

Clean up on every path

Code after a throw does not run, so resources acquired before it must be released in finally or, better, a scoped construct (try-with-resources, using, with, defer). Objects should also stay valid if a method throws halfway: validate before mutating, or build the new state aside and swap it in, so a failure leaves the old state intact. That property is called exception safety.

The anti-patterns

  • Swallowing: catch (Exception e) {} or a catch that only logs and continues as if nothing happened. The failure disappears and the program carries on with bad state.
  • Catching too broadly: catching every exception type, including programming errors like null dereferences, and treating them as expected failures.
  • Losing the cause: throwing a new exception without chaining the original.
  • Exceptions as return values: throwing for routine outcomes callers must always handle.

Trade-offs

Exceptions keep the happy path clean and make failures impossible to ignore silently by default, at the cost of hidden control flow and the temptation to catch broadly. Error values make every failure point explicit and composable, at the cost of verbosity. Checked exceptions document failure in the type system and, in practice, are often wrapped to escape it. Whatever the mechanism, the discipline is the same: fail fast, add context, handle at the boundary, clean up always.

In an interview

Describe a policy, not a syntax: "Domain code throws typed exceptions for violated rules; infrastructure errors are wrapped with context and the original cause; transient errors from the payment client are retried with backoff in one place; the API layer maps exceptions to status codes and logs once. Resources are always released with try-with-resources."

Check yourself

A nightly import wraps each record in try { save(record) } catch (Exception e) { }. For a week, half the records silently fail a new database constraint. What is wrong with the handling, and what should it do?

Exceptions in practice · Backplane