11.2 Exception Handling in Practice
This is the hands-on follow-up to the exception mechanism. By completing the Java 17 examples in about 50 minutes, you'll learn how to order your catch blocks, use try-with-resources to manage files, and design domain exceptions that preserve the root cause and structured context.
When config.txt Is Missing
When the program starts, config.txt is gone. The old code catches all errors with a large catch (Exception) and prints "Using default configuration." This strategy works when the file is missing; it also quietly falls back to defaults when the file exists but permissions are insufficient, the content is corrupted, or a null pointer occurs in the code. Real failures are hidden behind this fallback.
The same old code also tries to read map.txt, which contains north,150,200; customers might run out of balance when placing an order, or expired tasks might need to report ExpiredTaskException. These failures occur at different boundaries and are handled differently: recoverable errors are fixed in place, while unrecoverable ones preserve the cause and propagate it, with resources properly closed via try-with-resources.
1. catch going from specific to general
A try block can throw several exception types. The JVM selects the first compatible catch in source order. This method returns 8080 only when the configuration file is missing; other read and format failures propagate. This example permits ports 1 through 65535. Import the relevant types from java.io, java.nio.file, and java.nio.charset:
static int loadPort(Path path) {
try {
String input = Files.readString(path, StandardCharsets.UTF_8).strip();
int port = Integer.parseInt(input);
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("port must be in 1..65535");
}
return port;
} catch (NoSuchFileException exception) {
return 8080;
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("port is not an integer", exception);
} catch (IOException exception) {
throw new UncheckedIOException("cannot read port configuration", exception);
}
}NoSuchFileException is a subclass of IOException, so subclass branches come first. Placing the parent class first would make subclass branches permanently unreachable, and the compiler would reject it outright.
catch (Exception) is not a syntax error. It's suitable for a few clear top-level boundaries, such as a task executor marking a task as failed and then terminating it. Underneath, readConfig would blend parsing errors, program bugs, and I/O failures into a single statement.
1.1 multi-catch
When handling actions identically, you can merge unrelated exception types:
catch (NumberFormatException | DateTimeParseException exception) {
reportInvalidConfig(exception);
}Alternate types cannot have parent-child relationships. NoSuchFileException | IOException compilation fails because IOException already includes the former. Multi-catch parameters are also considered final and cannot be reassigned within a catch block.
1.2 What to do after catching it
A reasonable catch typically accomplishes one thing:
- Recovery: Use explicit default values when optional configurations are missing.
- Conversion: Convert IOException into a form understandable by the caller
MapLoadException, and preserve the cause. - End current boundary: Request entry records a failure and returns an error response.
Printing just one line and then continuing often neither recovers nor propagates; production logs still need structured context, levels, and a consistent logging location; printStackTrace() is suitable only for temporary troubleshooting and not a complete logging strategy.
2. Reading map.txt: Who is responsible for closing resources?
The old helper simply returns new BufferedReader(...).readLine(). It does not close the reader, so the resource may remain open indefinitely. Garbage collection is not a timely file-closing protocol.
Modern Path/Files APIs clearly specify the path and encoding:
static String readMap(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(
path, StandardCharsets.UTF_8)) {
return reader.readLine();
}
}Resources in the parentheses must implement AutoCloseable. When control leaves the try, Java closes each successfully acquired non-null resource, on both normal return and exceptional exit. If a later resource initializer fails, earlier resources still close; a resource that was never acquired does not enter the close list.
2.1 Closing multiple resources in reverse order
try (BufferedReader reader = Files.newBufferedReader(input, UTF_8);
BufferedWriter writer = Files.newBufferedWriter(output, UTF_8)) {
String line = reader.readLine();
if (line != null) writer.write(line);
}This snippet copies only the first line; empty input produces empty output. Input and output must refer to different files, or opening the writer may truncate the input. Resources close in reverse declaration order: writer first, then reader. This aligns with the ownership direction of nested constructions. Resources declared later often depend on those declared earlier, so they should be released first.
Java 9+ also allows placing already declared, final, or effectively final resources in the try parentheses:
BufferedReader reader = Files.newBufferedReader(path, UTF_8);
try (reader) {
System.out.println(reader.readLine());
}After the try block ends, the object is closed and should not be read again.
2.2 Both operation and cleanup fail
Suppose reading throws IOException("Read failed"), and both close operations also fail. Java preserves the reading exception as the primary exception and captures the close failures in getSuppressed(). This ensures the caller doesn't lose the failure that triggered stack unwinding.
Manually writing a finally block is easy to get wrong: a new exception from close can override the original reading exception. The try-with-resources pattern already defines a clear hierarchy, resources should be managed using it rather than hand-coded finally blocks.
3. Domain Exceptions Carry Structured Failure Information
Suppose an order costs 50 cents and the account has only 30 cents. The fixed small amounts use integer minor units to demonstrate exception contracts. Neither IOException nor IllegalStateException conveys that business context. A custom exception can preserve the requested and available amounts as fields:
final class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
private final int required;
private final int available;
InsufficientFundsException(int required, int available) {
super("Required " + required + " cents, but the account has " + available);
this.required = required;
this.available = available;
}
int required() { return required; }
int available() { return available; }
}The caller can display the message and also use the fields to determine how much more is needed. Never let business logic parse numbers from exception messages, those strings are subject to translation and formatting changes.
The older version also includes ExpiredTaskException: "Task not found or expired." If the caller violates a precondition (such as requiring a task to be in a "claimable" state) then a RuntimeException can express a call error. If the business logic allows tasks to expire during confirmation, returning a clear failure result or a checked exception may be more helpful for the caller in presenting recovery options. Always start by defining what the caller needs to do, then choose the inheritance hierarchy accordingly.
3.1 Preserve Cause During Transformation
The purchase module doesn't want the UI to directly depend on InsufficientFundsException. Instead, it can transform it into PurchaseException:
try {
placeOrder(cost, balance);
} catch (InsufficientFundsException exception) {
throw new PurchaseException("Purchase failed", exception);
}The constructor must invoke super(message, cause). If only the message is passed, monitoring and stack traces will only show "purchase failed", they won’t capture the specific "insufficient balance" object.
There’s no need to create a separate exception class for every method. The class name should express the boundary the caller will use to handle the failure and carry stable, safe context. Ending the class name with Exception is a common convention in the Java community, it’s not a compiler requirement.
4. Full Hands-On: Map, Purchase, and Resource Cleanup
Start by preparing a temporary directory; the program will also create and clean up its own I/O temporary directories internally:
chapter11_practice_root=$(mktemp -d)
readonly chapter11_practice_root
mkdir -p "$chapter11_practice_root/out"
cd "$chapter11_practice_root"Save as ExceptionPracticeDemo.java:
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class ExceptionPracticeDemo {
public static void main(String[] args) throws Exception {
Path directory = Files.createTempDirectory("ch11-practice-");
Path mapFile = directory.resolve("map.txt");
try (TemporaryFiles temporary = new TemporaryFiles(mapFile, directory)) {
Files.writeString(
temporary.file(), "North,150,200\n", StandardCharsets.UTF_8);
String mapData;
try (BufferedReader reader = Files.newBufferedReader(
mapFile, StandardCharsets.UTF_8)) {
mapData = reader.readLine();
}
checkEquals("North,150,200", mapData);
System.out.println("Region data: " + mapData);
PurchaseException purchaseFailure = expect(
PurchaseException.class,
() -> completePurchase(50, 30));
check(purchaseFailure.getCause() instanceof InsufficientFundsException,
"The purchase exception must preserve the insufficient-balance cause");
InsufficientFundsException balanceFailure =
(InsufficientFundsException) purchaseFailure.getCause();
checkEquals(50, balanceFailure.required());
checkEquals(30, balanceFailure.available());
System.out.println("Purchase failed: " + balanceFailure.getMessage());
List<String> closeOrder = new ArrayList<>();
IOException primaryFailure;
try (CloseProbe first = new CloseProbe("first", closeOrder);
CloseProbe second = new CloseProbe("second", closeOrder)) {
check(first != second, "The close probes must be distinct objects");
throw new IOException("Read failed");
} catch (IOException exception) {
primaryFailure = exception;
}
checkEquals(List.of("second", "first"), closeOrder);
checkEquals("Read failed", primaryFailure.getMessage());
checkEquals(2, primaryFailure.getSuppressed().length);
checkEquals("close second failed",
primaryFailure.getSuppressed()[0].getMessage());
checkEquals("close first failed",
primaryFailure.getSuppressed()[1].getMessage());
System.out.println("Close order: " + closeOrder);
System.out.println("Primary exception: " + primaryFailure.getMessage()
+ ", suppressed=" + primaryFailure.getSuppressed().length);
}
check(!Files.exists(mapFile) && !Files.exists(directory),
"Temporary files must be cleaned up");
System.out.println("Exception handling exercise passed");
}
static void completePurchase(int cost, int balance) {
try {
placeOrder(cost, balance);
} catch (InsufficientFundsException exception) {
throw new PurchaseException("Purchase failed", exception);
}
}
static void placeOrder(int cost, int balance)
throws InsufficientFundsException {
if (cost <= 0) {
throw new IllegalArgumentException("cost must be positive");
}
if (balance < 0) {
throw new IllegalArgumentException("negative balance");
}
if (balance < cost) {
throw new InsufficientFundsException(cost, balance);
}
}
static final class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
private final int required;
private final int available;
InsufficientFundsException(int required, int available) {
super("Required " + required + " cents, but the account has " + available);
this.required = required;
this.available = available;
}
int required() {
return required;
}
int available() {
return available;
}
}
static final class PurchaseException extends RuntimeException {
private static final long serialVersionUID = 1L;
PurchaseException(String message, Throwable cause) {
super(message, cause);
}
}
static final class TemporaryFiles implements AutoCloseable {
private final Path file;
private final Path directory;
TemporaryFiles(Path file, Path directory) {
this.file = file;
this.directory = directory;
}
Path file() { return file; }
@Override
public void close() throws IOException {
IOException failure = null;
for (Path path : List.of(file, directory)) {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
if (failure == null) failure = exception;
else failure.addSuppressed(exception);
}
}
if (failure != null) throw failure;
}
}
static final class CloseProbe implements AutoCloseable {
private final String name;
private final List<String> closeOrder;
CloseProbe(String name, List<String> closeOrder) {
this.name = name;
this.closeOrder = closeOrder;
}
@Override
public void close() throws IOException {
closeOrder.add(name);
throw new IOException("close " + name + " failed");
}
}
static <T extends Throwable> T expect(Class<T> type, Runnable action) {
try {
action.run();
} catch (Throwable throwable) {
if (type.isInstance(throwable)) return type.cast(throwable);
throw new AssertionError(
"expected=" + type + ", actual=" + throwable, throwable);
}
throw new AssertionError("expected exception=" + type.getName());
}
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static void checkEquals(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) {
throw new AssertionError("expected=" + expected + ", actual=" + actual);
}
}
}Compile and run:
javac --release 17 -encoding UTF-8 -Xlint:all -d out ExceptionPracticeDemo.java
java -cp out ExceptionPracticeDemoExpected output:
Region data: North,150,200
Purchase failed: Required 50 cents, but the account has 30
Close order: [second, first]
Primary exception: Read failed, suppressed=2
Exception handling exercise passedAfter saving the expected output, clean up the outer compile directory:
cd
ls -ld -- "$chapter11_practice_root"
rm -r -- "$chapter11_practice_root"The example uses a temporary directory to isolate I/O, avoiding printing machine paths in the output. TemporaryFiles also participates in try-with-resources. It attempts both file and directory deletion and retains cleanup failures. If the body has already failed, its close failure becomes suppressed instead of replacing the primary failure. After closure, assert that the paths no longer exist. main throws Exception is used only for top-level self-validation in this test program. In real business entry points, failure should be reported according to the startup strategy.
4.1 Step-by-Step Verification
- UTF-8 is specified in the read and write calls, so
North,150,200does not depend on the platform's default encoding. - The
BufferedReaderis closed when the try-with-resources block ends. InsufficientFundsExceptionstores numeric fields, andPurchaseExceptionsaves it as the cause.CloseProbecloses in the order second, then first; the main exception in the body remains the primary exception, and both close failures are recorded in the suppressed list.- TemporaryFiles handles fixture cleanup while preserving close failures. Reliable business writes still require persistence protocols; closing resources is not a transaction commit.
5. Anti-patterns of finally
5.1 Returning in finally
static int hiddenResult() {
try {
return 1;
} finally {
return 2;
}
}The caller only receives 2. The return value from the try block is overwritten; if an exception is thrown in the try block, it is also swallowed by the return in finally. Never write return, break, or continue in finally.
5.2 Throwing a new exception in finally
try {
readMap();
} finally {
closeMap(); // If this fails, might overwrite readMap failure
}Use try-with-resources for resource management. If cleanup operations might fail, design the relationship between the primary exception and the cleanup exception, do not simply suppress or override the exception.
5.3 Dependent on finally for business commit
Finally may not execute if the process terminates, and even if it does execute, it cannot undo already issued remote calls. Operations such as balance deduction, inventory transfer, and file replacement require transactional guarantees, idempotency, compensation mechanisms, or atomic file moves.
6. Common catch anti-patterns
Empty catch
try {
loadConfig();
} catch (IOException ignored) {
}Ignoring exceptions only makes sense when the failure is truly benign and the reason is clearly documented, either in variable names, comments, or API contracts. For example, Files.deleteIfExists explicitly designs "not found" as a return value, so an empty catch is not needed for an absent file.
Log then re-throw unchanged
catch (IOException exception) {
logger.error("Read failed", exception);
throw exception;
}If the upper layer also logs, this results in duplicated logs. Instead, choose a boundary where you record the request or task result. When intermediate layers add context, wrap the exception and preserve the cause.
Using Exception to control ordinary loops
try {
while (true) consume(iterator.next());
} catch (NoSuchElementException end) {
// Use exceptions to indicate normal termination
}Loop conditions should use hasNext(). Exception stacks are more expensive than regular branches and don’t align with loop semantics.
Suppressing InterruptedException
try {
queue.take();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new TaskCancelledException("The task was interrupted", exception);
}When possible, declare and propagate throws InterruptedException directly. If propagation isn’t feasible, restore the interrupt flag and exit the current operation or transition to a boundary exception with a cause. An empty catch prevents thread pools from properly terminating tasks according to their interruption protocol.
7. Exception Messages and Log Boundaries
Useful exception messages identify the operation, object, and context that is safe to expose.
Failed to read map configuration: file=map.txt, tenant=westNever log access tokens, passwords, full connection strings, or personal data. Exception messages are for diagnostics, not for programmatic parsing. Fields that are machine-readable should be placed in exception properties or structured error objects.
Logs typically appear at these boundaries: HTTP request entry points, message consumers, scheduled task executors, and command-line main methods. Underlying libraries are responsible for throwing exceptions with a cause, while the boundary layer decides the log level, user response, and retry behavior.
Retries are not simply re-executing the operation after catching an exception. First, determine whether the operation is idempotent. Set a retry count and backoff strategy, and distinguish between permanent and transient errors. For example, if a purchase request has already deducted funds, blindly retrying could result in duplicate charges.
8. Hands-on Exercise
Exercise A: Complete Region Data Loader
Implement MapLoader.load(Path):
- Read the first line using UTF-8 and try-with-resources;
- The format must be
name,x,y; - Throw
MapNotFoundExceptionif the file is missing, and throwInvalidMapFormatExceptionif the format is invalid; - Both exceptions must preserve the original cause or the original safe field;
- Return an immutable
MapLocation(name, x, y).
Test valid North,150,200 data, a missing column, a nonnumeric coordinate, and a missing file. Assert exception types and fields, not a full stack-trace string.
Exercise B: Purchase and Task
The current placeOrder only validates amounts; it has no deduction state. For an advanced exercise, add an account balance and actual deduction, then a request ID. Within one process, one thread, and the lifetime of its in-memory state, repeating a request should return the original result, while reusing the ID with another amount should be rejected. Restart recovery and concurrent idempotency are outside this exercise. Then implement confirmTask: the caller must distinguish "not found", "expired", and "predecessor task not completed". First design the return/exception contract, then write the class.
Exercise C: Close Failure
Modify CloseProbe: when the body succeeds but close fails, the close exception becomes the primary exception; when both body and close fail, the close exception is placed in suppressed exceptions. Write assertions for both paths.
9. Walk the Main Exception Path Again
Remove the "read failure" case from the full example, leaving only CloseProbe.close() to fail: now the first close exception becomes the main exception, and subsequent close exceptions are added as suppressed. After the main exception is restored, the main exception should revert to "read failure," with the two close exceptions appended in the order second, then first. Assertions must be placed on both paths to clearly see the hierarchy and precedence rules of try-with-resources.
Add a test thread to the interruption example. Inside that thread, catch InterruptedException, restore the flag, and wrap it in TaskCancelledException. In an outer test catch, verify that Thread.currentThread().isInterrupted() is true. If this check fails, the cancellation signal received by the thread pool has been lost in your catch block.
Finally, review the exception boundary: lower-level methods provide type, field, and cause information; the entry point (whether a request, a task, or a command line) logs only once. If "not found" is an expected outcome, it should be expressed through the return type. With responsibilities clearly separated into three layers, exceptions no longer become a cross-module string protocol.
Review Java 17's Official Rules
- JLS 14.20: try, catch, finally and Resource Management.
AutoCloseableandThrowable.getSuppressed: Resource closure and the contract for double failures.FilesandStandardCharsets.UTF_8: Explicit paths, encoding, and file operations.Thread.interrupt: Behavior of interrupt status and related methods.
Next Step: Generics
The map loader, purchase service, and task system are all beginning to exhibit the need for "the same logic handling different types." The next chapter uses type parameters to consolidate redundant code while preserving compile-time type safety.