Java

The Java Exception Hierarchy: Checked, Unchecked, and Error Handling by Design

Executive Summary

In the Java exception hierarchy, every failure is an object descending from Throwable. Error is for JVM-level failures you do not catch, and checked exceptions are for anticipated recoverable failures the compiler forces you to face. RuntimeException subclasses, by contrast, are for programming bugs like null pointers and illegal state. try wraps the risky region, catch handles specific types with multi-catch for siblings, and finally runs cleanup regardless of outcome. However, try-with-resources replaces most finally blocks by closing AutoCloseable resources automatically. Throw at the point where the rule breaks, with a message that names the value. Then wrap exceptions at layer boundaries to add context while preserving the cause chain. Finally, never swallow, never catch Exception casually, and never use exceptions as ordinary control flow. The next article turns these mechanics into a policy with custom exception types.

The Java Exception Hierarchy: Three Families

Every failure in Java descends from Throwable. The Java exception hierarchy then splits into three families with three different jobs, as the exceptions lesson and the JLS exceptions chapter define:

                  Throwable
                 /         \
           Error             Exception
        (JVM level)          /        \
     OutOfMemoryError   (checked)    RuntimeException
     StackOverflowError  IOException   NullPointerException
                        SQLException   IllegalStateException
                                       IllegalArgumentException
                                       NumberFormatException
                                       ClassCastException
                                       ConcurrentModificationException
Family Examples Meaning Your move
Error OutOfMemoryError, StackOverflowError The JVM itself is failing Do not catch; fix the cause
Checked exception IOException, SQLException Anticipated, potentially recoverable Handle or declare, by compiler force
Unchecked (Runtime) NullPointerException, IllegalStateException Programming bug or broken contract Fix the code; catch only at boundaries

You have lived the third family already. The calculator caught NumberFormatException because user input is hostile by nature. The library practice threw IllegalStateException because a domain rule refused an operation at the wrong moment. The collections article met ConcurrentModificationException because the iterator’s contract was violated. All unchecked, all bugs or rule refusals, all fixed by better code.

try, catch, finally: The Mechanics

try marks the risky region, catch handles a specific type, finally runs no matter what. The compiler enforces two rules: you cannot catch a type that try cannot throw (except Exception and friends). In addition, a more specific catch must precede a broader one. For a gentler first pass at try, catch, and finally, see the Java Mini Series introduction to exceptions. This article, however, goes further into the hierarchy and the design decisions.

try {
    int port = Integer.parseInt(input);      // may throw NumberFormatException
    connect(port);                            // may throw IOException (checked)
} catch (NumberFormatException e) {
    System.out.println("not a number: " + input);   // fix the input path
} catch (IOException | IllegalStateException e) {   // multi-catch: unrelated siblings
    System.out.println("cannot connect: " + e.getMessage());
} finally {
    System.out.println("attempt finished");   // always runs, even after return
}

Multi-catch (IOException | IllegalStateException) handles several types in one block without catching everything. The binding is also read-only in that block, which prevents the ambiguity bugs of a shared variable. finally runs on success, on exception, and on return, which is why it historically held cleanup, and why the next sections retire most of that use.

Swallowing: The One Crime

Wrong code first, the shape that turns incidents into archaeology:

// WRONG: the payment failed and the code pretends it did not
try {
    chargeCard(order);
} catch (Exception e) {
    // nothing, not even a log line
}
// RIGHT: wrap with context at the boundary, keep the cause chain
try {
    chargeCard(order);
} catch (PaymentGatewayException e) {
    throw new CheckoutException("charge failed for order " + order.id(), e);
}

The delta: the wrong version loses the failure completely. By contrast, the right version translates it, adds the order id, and preserves the original exception as the cause, so the eventual log shows the entire chain. Catching bare Exception is the second half of the crime. It also catches the RuntimeException bugs you wanted to see during development, and it catches Error subclasses you have no business seeing at all.

Checked vs Unchecked: The Design Split

Checked exceptions are Java’s most debated design decision, but the mechanism is simple. A method that throws a checked exception must declare it with throws, and every caller must either catch it or declare it too. In contrast, unchecked exceptions, all RuntimeException subclasses, spread silently and are meant for bugs and broken contracts. The compiler enforces the first contract and trusts you with the second.

Question Answer points to
Can the caller realistically recover, right here? Checked exception (or a result type)
Is this a programming bug or broken contract? Unchecked: IllegalStateException, IllegalArgumentException
Is the environment failing (file, network, database)? Usually checked: IOException, SQLException, and wrappers
Is this expected, ordinary input validation? Not an exception at all: return a result or Optional (Part 3)

Modern Java practice leans away from throwing new checked exceptions in application code. They viral-spread through every signature, and most callers cannot truly recover. Libraries at I/O boundaries still need them, though. There, the professional move is to wrap early, translate checked failures into one meaningful application exception at the boundary, and preserve the cause.

throw and throws: Declaring and Wrapping

throw raises one exception object, while throws declares what a method may raise. The rules that matter daily:

static int parsePort(String raw) throws BadPortException {         // checked: declared
    if (raw == null || raw.isBlank()) {
        throw new BadPortException("port is blank");                 // thrown with a message
    }
    try {
        int port = Integer.parseInt(raw);
        if (port < 1 || port > 65535) {
            throw new BadPortException("port out of range: " + port);
        }
        return port;
    } catch (NumberFormatException e) {
        throw new BadPortException("cannot parse port: " + raw, e);  // wrapped: cause preserved
    }
}

The message rule from the constructor-validation articles carries over. Name the value that broke the rule, because six months from now the message is the only thing in the log. The wrap rule is the same at every scale. Catch the low-level type, throw your domain type, and chain the original with the second constructor argument. The cause chain is what makes a stack trace readable from top to bottom.

try-with-resources: Closing Without finally

Manual close-in-finally was the Java idiom for a decade, and it is wrong more often than it is right. Wrong code first:

// WRONG: verbose, and close() can throw and mask the real failure
var out = new PrintWriter(new FileWriter(path));
try {
    out.println(data);
} finally {
    out.close();          // if this throws, the original error disappears
}
// RIGHT: the resource closes itself, in reverse order, even on exceptions
try (var out = new PrintWriter(new FileWriter(path))) {
    out.println(data);
}                          // closed here, always, suppressed exceptions preserved

try-with-resources works with anything implementing AutoCloseable and requires no finally. It also closes multiple resources in reverse declaration order and keeps suppressed exceptions from the close calls alongside the primary failure, per the try-with-resources lesson. The library practice’s CatalogStore already used it. Later, the File I/O article in this part makes it the default for every stream you open.

How Real Systems Do This

Production services handle exceptions at exactly three places, and nowhere between. At boundaries, they translate. For example, a web layer maps domain exceptions to HTTP responses, and a message consumer maps poison messages to a dead-letter path. Likewise, the persistence articles in Part 8 will translate SQLException into something your domain actually says. In middleware, they add cross-cutting context: request IDs, tenant, and timing. Finally, at the very top, they log and alarm: one place, one format, feeding the observability tools Part 6 and Part 7 introduce.

Between those three places, exceptions flow unwrapped and untouched, because every intermediate catch-and-log doubles the log line and halves the information. In my experience, the most expensive empty catch block I ever found sat inside a payment retry loop. It swallowed the gateway’s decline response, so the retry logic concluded “no news is good news”. As a result, roughly two percent of payments silently stopped retrying for six months. In fact, the fix was deleting the try-catch. The lesson is the same as this article’s: exceptions are information, and information you swallow is information you will hunt later.

The same discipline also shapes library design. For example, the JDK’s own classes throw specific types at specific layers: NumberFormatException at the parse edge, IllegalStateException from state machines, IOException from the environment. When Part 9 wraps your domain in a REST layer, the exception-to-response mapping will take an afternoon precisely because your exceptions already mean specific things. That is the payoff of the custom-exceptions article next.

Decision Framework

  1. Is the failure a bug or broken contract? If so, make it unchecked, thrown where the rule breaks and never caught mid-layer.
  2. Is it environmental and anticipated, like a missing file? If so, use checked at the low layer, wrapped into one domain exception at the first boundary.
  3. Can this exact caller recover and do something the user wants? If so, catch the specific type there. Otherwise, let it travel.
  4. Do you need cleanup on every path? try-with-resources first, finally only when the resource is not AutoCloseable.
  5. Does the message name the offending value? If not, rewrite it before the log needs it at 3 a.m.
  6. Is validation expected input? Return a result or Optional instead of throwing: Part 3’s Optional article shows the shape.

When NOT to Use This

  • Do not use exceptions as control flow. A parse you expect to fail half the time is a validation, and exceptions cost far more than a returned result, in CPU and in readability.
  • Do not catch Exception or Throwable in library code. The boundary above the library decides policy, so you only report faithfully with specific types.
  • Do not catch an exception you cannot act on. Logging “just in case” is how the same failure enters the log three times wearing three different stack traces.

Common Mistakes

  • The empty catch block. It converts a loud failure into a silent one, and silent failures surface as reconciliation mismatches months later.
  • Catching Exception broadly. It sweeps in the RuntimeException bugs you need to see and the Errors you must not touch, in one lazy line.
  • Log-and-rethrow. The top-level handler already logs, so logging again produces duplicate entries and muddied causes.
  • Closing resources in finally. close() throws checked IOException, and a close failure masks the original exception. However, try-with-resources handles suppression correctly.
  • Exceptions for expected validation. Missing-field form errors are a result, not an exception, and the Optional article formalizes the return shape.
  • Messages without values. “invalid input” in a log tells nobody anything, while “port out of range: 70000” ends the investigation in one line.

Key Takeaways

  • In the Java exception hierarchy, failures descend from Throwable: Error is the JVM’s, checked exceptions are anticipated and compiler-enforced, RuntimeException subclasses are bugs and broken contracts.
  • Throw where the rule breaks, with a message naming the offending value.
  • Catch specific types, multi-catch siblings together, and only where you can act, and otherwise let exceptions travel everywhere else.
  • Wrap at boundaries: your domain exception, full context, original cause chained.
  • try-with-resources replaces close-in-finally for every AutoCloseable: correct ordering, suppressed exceptions, no cleanup code.
  • Never swallow, never catch Exception casually, never log-and-rethrow.
  • Expected validation failures are returned results, not exceptions, and Optional arrives later in this part to formalize it.

FAQ

What is the difference between checked and unchecked exceptions in Java?

Checked exceptions extend Exception outside RuntimeException and the compiler forces callers to catch or declare them: IOException, SQLException. Unchecked exceptions extend RuntimeException and spread silently: NullPointerException, IllegalStateException. Checked suits anticipated environmental failures, while unchecked suits bugs and broken contracts.

What is try-with-resources in Java?

A try block that declares AutoCloseable resources in parentheses. Each then closes automatically in reverse order when the block ends, on success or failure, with suppressed close-time exceptions preserved. It replaces manual close-in-finally code and its masking bugs.

Should I catch Exception in Java?

Almost never below the top-level boundary. Catching Exception also catches the RuntimeException bugs you need to see during development and the Errors you cannot handle. Catch the specific type at the place that can act on it.

What does finally do in Java?

It runs after try and catch on every path: normal completion, exception, even return. It exists for cleanup with non-AutoCloseable resources. For streams and connections, however, try-with-resources does the job correctly and with less code.

When should I throw IllegalStateException instead of IllegalArgumentException?

IllegalArgumentException means the argument was bad data at birth. IllegalStateException means the data was fine but the object or system was in the wrong state for the operation: closing an already-closed loan, borrowing with no copies left. The library practice used exactly this split.

Conclusion

Exceptions are Java’s designed failure channel: typed at the throw site, specific at the catch site, and readable end to end when you wrap with context and preserve the cause. The mechanics are small, but the discipline is everything.

The next article builds on this base with custom exception types. It covers how to name your domain’s failures, how to structure the hierarchy, and the best practices that keep a growing codebase’s errors meaningful.

Throw early, wrap at boundaries, swallow never. Every incident review ends at those six words.

Last updated on 3 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *