Exception Handling
Exceptions signal that something went wrong and unwind the stack until a handler runs—or the thread dies. Java splits recoverable application failures from unchecked programming errors and JVM Errors you usually do not catch. This chapter covers syntax, resource safety, and the habits that keep logs actionable in production.
Exception hierarchy
Every throwable is a Throwable. The language and libraries partition them into three mental buckets: Errors (JVM/system), checked exceptions (must handle or declare), and unchecked exceptions (RuntimeException and subclasses).
Throwable
├── Error (OutOfMemoryError, StackOverflowError)
│ → do NOT catch in application code
└── Exception
├── checked Exception (IOException, SQLException)
│ → compiler enforces handle or throws
└── RuntimeException (unchecked)
├── IllegalArgumentException, NPE, IllegalStateException
├── Spring DataAccessException (wraps SQLException)
└── custom unchecked domain exceptions
| Kind | Checked? | Typical meaning |
|---|---|---|
Error | N/A (should not catch) | JVM out of memory, linkage, thread death |
Checked Exception | Yes | Recoverable external failure—I/O, DB, parsing |
RuntimeException | No | Programming bug, violated precondition, bad state |
// Checked — caller must handle or declare
public String readConfig(Path path) throws IOException {
return Files.readString(path);
}
// Unchecked — no throws clause required
public void setAge(int age) {
if (age < 0) throw new IllegalArgumentException("age: " + age);
}
Modern frameworks (Spring, JPA) often wrap checked exceptions in unchecked ones so service code stays clean—but the root cause still appears in getCause().
Checked exceptions are a compile-time contract, not a runtime feature—the JVM does not distinguish checked vs unchecked at throw time. Debate: checked exceptions encourage boilerplate; many codebases prefer unchecked + documentation for business failures.
try-catch-finally
try guards code that may throw. catch handles matching types. finally runs on almost every exit path—historically used for cleanup before try-with-resources.
Execution order guarantees
- Normal completion — try body runs; finally runs; control continues after try-finally/catch
- Exception in try — matching catch runs; then finally; then propagate or return from catch
- Exception in catch — finally still runs; new exception may replace or suppress prior (see pitfalls)
- return in try or catch — finally still runs before the method actually returns (values may be mutated—see pitfalls)
enter try
→ exception? ──no──→ finally → exit
│
yes
→ match catch? ──yes──→ catch block → finally → exit/rethrow
└──no──→ finally → propagate exception
try {
processPayment(order);
} catch (PaymentDeclinedException e) {
log.warn("declined {}", order.id(), e);
metrics.increment("payment.declined");
} catch (RuntimeException e) {
log.error("unexpected", e);
throw e; // rethrow after handling side effects
} finally {
MDC.clear(); // always clear request context
}
Specific before general
Catch blocks are checked in source order—subclass handlers must appear before superclass handlers, or the subclass catch is unreachable.
try {
fetch();
} catch (SocketTimeoutException e) { // specific first
retry();
} catch (IOException e) { // general second
failOver();
}
try-with-resources Java 7+
Resources implementing AutoCloseable (or Closeable) are closed automatically at the end of the block—normal or exceptional exit—without a manual finally.
// Equivalent to try/finally calling close()
try (InputStream in = Files.newInputStream(path);
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
return reader.readLine();
} // close() called in reverse order: reader, then in
Suppressed exceptions
If the try block throws and close() also throws, the primary exception is thrown; the close exception is added via addSuppressed() and visible on getSuppressed().
try (BrokenResource r = new BrokenResource()) {
r.read(); // throws IOException "read failed"
} // close() throws IOException "close failed"
// thrown: read failed; suppressed: close failed
Throwable[] suppressed = primary.getSuppressed();
Implement AutoCloseable.close() without throwing when possible; swallow close errors only with logging—never hide the primary failure.
JDBC Connection, Statement, ResultSet, HTTP clients, file streams, and Spring Resource streams should use try-with-resources or framework-managed lifecycle (Spring closes JDBC per transaction).
Multi-catch Java 7+
One catch block can handle several exception types that share the same recovery logic— the caught exception is implicitly final.
try {
persist(entity);
} catch (IOException | SQLException e) {
throw new DataAccessException("persist failed", e);
}
// e is effectively final — cannot reassign
// catch (IOException | FileNotFoundException e) { } // error: FileNotFound is subclass of IOException
If one exception type is a subclass of another in the same multi-catch, the compiler rejects it—use the superclass only or separate catches.
Custom exceptions — design choice
Domain-specific types make failures searchable in logs and document API contracts. The main decision: extend Exception (checked) or RuntimeException (unchecked).
| Checked custom | Unchecked custom |
|---|---|
| Caller forced to handle | Cleaner signatures in layers that do not recover |
| Good for truly recoverable ops | Default in Spring, many REST services |
| Propagates through many layers | Use for precondition violations, not-found, conflict |
// Unchecked — typical for business rules
public class OrderNotFoundException extends RuntimeException {
private final UUID orderId;
public OrderNotFoundException(UUID orderId) {
super("Order not found: " + orderId);
this.orderId = orderId;
}
public UUID orderId() { return orderId; }
}
// Checked — rare in greenfield services; still seen in libraries
public class ExportException extends Exception {
public ExportException(String message, Throwable cause) {
super(message, cause);
}
}
Include a useful message, optional structured fields (IDs, error codes), and avoid deep exception class hierarchies unless mapping to HTTP status or error catalogs.
Exception chaining
Preserve the original failure when translating layers—JDBC SQLException → Spring DataAccessException, low-level I/O → domain exception. The cause carries the full stack trace for debugging.
try {
repository.save(user);
} catch (SQLException e) {
throw new DataAccessException("save user failed", e);
}
// Older style — prefer constructor with cause
IOException io = new IOException("disk full");
RuntimeException wrap = new RuntimeException("export failed");
wrap.initCause(io); // initCause can only be called once
// Walking the chain
Throwable t = caught;
while (t != null) {
log.info("{}: {}", t.getClass().getSimpleName(), t.getMessage());
t = t.getCause();
}
Always pass the cause to super(message, cause) when wrapping—do not only log the message string and throw a fresh exception without cause.
Best practices
Never swallow exceptions silently
Empty catch blocks hide production incidents. At minimum log at appropriate level with context (request ID, entity ID). If truly ignorable, comment why and metric the skip.
// BAD
try { cache.evict(key); } catch (Exception ignored) { }
// BETTER
try {
cache.evict(key);
} catch (Exception e) {
log.warn("cache evict failed for {}", key, e);
}
Specific before general
Catch the narrowest type that defines your recovery path; avoid catch (Exception e) at the top of business logic unless rethrowing or at a global boundary.
Don't use exceptions for flow control
Exceptions are for exceptional paths—slow (stack capture), noisy in logs. Use return values, Optional, or result types for expected outcomes (not found, validation failure).
Log or rethrow — not both (log-and-throw)
Logging at the catch site and rethrowing causes duplicate stack traces upstream. Either handle completely, or wrap and throw once—let a boundary (controller advice, main) log once.
// Anti-pattern — same error logged twice
catch (IOException e) {
log.error("failed", e);
throw new ServiceException("failed", e);
}
// Prefer — wrap and let @ControllerAdvice log once
catch (IOException e) {
throw new ServiceException("export failed for " + id, e);
}
Fail fast
Validate preconditions early (Objects.requireNonNull, guard clauses) and throw immediately—do not let invalid state propagate until a deep layer throws a confusing NPE.
Use SLF4J parameterized messages: log.error("order {} failed", orderId, e). Centralize HTTP/API error mapping in one handler; keep domain exceptions meaningful for operators.
Common pitfalls
| Pitfall | Why it hurts |
|---|---|
catch (Throwable t) | Catches Error and OutOfMemoryError—may run recovery on unrecoverable JVM state |
catch (Error e) | Almost never appropriate in app code; masks serious failures |
Throw in finally | Masks exception from try/catch—original stack lost |
return in finally | Overrides return/exception from try or catch—surprising control flow |
| Mutating return value in finally | Can change returned int/reference after try computed result |
// return in finally — returns 2, not 1
static int broken() {
try {
return 1;
} finally {
return 2; // suppresses try return
}
}
// exception in finally masks try exception
try {
throw new IOException("primary");
} finally {
throw new RuntimeException("from finally"); // this propagates; primary lost
}
Catch Exception at application boundaries (filters, advice), not Throwable. Let JVM errors terminate the thread or process so orchestrators restart unhealthy pods.
Thread.stop()-era patterns and catching Throwable in main hide OOM. Spring Boot’s default error handler maps exceptions to HTTP responses—still log the full chain once with correlation IDs.
Explain try-with-resources vs finally close(). Describe suppressed exceptions. Walk through finally execution when try has return. Contrast checked vs unchecked and when you would create each.