Java Exceptions: Checked, Unchecked & Custom Error Handling
Learn Java exceptions: checked vs unchecked, try-with-resources, custom exception hierarchies, and chaining for reliable backend code.
Java Exceptions and Error Handling
Backend services fail in predictable ways — bad input, a downed dependency, a violated invariant. How your code models and propagates those failures determines whether an incident is a clean 400 response or a 3am page. This guide covers the language mechanics; Spring-specific handling (@ExceptionHandler, @ControllerAdvice) is covered separately in the Spring Boot essentials phase.
Checked vs Unchecked Exceptions
- Checked exceptions (
extends Exception) must be declared withthrowsor caught — the compiler enforces it. Used for recoverable conditions the caller can reasonably handle (IOException,SQLException). - Unchecked exceptions (
extends RuntimeException) are not compiler-enforced. Used for programming errors the caller shouldn't be expected to catch (NullPointerException,IllegalArgumentException,IllegalStateException). - Errors (
extends Error) signal conditions an application generally shouldn't try to catch at all (OutOfMemoryError,StackOverflowError) — they indicate the JVM itself is in trouble.
Most modern backend codebases lean unchecked-by-default for their own domain exceptions. Checked exceptions force every caller up the stack to either handle or re-declare them, which tends to leak low-level failure details (a SQLException) into layers that have no business knowing about them (a controller). Reserve checked exceptions for cases where the caller genuinely has a recovery path.
try / catch / finally
Connection conn = null;
try {
conn = dataSource.getConnection();
return runQuery(conn);
} catch (SQLException e) {
throw new DataAccessException("Query failed", e);
} finally {
if (conn != null) {
conn.close();
}
}finally always runs — on a normal return, an exception, or even a return inside the try block — which makes it the classic place for cleanup. The pattern above has a bug class of its own though: if close() itself throws, it silently replaces whatever exception was already propagating. try-with-resources exists specifically to fix this.
try-with-resources
Any class implementing AutoCloseable can be declared in the try(...) parentheses and is guaranteed to be closed, in reverse declaration order, without the manual finally boilerplate:
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
return runQuery(stmt);
} catch (SQLException e) {
throw new DataAccessException("Query failed", e);
}If both the try body and close() throw, the body's exception is the one propagated — close()'s exception is attached as a suppressed exception, retrievable via getSuppressed() instead of silently disappearing. This is the correct default for almost every resource-cleanup case: connections, streams, locks, anything Closeable.
Custom Exception Hierarchies
Design a small, domain-specific exception hierarchy instead of throwing generic RuntimeException everywhere — it lets callers catch at the right granularity and lets logging/monitoring categorize failures automatically.
public class OrderException extends RuntimeException {
public OrderException(String message) { super(message); }
public OrderException(String message, Throwable cause) { super(message, cause); }
}
public class OrderNotFoundException extends OrderException {
public OrderNotFoundException(String orderId) {
super("Order not found: " + orderId);
}
}
public class InsufficientInventoryException extends OrderException {
public InsufficientInventoryException(String sku, int requested, int available) {
super("Insufficient inventory for " + sku + ": requested " + requested + ", available " + available);
}
}A single catch (OrderException e) upstream handles the whole family, while a more specific handler can still target OrderNotFoundException alone to return a 404 instead of a generic 500.
Exception Chaining
Always pass the original exception as the cause when wrapping one exception in another (new DataAccessException("...", e), as in the examples above). This preserves the full stack trace via getCause() — losing it is one of the most common causes of "the log says the error happened here, but that's not actually where it happened" during incident debugging.
Anti-Patterns to Avoid
- Swallowing exceptions — an empty
catch (Exception e) {}block hides failures until they surface somewhere confusing, much later. At minimum, log it. - Catching
Exception(orThrowable) broadly — catches bugs you didn't anticipate (like aNullPointerExceptionfrom unrelated code) along with the one you meant to handle, masking real defects as "expected" failures. - Using exceptions for control flow — throwing to signal an expected, common outcome (e.g. "item not in cache") is slow (stack trace capture isn't free) and obscures intent. Prefer a return value (
Optional, a result type, ornullwhere the codebase already uses it consistently) for expected outcomes; reserve exceptions for genuinely exceptional ones. - Rethrowing without context —
catch (Exception e) { throw e; }adds nothing. If you're not adding context or translating to a more meaningful type, don't catch at all.
Production Observations
- Log the exception once, at the boundary where it's finally handled — not at every layer it passes through. Logging at each
catchbefore rethrowing produces duplicate, confusing log entries for a single failure. - Include enough context in the exception message to debug without a debugger attached: the ID being looked up, the operation being attempted — not just "operation failed."
- Distinguish exceptions a caller can act on (validation failures, not-found) from ones that indicate a bug or infrastructure failure (a
NullPointerException, a timeout) — the former should map to a 4xx response, the latter to a 5xx and an alert.