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.
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.
| Approach | How errors travel | Languages | Strength | Weakness |
|---|---|---|---|---|
| Unchecked exceptions | Thrown and propagated implicitly | C#, Python, JavaScript, Kotlin, Java's RuntimeException | Clean happy path | Nothing in the signature says what can fail |
| Checked exceptions | Thrown; the compiler requires callers to catch or declare | Java (IOException and friends) | Failure is part of the contract | Boilerplate; often wrapped or swallowed to silence the compiler |
| Error values | Returned as values the caller must inspect | Go (value, err), Rust Result, Swift throws with explicit try | Every failure point is visible; no hidden control flow | More 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
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?