Skip to content
C Codeloom
Java

Java Exception Handling Best Practices

Clear rules for using checked and unchecked exceptions in Java, with patterns for wrapping, logging, and propagating errors in real production code.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • The difference between checked, unchecked, and Error
  • When to wrap versus rethrow
  • How try-with-resources prevents leaks
  • Patterns for logging exceptions without losing context
  • Designing your own exception hierarchy

Prerequisites

  • Basic familiarity with Java syntax

What and Why

Exceptions are Java’s primary mechanism for signaling that a method could not fulfill its contract. Used well, they make failure paths explicit and let callers handle problems at the right layer. Used badly, they create noisy logs, hide bugs, and turn small failures into outages.

Good exception code is not about catching everything. It is about deciding what each layer can meaningfully do when something goes wrong.

Mental Model

Java exceptions form a hierarchy. Throwable splits into Error (JVM-level problems you should not catch) and Exception. Under Exception, RuntimeException and its subclasses are unchecked; everything else is checked and must be declared or handled.

Throwable
|-- Error                (OutOfMemoryError, StackOverflowError)
|-- Exception            (checked: IOException, SQLException)
      |-- RuntimeException  (unchecked: NullPointerException,
                               IllegalArgumentException, ...)
Throwable hierarchy

The rough rule: throw checked exceptions for recoverable conditions a caller can reasonably handle. Throw unchecked exceptions for programmer errors and broken invariants.

Hands-on Example

A common case is reading a file and parsing it.

import java.io.*;
import java.nio.file.*;
import java.util.List;

public class Config {
    public static List<String> loadLines(Path path) throws ConfigException {
        try {
            return Files.readAllLines(path);
        } catch (NoSuchFileException e) {
            throw new ConfigException("Config not found: " + path, e);
        } catch (IOException e) {
            throw new ConfigException("Failed reading: " + path, e);
        }
    }
}

class ConfigException extends Exception {
    public ConfigException(String message, Throwable cause) {
        super(message, cause);
    }
}

Notice three things. First, the low-level IOException is translated into a domain-meaningful ConfigException. Second, the original cause is preserved with the two-argument constructor. Third, the catch blocks are specific, so each failure mode could grow its own response.

Try-with-resources handles cleanup deterministically:

try (BufferedReader r = Files.newBufferedReader(path)) {
    return r.lines().toList();
}

If an exception escapes the body and close also throws, the secondary failure is attached as a suppressed exception, not silently swallowed.

Common Pitfalls

  • Swallowing exceptions: an empty catch block is almost always a bug. At minimum, log the exception with logger.error("context", e) so the stack trace survives.
  • Losing the cause: writing throw new MyException(e.getMessage()) discards the stack. Always pass e as the cause.
  • Catching Exception or Throwable: too broad. You also catch RuntimeExceptions you would rather see crash in tests. Catch the narrowest type that matches your recovery plan.
  • Using exceptions for control flow: throwing and catching is expensive and obscures intent. For predictable conditions (missing map key, end of input), return Optional or a sentinel.
  • Wrapping then logging then rethrowing: pick one. Either log at the boundary where you handle the error, or rethrow and let a higher layer log it. Logging the same exception three times pollutes operational dashboards.

Practical Tips

Design a small exception hierarchy per module. Public methods throw a single base type (often unchecked), with subclasses for cases callers might handle differently.

For input validation, prefer Objects.requireNonNull, Preconditions-style guards, or IllegalArgumentException with a clear message. Fail fast at the boundary.

Use try-with-resources for anything implementing AutoCloseable: file streams, JDBC connections, HTTP clients with explicit shutdown. The pattern is shorter and safer than finally.

When integrating with frameworks (Spring, Jakarta EE), prefer unchecked exceptions for business errors. They flow through proxies cleanly and avoid the noise of declared throws clauses everywhere.

For logging, include identifiers: order ID, request ID, file path. A stack trace is far more useful when you know which input produced it. Tools like SLF4J’s MDC make this systematic.

Wrap-up

Treat exceptions as a design decision, not a reflex. Choose checked or unchecked based on what callers can reasonably do. Wrap to preserve domain meaning, but always keep the cause chain intact. Close resources with try-with-resources. With these habits, error paths become as understandable as the happy path, and production incidents become much easier to diagnose.