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.

beginner mid

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).

KindChecked?Typical meaning
ErrorN/A (should not catch)JVM out of memory, linkage, thread death
Checked ExceptionYesRecoverable external failure—I/O, DB, parsing
RuntimeExceptionNoProgramming bug, violated precondition, bad state
Java
// 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().

🎯 Interview Tip

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

  1. Normal completion — try body runs; finally runs; control continues after try-finally/catch
  2. Exception in try — matching catch runs; then finally; then propagate or return from catch
  3. Exception in catch — finally still runs; new exception may replace or suppress prior (see pitfalls)
  4. return in try or catch — finally still runs before the method actually returns (values may be mutated—see pitfalls)
Java
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.

Java
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.

Java
// 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().

Java
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.

📦 Real World

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.

Java
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 customUnchecked custom
Caller forced to handleCleaner signatures in layers that do not recover
Good for truly recoverable opsDefault in Spring, many REST services
Propagates through many layersUse for precondition violations, not-found, conflict
Java
// 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.

Java
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.

Java
// 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.

Java
// 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.

💡 Pro Tip

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

PitfallWhy 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 finallyMasks exception from try/catch—original stack lost
return in finallyOverrides return/exception from try or catch—surprising control flow
Mutating return value in finallyCan change returned int/reference after try computed result
Java
// 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.

⚠️ Pitfall

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.

🎯 Interview Tip

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.