Home
Java from First Principles / Chapter 10 — Exception Handling

Exception Handling

Checked vs unchecked. try/catch/finally. try-with-resources. The dogmatic rules everyone learns and the pragmatic ones senior developers actually follow.


When normal flow breaks

A program that never fails is a program that does nothing. The moment your code touches a file, a database, a network connection, or user input, things can go wrong. Files don't exist. Connections drop. Inputs are malformed. Disks fill up. You need a way to handle these "abnormal" situations without abandoning the rest of your program.

Java's mechanism is exceptions. When something goes wrong, an exception is **thrown**. Control jumps out of the current method, propagates up the call stack, and lands in whatever code can handle it — or terminates the program if nothing does.

The system is straightforward in concept. The pain comes from Java's particular twist: checked exceptions, a feature no major language designed since has copied. Whether they're a brilliant safety mechanism or a 25-year-old mistake is one of the longest-running debates in Java. This chapter covers both sides, plus the syntax, the rules, and the patterns that actually hold up in real code.

Java exception hierarchy: Throwable, Error, checked and unchecked Exception Exception hierarchy Throwable java.lang Error JVM problems — don't catch Exception checked RuntimeException unchecked OutOfMemoryError StackOverflowError IOException SQLException ClassNotFoundException NullPointerException IllegalArgumentException IndexOutOfBoundsException JVM-level problems Must catch or declare in throws clause Programming bugs — no catch required
Three branches under Throwable. Error means the JVM itself is in trouble — never catch these. Checked exceptions must be handled or declared. Unchecked (RuntimeException subclasses) flow through silently — they signal programming bugs and don't force a try/catch.

The hierarchy: Throwable, Error, Exception

Every exception in Java extends java.lang.Throwable. Beneath it, three branches:

**Error** — something has gone wrong with the JVM itself, not your code. OutOfMemoryError, StackOverflowError, LinkageError. You almost never catch these. There's nothing useful you can do — the JVM is broken or out of resources. Let it crash, log, restart.

**Exception** — recoverable problems your code might handle. IOException, SQLException, ParseException. These are **checked**: the compiler forces you to either catch them or declare them in your method's throws clause.

**RuntimeException** (a subclass of Exception, despite the name implying otherwise) — programming errors. NullPointerException, IllegalArgumentException, IndexOutOfBoundsException. These are **unchecked**: the compiler doesn't force you to catch or declare them. They propagate freely until something catches them or the JVM terminates the thread.

The split between checked and unchecked is the defining feature of Java's exception system. The intent: checked exceptions represent expected failure modes that callers should plan for (the file might not exist; the network might fail). Unchecked exceptions represent bugs (you forgot to check for null; you passed an invalid argument). The caller has the responsibility for the first kind; the caller is responsible for *not causing* the second.


try / catch / finally

The basic syntax:

Java
try {
    // code that might throw
    String content = Files.readString(Path.of("config.json"));
    parseConfig(content);
} catch (IOException e) {
    // handle file-related failure
    log.error("Failed to read config", e);
    useDefaults();
} catch (JsonParseException e) {
    // handle malformed JSON
    log.error("Config is not valid JSON", e);
    useDefaults();
} finally {
    // always runs — successful or not
    log.info("Config load attempt complete");
}

The try block contains code that might throw. Each catch handles a specific exception type. finally runs no matter what — even if there's no exception, even if you return from inside try, even if a catch block re-throws.

**Multi-catch** (Java 7+) lets you handle several types the same way:

Java
try {
    doSomething();
} catch (IOException | SQLException e) {
    log.error("Recoverable I/O failure", e);
}

The e variable is typed as the common supertype — in this case Exception. You can only call methods that exist on the common type.

**Order matters.** Java picks the FIRST matching catch block. Always list more specific exceptions before their parents:

Java
try {
    doSomething();
} catch (FileNotFoundException e) {
    // specific case — list FIRST
} catch (IOException e) {
    // catches any OTHER IOException
}

Reverse the order and the compiler errors: FileNotFoundException is unreachable because IOException catches it first.


try-with-resources — the modern way to handle files and streams

Before Java 7, properly closing resources required this pattern:

Java
BufferedReader reader = null;
try {
    reader = new BufferedReader(new FileReader("data.txt"));
    return reader.readLine();
} catch (IOException e) {
    throw new RuntimeException(e);
} finally {
    if (reader != null) {
        try {
            reader.close();
        } catch (IOException e) {
            // swallow — what else can you do?
        }
    }
}

That's 14 lines to read one line from a file. The closing logic is harder to follow than the actual work. The error-handling of close() itself is ugly.

Java 7 added **try-with-resources**:

Java
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
    return reader.readLine();
} catch (IOException e) {
    throw new RuntimeException(e);
}

The resource declared in try (...) is automatically closed when the block exits — successfully or via exception. You can declare multiple resources, separated by semicolons. They close in reverse order. Works with any class implementing AutoCloseable (which is essentially all I/O classes in modern Java).

**Use try-with-resources for every file, stream, socket, connection, or anything else that needs closing.** Old-style finally-close code is a code smell in 2026 — it suggests the developer learned Java before 2011.


throw and throws

**throw** (verb) — fires an exception:

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

**throws** (clause on method signature) — declares that this method MIGHT throw a particular checked exception, leaving handling to the caller:

Java
public String loadConfig(String path) throws IOException {
    return Files.readString(Path.of(path));
}

When the caller invokes loadConfig(...), they MUST either catch IOException or declare it themselves. The compiler enforces it.

You don't need throws for unchecked exceptions. You CAN write throws RuntimeException for documentation, but the compiler doesn't enforce it and most teams don't bother.

**Picking an exception to throw.** The standard library has many useful types — use the right one, don't reflexively use plain Exception or RuntimeException:

| Situation | Type to throw |
|---|---|
| Argument has an invalid value | IllegalArgumentException |
| Argument is null (when null isn't allowed) | NullPointerException (yes — it's the right one here) |
| Method called when object is in wrong state | IllegalStateException |
| Something genuinely unsupported | UnsupportedOperationException |
| Specific I/O failure | IOException (or a subtype) |
| Custom domain failure | A custom exception you define |

Avoid throwing RuntimeException or Exception directly — they tell callers nothing about what went wrong.


The checked exceptions debate

Here's the honest assessment.

**The argument FOR checked exceptions:** they force the caller to acknowledge failure modes. If a method can fail with IOException, every caller is reminded by the compiler. No silently-ignored errors.

**The argument AGAINST:** in practice, most callers either catch-and-log (defeating the purpose), wrap in RuntimeException and rethrow (creating ugly nesting), or declare the throws and pass the buck up indefinitely. Almost every other modern language (C#, Kotlin, Scala, Python, JavaScript, Go, Rust) avoided checked exceptions on purpose after watching Java's experience.

**What experienced Java developers actually do:**

- **For new code:** prefer unchecked exceptions. Define a custom RuntimeException subclass for domain errors. Let it propagate naturally; catch only where you can actually do something useful.

- **For libraries:** use checked exceptions sparingly, and only when the caller genuinely SHOULD handle the failure. IOException is reasonable. BillingProcessorInternalErrorException is not — make it unchecked.

- **For interfacing with checked-heavy code:** wrap and rethrow once at the boundary:

Java
public String getConfig(String key) {
    try {
        return Files.readString(Path.of("/etc/myapp/" + key));
    } catch (IOException e) {
        throw new ConfigLoadException("Failed to load " + key, e);
    }
}

This converts the checked exception into an unchecked one with domain context. Callers don't have to litter every method signature with throws IOException.

The Spring framework famously made this stance explicit: it converts JDBC's checked SQLException into an unchecked hierarchy (DataAccessException). Modern Java code increasingly follows the same pattern.


Defining custom exceptions

When the standard exceptions don't capture your domain, define your own. Keep them simple — they're data, not behaviour.

Java
public class PaymentDeclinedException extends RuntimeException {
    private final String declineCode;

    public PaymentDeclinedException(String declineCode, String message) {
        super(message);
        this.declineCode = declineCode;
    }

    public PaymentDeclinedException(String declineCode, String message, Throwable cause) {
        super(message, cause);
        this.declineCode = declineCode;
    }

    public String getDeclineCode() { return declineCode; }
}

Three things worth noting:

**1. Pick a base class deliberately.** RuntimeException for unchecked (most modern code). Exception for checked (when you really want the caller to handle it). Never extend Throwable or Error directly.

**2. Provide a constructor that takes a cause.** When you wrap a lower-level exception, pass it as the cause so the full stack trace survives. Without this, the original error context is lost.

**3. Keep them small.** An exception is a signal. Don't bloat it with logic. Constructors set fields; getters return them; that's all.

**Wrapping example:**

Java
try {
    bankApi.charge(amount);
} catch (HttpClientErrorException e) {
    throw new PaymentDeclinedException("network_error", "Bank API call failed", e);
}

The caller sees PaymentDeclinedException with declineCode = "network_error". The underlying HttpClientErrorException is preserved as the cause — logged, traceable, debuggable.


Common mistakes

The recurring patterns that cause production bugs.

**1. Catching Exception (or Throwable) too broadly.**

Java
try {
    doSomething();
} catch (Exception e) {
    log.error("oops", e);
    // continue as if nothing happened
}

This swallows EVERY exception, including ones you didn't expect — including bugs you'd want to know about. Always catch the narrowest type that makes sense.

**2. Empty catch blocks.**

Java
try {
    parseInt(input);
} catch (NumberFormatException e) {
    // ignore — assume it's not a number
}

If you genuinely mean to ignore an exception, add a comment explaining WHY. Otherwise this looks like a forgotten TODO and confuses every future reader.

**3. Catching and re-throwing without preserving the cause.**

Java
try {
    bankApi.charge(amount);
} catch (Exception e) {
    throw new PaymentException("charge failed");   // loses original stack trace!
}

Always pass e as the cause: throw new PaymentException("charge failed", e);

**4. Using exceptions for control flow.**

Java
try {
    int n = Integer.parseInt(input);
    handleNumber(n);
} catch (NumberFormatException e) {
    handleNonNumber(input);   // using exception as an if-else
}

Exceptions are expensive — building the stack trace takes time. For predictable cases, check first with a regex or a try-parse helper.

**5. Catching NullPointerException instead of preventing it.**

Java
try {
    return user.getAddress().getStreet();
} catch (NullPointerException e) {
    return "Unknown";
}

NPE means a bug. Catching it hides the bug. Use null checks or Optional instead.

**6. Throwing exceptions from finally.**

Java
try {
    riskyOp();
} finally {
    cleanup();   // if this throws, the original exception is LOST
}

If both riskyOp() and cleanup() throw, only the cleanup exception propagates. Use try-with-resources or guard cleanup with its own try-catch.

**7. Forgetting to chain causes when wrapping.** Same as #3. The cause IS the most important part of an exception — the most-asked debugging question is "what was the root cause?"


What to actually do in production code

A practical playbook.

**Throw early.** Validate inputs at the start of methods. Throw IllegalArgumentException with a helpful message immediately, before you've done other work that needs to be unwound.

**Catch late.** Don't catch unless you can actually do something useful — retry, log with context, return a default, convert to a different exception. Catching just to log and continue is rarely the right answer.

**Use try-with-resources for everything closeable.** No exceptions to this rule in 2026.

**Include context in exception messages.** "Failed to parse" is useless. "Failed to parse user.email = 'not_an_email' at line 47" is debuggable.

**Log the full stack trace, not just the message.** log.error("failed", e) not log.error("failed: " + e.getMessage()). The trace is where the answer lives.

**At the application boundary, catch and translate.** A web controller's outermost layer should catch all unhandled exceptions, log them with request context, and return a sensible HTTP response. Don't let raw exceptions leak to users.

**Domain exceptions for domain errors.** Don't use RuntimeException("payment failed"). Use PaymentDeclinedException("card_expired", "Card expired on 2024-12-31"). Future code can catch the specific type.

**Document exceptions in javadoc** for any method that throws meaningfully. Especially for libraries:

Java
/**
 * Charges the given amount to this card.
 * @throws PaymentDeclinedException if the bank declines the charge
 * @throws NetworkException if the bank API is unreachable
 */
public void charge(BigDecimal amount) { ... }

This is the contract the caller depends on. If you change what you throw, you've changed the contract.


⁂ Back to all modules