Saltar a contenido

06 - Exceptions done right

What this session is

About ninety minutes. From Scratch taught you try/catch/finally - the mechanics. This session is about the strategy: checked vs unchecked (Java's most-debated design decision), designing exception types, try-with-resources properly, exception chaining, and the anti-patterns that turn error handling into a liability. By the end you'll handle errors like someone who's maintained a large codebase, not someone who wraps everything in try { } catch (Exception e) { }.

The exception hierarchy

Every error in Java is a Throwable. The tree matters because it drives the rules:

Throwable
  ├─ Error                  - JVM-level catastrophes. Don't catch these.
  │    └─ OutOfMemoryError, StackOverflowError, ...
  └─ Exception              - things programs can reasonably handle
       ├─ RuntimeException  - UNCHECKED. Programming errors, usually.
       │    └─ NullPointerException, IllegalArgumentException,
       │       IllegalStateException, IndexOutOfBoundsException, ...
       └─ (everything else) - CHECKED. The compiler forces you to handle these.
            └─ IOException, SQLException, ...

Two splits to internalize:

  1. Error vs Exception. Error means the JVM is in trouble (out of memory, stack overflow). You generally cannot meaningfully recover, so you don't catch them. Exception is for conditions a program can handle.

  2. Checked vs unchecked. This is the big one. RuntimeException and its subclasses are unchecked - the compiler doesn't force you to handle them. Everything else under Exception is checked - the compiler forces you to either catch it or declare throws it. This single distinction shapes how all Java error handling reads.

Checked vs unchecked: the strategy

// Checked: the compiler MAKES you deal with IOException.
void readFile(String path) throws IOException {   // declare it...
    Files.readString(Path.of(path));
}
// or catch it:
void readFileSafe(String path) {
    try {
        Files.readString(Path.of(path));
    } catch (IOException e) {
        // handle
    }
}

// Unchecked: no compiler obligation. You CAN catch it, but aren't forced to.
void parse(String s) {
    int n = Integer.parseInt(s);   // throws NumberFormatException (unchecked) - no try needed
}

The design intent:

  • Checked exceptions = "recoverable conditions the caller should consciously handle." A file might not exist; a network call might fail. The compiler forces the caller to acknowledge the possibility.
  • Unchecked exceptions = "programming errors that shouldn't happen if the code is correct." A null where one shouldn't be, an index out of range, an illegal argument. Forcing callers to catch these everywhere would drown the code.

In practice, the Java community has drifted toward using unchecked exceptions for most things. Checked exceptions sound good but have real costs: they leak through every layer (a low-level IOException forces throws clauses all the way up), they don't compose with lambdas/streams (a lambda can't throw a checked exception that the functional interface doesn't declare), and they tempt developers into the worst anti-pattern (catch-and-ignore) just to make the compiler stop complaining.

The pragmatic guidance most modern Java follows:

Use unchecked exceptions (extend RuntimeException) by default. Use checked exceptions only when the caller can realistically recover and you want to force them to think about it.

Many influential libraries (Spring, and most modern frameworks) use unchecked exceptions almost exclusively, often wrapping checked ones. You'll still handle checked exceptions constantly (the JDK is full of them - IOException, SQLException), but when designing your own exceptions, default to unchecked.

Choosing and throwing the right exception

The JDK provides standard unchecked exceptions - use them instead of inventing your own for common cases:

void setAge(int age) {
    if (age < 0)
        throw new IllegalArgumentException("age must be non-negative, got " + age);
    this.age = age;
}

void withdraw(double amount) {
    if (closed)
        throw new IllegalStateException("account is closed");   // wrong object state
    if (amount > balance)
        throw new IllegalArgumentException("insufficient funds");
}

String get(int index) {
    Objects.checkIndex(index, size);   // throws IndexOutOfBoundsException with a good message
    return data[index];
}

void process(Order order) {
    Objects.requireNonNull(order, "order must not be null");   // throws NPE with a message
    // ...
}

The standard ones and when to throw them:

  • IllegalArgumentException - a method argument is invalid (negative age, empty string where one's required).
  • IllegalStateException - the object is in the wrong state for this call (using a closed resource, calling next() past the end).
  • NullPointerException - a required value was null. Use Objects.requireNonNull(x, "msg") to throw it early with a message rather than letting a confusing NPE surface deep in the call.
  • IndexOutOfBoundsException - an index is out of range. Objects.checkIndex does this with a good message.
  • UnsupportedOperationException - an operation isn't supported (the chapter 01 sign of inheritance abuse, but also legitimately used by immutable collections).

Always include a useful message. throw new IllegalArgumentException() tells the next debugger nothing. throw new IllegalArgumentException("age must be non-negative, got " + age) tells them exactly what went wrong and what the bad value was. The message is the gift you give your future self at 2 AM.

Designing custom exceptions

When the standard exceptions don't fit - when callers need to distinguish your error type to handle it specifically - design your own. Keep them unchecked unless you have a recovery reason.

// A focused, unchecked domain exception.
public class PaymentException extends RuntimeException {
    private final String orderId;

    public PaymentException(String orderId, String message, Throwable cause) {
        super(message, cause);     // pass message AND cause to super (chaining - see below)
        this.orderId = orderId;
    }

    public String orderId() { return orderId; }
}

For a family of related errors, use a small hierarchy so callers can catch broadly or narrowly:

public class PaymentException extends RuntimeException { /* ... */ }
public class CardDeclinedException extends PaymentException { /* ... */ }
public class InsufficientFundsException extends PaymentException { /* ... */ }

// Caller chooses granularity:
try {
    processPayment(order);
} catch (CardDeclinedException e) {
    promptForNewCard();                  // handle this specific case
} catch (PaymentException e) {
    abortCheckout(e.orderId());          // handle the whole family
}

Catch blocks are checked top to bottom, so order subclasses before superclasses - if PaymentException came first, CardDeclinedException would be unreachable (compile error, helpfully).

Design principles for custom exceptions:

  • Carry structured data the handler needs (the orderId above), not just a string. This is the chapter 03 lesson - an exception is an object; put useful fields on it.
  • Keep the hierarchy shallow and meaningful. Two or three levels max.
  • Always provide a constructor that accepts a cause (Throwable) so chaining works.

Try-with-resources, properly

Anything that holds an external resource (a file, a socket, a database connection) must be closed, even if an exception is thrown mid-use. The old way - finally blocks - was verbose and error-prone:

// The old, painful way - don't write this anymore.
BufferedReader r = null;
try {
    r = new BufferedReader(new FileReader("data.txt"));
    return r.readLine();
} finally {
    if (r != null) r.close();   // and close() itself can throw, masking the real error...
}

Try-with-resources handles all of it. Any object implementing AutoCloseable declared in the try (...) header is automatically closed when the block exits - normally or via exception, in reverse order of declaration:

try (BufferedReader r = new BufferedReader(new FileReader("data.txt"))) {
    return r.readLine();
}   // r.close() called automatically, even if readLine() throws

Multiple resources, closed in reverse:

try (var in = new FileInputStream("src");
     var out = new FileOutputStream("dst")) {
    in.transferTo(out);
}   // out closed first, then in - reverse of declaration order

This also fixes a subtle bug the old way had: if both the body and close() throw, try-with-resources keeps the body's exception as primary and attaches the close exception as a suppressed exception (retrievable via e.getSuppressed()), instead of the close exception silently replacing the real one. Always use try-with-resources for anything closeable. Making your own resource closeable is just implements AutoCloseable with a close() method.

Exception chaining: never lose the cause

When you catch a low-level exception and throw a higher-level one, always pass the original as the cause. This preserves the full stack trace - the "caused by:" chain you've seen in logs.

try {
    return database.query(sql);
} catch (SQLException e) {
    // GOOD: wrap, preserving the original as the cause
    throw new DataAccessException("failed to load user " + id, e);
}

The second argument (e) becomes the cause. The resulting stack trace shows your DataAccessException and "Caused by: SQLException ..." underneath - you keep the high-level context and the low-level detail. Drop the cause and you throw away the actual reason it failed:

} catch (SQLException e) {
    // BAD: the original cause is lost forever. Debugging nightmare.
    throw new DataAccessException("failed to load user " + id);
}

This is the Java equivalent of error wrapping in other languages. The cause chain is your single most valuable debugging tool when production breaks. Never break it.

The anti-patterns (and their fixes)

These are the error-handling crimes that show up in code review. Recognize and avoid all of them.

1. Swallowing exceptions.

try {
    riskyOperation();
} catch (Exception e) {
    // nothing here. The error vanishes. The program limps on with bad state.
}
The worst one. The error happened, you hid it, and now something downstream fails mysteriously with no clue why. Never have an empty catch block. At minimum, log it. Usually, handle it or rethrow.

2. Catching Exception (or Throwable) too broadly.

try {
    doStuff();
} catch (Exception e) {   // catches EVERYTHING, including bugs you didn't anticipate
    showError("something went wrong");
}
Catching Exception scoops up NullPointerException, IllegalStateException - programming bugs you'd rather see crash loudly in development. Catch the specific exceptions you can actually handle. Catching Throwable is even worse (it catches Error - out-of-memory, etc.).

3. Using exceptions for control flow.

// BAD: using an exception as a loop terminator
try {
    int i = 0;
    while (true) System.out.println(array[i++]);
} catch (ArrayIndexOutOfBoundsException e) { /* done */ }
Exceptions are for exceptional conditions, not normal flow. They're also expensive (building a stack trace costs real time). Use a normal loop condition.

4. Logging and rethrowing (double-logging).

} catch (IOException e) {
    log.error("read failed", e);   // logged here...
    throw new RuntimeException(e); // ...and will be logged AGAIN by whoever catches this
}
Pick one: either handle it here (log and recover), or wrap-and-rethrow (let the caller log). Doing both produces the same error in the logs three times, making incidents harder to read. The rule: log where you handle, not where you rethrow.

5. Throwing from finally.

try { ... }
finally {
    cleanup();   // if cleanup() throws, it MASKS any exception from the try block
}
An exception thrown in finally replaces any exception in flight from the try - the original error vanishes. Keep finally blocks (or prefer try-with-resources) free of code that can throw.

A clean end-to-end example

Everything together - a service method that does I/O, wraps appropriately, chains causes, and uses try-with-resources:

public class UserRepository {

    // Custom unchecked domain exception with structured data + cause support.
    public static class UserLoadException extends RuntimeException {
        private final long userId;
        public UserLoadException(long userId, Throwable cause) {
            super("failed to load user " + userId, cause);   // message + chained cause
            this.userId = userId;
        }
        public long userId() { return userId; }
    }

    public User load(long userId) {
        Objects.requireNonNull(connection, "connection not initialized");  // fail early, clear

        String sql = "SELECT * FROM users WHERE id = ?";
        try (PreparedStatement stmt = connection.prepareStatement(sql)) {  // auto-closed
            stmt.setLong(1, userId);
            try (ResultSet rs = stmt.executeQuery()) {                     // auto-closed
                if (!rs.next())
                    throw new IllegalArgumentException("no user with id " + userId);
                return mapRow(rs);
            }
        } catch (SQLException e) {
            // Wrap the low-level checked exception in our unchecked domain one,
            // preserving the cause. Callers handle UserLoadException, not SQLException.
            throw new UserLoadException(userId, e);
        }
    }
}

The caller never sees SQLException - it sees a clean UserLoadException carrying the userId and the original cause underneath. Resources close automatically. Bad input fails fast with a clear message. This is the shape of production error handling.

Try it

  1. Wrap and chain. Write a method that reads an int from a file (Files.readString + Integer.parseInt). Catch IOException and NumberFormatException, wrap each in a custom unchecked ConfigException with the cause. Trigger both paths and print e.getCause() - confirm the original is preserved. Then "forget" the cause and see how much worse the stack trace is.

  2. Resource closing order. Make two classes implementing AutoCloseable whose close() prints their name. Use both in one try-with-resources. Confirm they close in reverse declaration order. Then throw inside the body and confirm they still close.

  3. Suppressed exceptions. Make an AutoCloseable whose close() throws. Use it in a try-with-resources whose body also throws. Catch the body's exception and print e.getSuppressed() - see the close exception preserved, not lost.

  4. Build a hierarchy. Design OrderException with subclasses OutOfStockException and PaymentFailedException, each carrying relevant data. Write a caller with multiple catch blocks (subclasses first). Deliberately put the superclass catch first and read the compile error.

  5. Spot the anti-patterns. Take this and fix every crime: try { x(); } catch (Throwable t) { log.error("err", t); throw new RuntimeException(t); }. (Too broad - catches Error; double-logs; loses specificity.) Rewrite it correctly.

  6. requireNonNull early. Write a method that uses a parameter three statements in. Pass null. Note the confusing NPE deep in the method. Add Objects.requireNonNull(param, "param required") at the top. Pass null again. Compare the two stack traces - the second points at the real problem.

What you might wonder

"So are checked exceptions just bad?" They're controversial, not bad. They genuinely help when the caller can recover and should be forced to consider failure (file I/O, network). They hurt when they leak through layers and don't compose with streams/lambdas. The modern lean is "unchecked by default, checked when recovery is realistic and you want to force the conversation." Know how to handle both (the JDK forces you to), but design your own as unchecked unless you have a reason.

"How do I throw a checked exception from inside a lambda/stream?" You can't directly - functional interfaces like Function don't declare throws. Options: catch inside the lambda and wrap in an unchecked exception, or use a library helper. This composition problem is a big reason the community drifted toward unchecked exceptions.

"Should I ever catch Exception?" At the top of an application - a request handler, a thread's run loop, a main - yes: a last-resort catch-all that logs and returns a clean error to the user, so one bad request doesn't crash the server. Deep in business logic, no - catch specific types. The rule: broad catches belong only at boundaries.

"What about Optional instead of exceptions?" For "value might be absent" (a lookup that finds nothing), Optional<T> is often cleaner than throwing (chapter 07). For "operation failed in a way the caller must handle," exceptions are right. Don't throw an exception for an empty search result; do throw for "the database connection died."

"Performance - are exceptions slow?" Throwing is moderately expensive, mostly from capturing the stack trace. That's another reason not to use them for control flow. For genuine errors (which are rare by definition), the cost is irrelevant. If you must throw very frequently in a hot path (you usually shouldn't), you can override fillInStackTrace to skip the trace - but that's a rare optimization, covered more in chapter 12.

Done

  • You know the Throwable hierarchy and the Error/Exception, checked/unchecked splits.
  • You know the strategy: unchecked by default, checked when recovery is realistic.
  • You can throw the right standard exception with a useful message, and fail fast with requireNonNull.
  • You can design custom exception hierarchies that carry structured data and support chaining.
  • You use try-with-resources for anything closeable, and understand suppressed exceptions.
  • You always chain causes, and you can spot and fix the five anti-patterns.

Next: functional Java - lambdas, method references, streams used well, and Optional.

Next: Functional Java →

Comments