Skip to content
elephantoo

Custom exceptions & try-with-resources

Lesson 22 of 43 14 min read

Design your own exceptions, chain causes, and close resources automatically with AutoCloseable.


Java's built-in exceptions describe generic problems: an illegal argument, a missing file. Your application has its own vocabulary: an InsufficientFundsException, an OrderNotFoundException, a PaymentDeclinedException. In this lesson you will create custom exceptions, chain them to preserve root causes, and use try-with-resources to close files, connections and other resources automatically.

Creating a custom exception#

A custom exception is just a class that extends an exception type:

Bank.java
public class Bank {
    public static void main(String[] args) {
        Account acc = new Account("ACC-7", 2_000);
        try {
            acc.withdraw(500);
            acc.withdraw(5_000);
        } catch (InsufficientFundsException e) {
            System.out.println(e.getMessage());
            System.out.println("Short by: " + e.getShortfall());
        }
    }
}

class InsufficientFundsException extends Exception {     // checked
    private final double shortfall;

    InsufficientFundsException(String accountId, double shortfall) {
        super("Account " + accountId + " is short by " + shortfall);
        this.shortfall = shortfall;
    }

    double getShortfall() { return shortfall; }
}

class Account {
    private final String id;
    private double balance;

    Account(String id, double balance) {
        this.id = id;
        this.balance = balance;
    }

    void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException(id, amount - balance);
        }
        balance -= amount;
        System.out.println("Withdrew " + amount + ", balance " + balance);
    }
}
Output
Withdrew 500.0, balance 1500.0
Account ACC-7 is short by 3500.0
Short by: 3500.0

Notice:

  • The constructor passes a message up with super(message), so getMessage() works.
  • Custom exceptions can carry extra data (shortfall) that callers can use to react sensibly.
  • The class name says exactly what went wrong, so callers can catch precisely that.

Checked or unchecked?#

Make it checked (extends Exception) when...Make it unchecked (extends RuntimeException) when...
the caller can reasonably recover and you want to force them to think about itit signals a bug or a broken precondition
it's an expected business outcome (PaymentDeclinedException)the caller can't do much except fail (ConfigurationMissingException)
you're in code that uses lambdas/streams, where checked exceptions are awkward

Modern Java libraries and frameworks (Spring, Hibernate) lean heavily towards unchecked exceptions. Spring, for instance, translates SQLException into its unchecked DataAccessException hierarchy. Many teams use a small hierarchy of unchecked domain exceptions:

Hierarchy.java
public class Hierarchy {
    static abstract class ShopException extends RuntimeException {
        ShopException(String message) { super(message); }
    }

    static class ProductNotFoundException extends ShopException {
        ProductNotFoundException(String sku) { super("No product with SKU " + sku); }
    }

    static class OutOfStockException extends ShopException {
        OutOfStockException(String sku, int requested, int available) {
            super("SKU " + sku + ": requested " + requested + ", only " + available + " left");
        }
    }

    static void order(String sku, int qty) {
        if (sku.equals("X-404")) throw new ProductNotFoundException(sku);
        if (qty > 3) throw new OutOfStockException(sku, qty, 3);
        System.out.println("Ordered " + qty + " x " + sku);
    }

    public static void main(String[] args) {
        String[][] orders = {{"A-1", "2"}, {"X-404", "1"}, {"B-2", "10"}};
        for (String[] o : orders) {
            try {
                order(o[0], Integer.parseInt(o[1]));
            } catch (ShopException e) {          // one handler for the whole family
                System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
            }
        }
    }
}
Output
Ordered 2 x A-1
ProductNotFoundException: No product with SKU X-404
OutOfStockException: SKU B-2: requested 10, only 3 left

A shared base class lets callers catch "any shop problem" or one specific problem, whichever suits them.

Exception chaining: keep the root cause#

When a low-level exception occurs, you often want to throw a higher-level, more meaningful one. Always pass the original as the cause:

Chaining.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Chaining {
    static class ConfigException extends RuntimeException {
        ConfigException(String message, Throwable cause) {
            super(message, cause);              // keep the cause!
        }
    }

    static String loadSetting(String file) {
        try {
            return Files.readString(Path.of(file)).strip();
        } catch (IOException e) {
            throw new ConfigException("Cannot load settings from " + file, e);
        }
    }

    public static void main(String[] args) {
        try {
            loadSetting("app-settings.txt");
        } catch (ConfigException e) {
            System.out.println(e.getMessage());
            System.out.println("Root cause: " + e.getCause().getClass().getSimpleName()
                    + " (" + e.getCause().getMessage() + ")");
        }
    }
}
Output
Cannot load settings from app-settings.txt
Root cause: NoSuchFileException (app-settings.txt)

In a printed stack trace, the cause appears as a Caused by: section. Throwing new ConfigException("failed") without the cause would throw away the most important clue.

Also standard: the convention of providing the four constructors (), (String message), (String message, Throwable cause) and (Throwable cause). IDEs can generate them.

The resource problem#

Files, network sockets, database connections and locks must be closed when you are done, or your program leaks them. The old way used finally:

Java
BufferedReader reader = null;
try {
    reader = Files.newBufferedReader(Path.of("data.txt"));
    System.out.println(reader.readLine());
} catch (IOException e) {
    System.out.println("Read failed: " + e.getMessage());
} finally {
    if (reader != null) {
        try {
            reader.close();                 // close can throw too!
        } catch (IOException e) {
            // ignored... and if both fail, the first exception is lost
        }
    }
}

Verbose, easy to get wrong, and it can hide the original exception.

try-with-resources#

Declare resources in parentheses after try. They are closed automatically at the end of the block, in reverse order, even if an exception occurs:

ReadLines.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;

public class ReadLines {
    public static void main(String[] args) throws IOException {
        Path file = Path.of("notes.txt");

        try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(file))) {
            out.println("first line");
            out.println("second line");
        }   // out.close() called here automatically

        try (BufferedReader reader = Files.newBufferedReader(file)) {
            String line;
            int n = 1;
            while ((line = reader.readLine()) != null) {
                System.out.println(n++ + ": " + line);
            }
        } catch (IOException e) {
            System.out.println("Read failed: " + e.getMessage());
        }
        Files.delete(file);
    }
}
Output
1: first line
2: second line

Any object that implements java.lang.AutoCloseable (or its subinterface java.io.Closeable) can be used: readers, writers, streams, Scanner, JDBC Connection/Statement/ResultSet, ExecutorService (Java 19+) and more.

Your own AutoCloseable resource#

Implementing AutoCloseable makes your class work with try-with-resources. Watch the order of events:

Resources.java
public class Resources {
    static class Resource implements AutoCloseable {
        private final String name;

        Resource(String name) {
            this.name = name;
            System.out.println("open " + name);
        }

        void use(boolean fail) {
            System.out.println("use " + name);
            if (fail) throw new IllegalStateException(name + " failed");
        }

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

    public static void main(String[] args) {
        try (Resource db = new Resource("db"); Resource file = new Resource("file")) {
            db.use(false);
            file.use(true);
            System.out.println("not reached");
        } catch (IllegalStateException e) {
            System.out.println("caught: " + e.getMessage());
        } finally {
            System.out.println("finally");
        }
    }
}
Output
open db
open file
use db
use file
close file
close db
caught: file failed
finally

The resources are closed before the catch and finally blocks run, and in reverse order of opening.

Suppressed exceptions#

What if the body throws and close() throws too? try-with-resources keeps the body's exception as the main one and attaches the close failure as a suppressed exception, so nothing is lost:

Suppressed.java
public class Suppressed {
    static class Flaky implements AutoCloseable {
        @Override
        public void close() {
            throw new IllegalStateException("close failed");
        }
    }

    public static void main(String[] args) {
        try (Flaky f = new Flaky()) {
            throw new RuntimeException("body failed");
        } catch (RuntimeException e) {
            System.out.println("Main: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("Suppressed: " + s.getMessage());
            }
        }
    }
}
Output
Main: body failed
Suppressed: close failed

Since Java 9: effectively final resources#

If a resource variable already exists and is (effectively) final, you can name it directly:

Java
BufferedReader reader = Files.newBufferedReader(path);
try (reader) {                 // Java 9+
    System.out.println(reader.readLine());
}

Handling exceptions at the right layer#

In a typical application:

  1. Low-level code (file, database, HTTP) throws or wraps technical exceptions with causes.
  2. Service code throws meaningful domain exceptions (OrderNotFoundException).
  3. The top layer (a main loop, a web controller, Spring's @ControllerAdvice) catches them, logs the details, and shows the user a friendly message or an HTTP status.

Common mistakes#

  • Creating a custom exception for every tiny thing. Reuse IllegalArgumentException and friends when they fit.
  • Losing the cause when wrapping (throw new MyException(e.getMessage())).
  • Catching an exception just to re-throw it unchanged.
  • Forgetting to close resources, or closing them in finally by hand when try-with-resources would do.
  • Making exception classes mutable or giving them heavy logic. They should be simple value carriers.

What's next#

Before we reach the collections framework, there is one more piece to understand: how primitives like int become objects like Integer. Next up: wrapper classes and autoboxing.

Check your understanding

Quick quiz

0/3 answered
  1. 1.To create a custom unchecked exception, which class should you extend?

  2. 2.In try (A a = new A(); B b = new B()) { ... }, in which order are the resources closed?

  3. 3.Why pass the original exception as the cause when wrapping it in a new exception?

Finished reading?

Mark this lesson complete to track your progress.