Skip to content

05 · Exception Handling Advanced

Level 1 covered try/catch/finally and the built-in exception types. This module covers writing your own exception classes and Java's modern resource-cleanup syntax, try-with-resources.

Custom unchecked exceptions

Extend RuntimeException for errors that represent programming mistakes or conditions callers aren't required to explicitly handle.

public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(String message) {
        super(message);   // pass the message up to Exception's constructor
    }
}

public class Account {
    private double balance;

    public Account(double balance) {
        this.balance = balance;
    }

    public void withdraw(double amount) {
        if (amount > balance) {
            throw new InsufficientFundsException(
                "Cannot withdraw " + amount + "; balance is only " + balance);
        }
        balance -= amount;
    }
}

Account acc = new Account(100);
acc.withdraw(150);   // throws InsufficientFundsException: Cannot withdraw 150.0; balance is only 100.0

Custom checked exceptions

Extend Exception directly (not RuntimeException) when callers must handle the failure — typically for recoverable conditions tied to external resources (files, networks, parsing).

public class InvalidOrderException extends Exception {
    public InvalidOrderException(String message) {
        super(message);
    }
}

public class OrderProcessor {
    public void submit(int quantity) throws InvalidOrderException {
        if (quantity <= 0) {
            throw new InvalidOrderException("Quantity must be positive: " + quantity);
        }
        System.out.println("Order submitted for " + quantity + " item(s)");
    }
}

OrderProcessor processor = new OrderProcessor();
try {
    processor.submit(-3);
} catch (InvalidOrderException e) {
    System.out.println("Order failed: " + e.getMessage());
}
// Order failed: Quantity must be positive: -3

Because submit declares throws InvalidOrderException, every caller is forced by the compiler to either catch it or re-declare it — the same rule Level 1 showed for IOException.

Exception chaining

When you catch one exception but throw another (often to translate a low-level failure into a more meaningful, domain-specific one), pass the original exception as the cause so the full failure history is preserved for debugging.

public class DataLoadException extends RuntimeException {
    public DataLoadException(String message, Throwable cause) {
        super(message, cause);   // constructor form that sets the cause directly
    }
}

public void loadConfig(String path) {
    try {
        int result = Integer.parseInt(readRawValue(path));
    } catch (NumberFormatException e) {
        throw new DataLoadException("Config value at " + path + " was not numeric", e);
    }
}

e.getCause() on the resulting DataLoadException returns the original NumberFormatException, and printing the stack trace shows both, chained with "Caused by:". initCause(Throwable) achieves the same thing after construction, for exception types whose constructor doesn't accept a cause.

try-with-resources and AutoCloseable

Any class implementing AutoCloseable (a single method, close()) can be declared inside the parentheses of a try — Java guarantees close() runs automatically when the block exits, success or failure, without a manual finally.

public class ManagedConnection implements AutoCloseable {
    private final String name;

    public ManagedConnection(String name) {
        this.name = name;
        System.out.println("Opening connection: " + name);
    }

    public void query(String sql) {
        System.out.println("Running on " + name + ": " + sql);
    }

    @Override
    public void close() {
        System.out.println("Closing connection: " + name);
    }
}

try (ManagedConnection conn = new ManagedConnection("db-primary")) {
    conn.query("SELECT * FROM users");
}
// Output:
// Opening connection: db-primary
// Running on db-primary: SELECT * FROM users
// Closing connection: db-primary

Multiple resources can be declared in the same try, separated by ; — they close in reverse order of declaration:

try (ManagedConnection a = new ManagedConnection("a");
     ManagedConnection b = new ManagedConnection("b")) {
    a.query("...");
    b.query("...");
}
// closes b first, then a

This is exactly the pattern Level 1's contact book project used with Files.newBufferedReader/newBufferedWriter, and it's the standard idiom for files, streams, and database connections covered in Module 6.

Multi-catch recap

As shown in Level 1, a single catch can handle several exception types with | when the handling logic is identical:

try {
    ManagedConnection conn = new ManagedConnection("temp");
    conn.query(null);
} catch (NullPointerException | IllegalStateException e) {
    System.out.println("Connection problem: " + e.getMessage());
}

Suppressed exceptions

If both the try block and a resource's close() throw, the exception from try is the one propagated, and the close() failure is attached to it as a suppressed exception rather than silently lost:

class FlakyResource implements AutoCloseable {
    @Override
    public void close() {
        throw new IllegalStateException("close failed");
    }
}

try (FlakyResource r = new FlakyResource()) {
    throw new RuntimeException("main failure");
} catch (RuntimeException e) {
    System.out.println("Caught: " + e.getMessage());               // main failure
    for (Throwable suppressed : e.getSuppressed()) {
        System.out.println("Suppressed: " + suppressed.getMessage()); // close failed
    }
}

How It Actually Works

Multi-catch (catch (IOException | SQLException e)) compiles to a single exception-table entry per alternative type, all pointing at the same handler bytecode — the JVM doesn't merge the types into anything new, which is exactly why the compiler forbids you from reassigning e inside a multi-catch block (its static type is the least upper bound of the alternatives, and it's implicitly final).

Try-with-resources desugars into a try/finally with an extra hidden suppressed-exception path: if the try block throws and the close() call in the compiler-generated finally also throws, the close-time exception is attached to the original via addSuppressed()/getSuppressed() rather than replacing it — a real bytecode-level accommodation (added in Java 7) so you never silently lose the original failure to a masking close() error.

Custom exception hierarchies interact with the exception table's catch_type matching, which the JVM performs via class-hierarchy instanceof-style checks at each table entry in declaration order — which is why a broader catch clause (e.g. catch (Exception e)) placed before a narrower one is a compile error: the compiler can prove the narrower handler would be unreachable dead code.

Exercise

Write a custom checked exception InvalidTemperatureException with a message constructor. Write a class Thermostat whose setTemperature(double degrees) method throws that exception if degrees is below -50 or above 150. Then write a class LoggingResource implements AutoCloseable whose constructor prints "opened" and whose close() prints "closed". In main, use a try-with-resources block that creates a LoggingResource and, inside it, calls setTemperature with an invalid value inside a try/catch that prints the exception's message — confirm "closed" still prints even though the temperature call failed.