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.
Exceptions are flow control for unexpected conditions. They unwind the stack until caught. They carry information (type, message, stack trace, cause).
The decisions:
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.
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.
For most internal code, unchecked is the right default.
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.
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 (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.
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.
// 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.
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.
// 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.
catch (SQLException e) {
throw new RuntimeException(e);
}
Loses information. The new exception has no message describing what was being attempted. Wrap with context.
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.
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.
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:
log.error(e.getMessage()) — loses the stack tracelog.error("Failed: " + e) — loses the stack trace and the message-vs-trace distinctionException or Throwable. Almost always wrong outside top-level handlers.