Java Exception Handling Patterns

Java's exception system has nuances — checked vs. unchecked, the controversial throws clause, exception chaining, the temptation to swallow exceptions in catch blocks. The right patterns are not particularly novel but they do require deliberate decisions; default behavior often produces poor outcomes.

The mental model

Exceptions are flow control for unexpected conditions. They unwind the stack until caught. They carry information (type, message, stack trace, cause).

The decisions:

Checked vs. unchecked

Checked exceptions

Subclasses of Exception (but not RuntimeException). Compiler-enforced — calling code must either handle them or declare them in throws.

The original idea: force callers to acknowledge possible failures. The reality: throws IOException everywhere becomes noise; teams routinely catch-and-ignore to satisfy the compiler.

Unchecked exceptions

Subclasses of RuntimeException. No compiler enforcement.

Modern Java codebases lean toward unchecked. The argument: checked exceptions force ceremony without producing better error handling, since callers often don't have a meaningful response.

When to use checked

When to use unchecked

For most internal code, unchecked is the right default.

The patterns that work

Wrap-and-rethrow at boundaries

When code catches a low-level exception and the caller cares about a higher-level concept:

try {
    return loadFromDatabase(id);
} catch (SQLException e) {
    throw new OrderLoadException("Failed to load order " + id, e);
}

Preserve the cause; add context. The high-level exception has a meaningful name; the low-level exception is in the chain.

Domain-specific exception types

Define specific exceptions for specific failure modes:

public class InvalidOrderException extends RuntimeException {
    public InvalidOrderException(String message) {
        super(message);
    }
}

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(String id) {
        super("Order not found: " + id);
    }
}

Specific types let callers distinguish failure modes; the alternative — checking exception messages — is fragile.

Try-with-resources for cleanup

try (var connection = pool.getConnection();
     var statement = connection.prepareStatement(sql)) {
    return statement.executeQuery();
}

Resources implementing AutoCloseable are closed automatically. Replaces try-finally for cleanup. Always use this for resource management.

Top-level handling

A single point in the application catches uncaught exceptions and translates them to user-facing errors, logs, metrics. Servlet filters, Spring's @ControllerAdvice, similar patterns. Centralizes the "what does the user see when something fails" decision.

Anti-patterns

Swallowing exceptions

// Bad
try {
    riskyOperation();
} catch (Exception e) {
    // ignore
}

Information loss. The exception happened; nobody knows. If you genuinely want to ignore an exception, log it with rationale. Empty catch blocks are bugs.

Catching Exception or Throwable

// Almost always wrong
try {
    operation();
} catch (Exception e) {
    handle(e);
}

Catches too much: RuntimeException, Error, things you didn't anticipate. Be specific about what you're catching.

The exception: top-level handlers explicitly want to catch everything. There, the broad catch is intentional.

Using exceptions for control flow

// Wrong
try {
    int value = Integer.parseInt(input);
    handleNumeric(value);
} catch (NumberFormatException e) {
    handleNonNumeric(input);
}

Exceptions are expensive (stack trace generation). Using them for routine control flow is slow and obscures intent. Pre-check or return optional.

Re-throwing without context

catch (SQLException e) {
    throw new RuntimeException(e);
}

Loses information. The new exception has no message describing what was being attempted. Wrap with context.

Logging and rethrowing

catch (IOException e) {
    log.error("Failed", e);
    throw e;
}

Logged twice — once here, once at the top-level handler. Pick one: log here and translate to a different exception, or rethrow without logging.

Result types as alternative

For internal APIs, sealed types as result types can be cleaner than exceptions:

public sealed interface Result<T, E> {
    record Ok<T, E>(T value) implements Result<T, E> {}
    record Err<T, E>(E error) implements Result<T, E> {}
}

public Result<Order, OrderError> load(String id) {
    if (!repository.exists(id)) {
        return new Err<>(new NotFound(id));
    }
    return new Ok<>(repository.find(id));
}

Callers handle both cases explicitly; no exception flow control. See JavaRecordsAndSealedClasses.

This pattern works well at internal boundaries. At external boundaries (servlets, controllers), exceptions integrate better with framework error handling.

Logging exceptions correctly

log.error("Failed to process order {}", orderId, e);

The exception parameter (last) is the standard SLF4J pattern. The full stack trace is logged.

Anti-patterns:

Common failure patterns

Further Reading