11.1 Exceptions and Propagation
After mastering control flow, methods, and call stacks, you can complete this Java 17 example in about 35 minutes. Upon reading, you should be able to distinguish between checked and unchecked exceptions and explain where a single failure would be caught along the call stack.
Can't Find missingBatch
An import job requests missingBatch, which is absent from the registry. The old interface used -1 to mean “batch not found”:
int count = findItemCount("missingBatch");
if (count == -1) {
// -1 Error or a valid state?
}The return value is suitable for expressing normal results, and OptionalInt can also express "may not be present." Exceptions are suitable for a different type of situation: when a method cannot fulfill its contract, the caller needs to handle the failure at a more appropriate boundary. By separating the two channels, the success path does not need to pass error codes at every step.
Exceptions also have a cost. They change control flow, and exception objects usually record stack information during construction. If the caller is already frequently querying "does this batch exist," Optional is closer to the business context than an exception. First, determine whether the failure belongs to the normal branch, and then decide the interface.
1. Three-Minute Run-Through of Exception Flow
First, prepare a temporary directory:
chapter11_flow_root=$(mktemp -d)
readonly chapter11_flow_root
mkdir -p "$chapter11_flow_root/out"
cd "$chapter11_flow_root"Save as ExceptionFlowDemo.java:
import java.util.Map;
import java.util.Objects;
public class ExceptionFlowDemo {
private static final Map<String, Integer> BATCHES = Map.of("starterBatch", 3);
public static void main(String[] args) {
BatchAccessException accessFailure = expect(
BatchAccessException.class,
() -> openBatch("missingBatch"));
check(accessFailure.getCause() instanceof BatchNotFoundException,
"The wrapper must preserve the original cause");
System.out.println("Batch read failed: " + accessFailure.getMessage());
System.out.println("Original type: " + accessFailure.getCause().getClass().getSimpleName());
try {
divide(10, 0);
} catch (ArithmeticException exception) {
System.out.println("Runtime exception: " + exception.getClass().getSimpleName());
} finally {
System.out.println("finally: calculation finished");
}
checkEquals(3, openBatch("starterBatch"));
System.out.println("Exception flow check passed");
}
static int openBatch(String batchName) {
try {
return getItemCount(batchName);
} catch (BatchNotFoundException exception) {
throw new BatchAccessException("Cannot read batch " + batchName, exception);
}
}
static int getItemCount(String batchName) throws BatchNotFoundException {
Integer count = BATCHES.get(batchName);
if (count == null) {
throw new BatchNotFoundException("Batch " + batchName + " Does not exist");
}
return count;
}
static int divide(int dividend, int divisor) {
return dividend / divisor;
}
static final class BatchNotFoundException extends Exception {
private static final long serialVersionUID = 1L;
BatchNotFoundException(String message) {
super(message);
}
}
static final class BatchAccessException extends RuntimeException {
private static final long serialVersionUID = 1L;
BatchAccessException(String message, Throwable cause) {
super(message, cause);
}
}
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 ExceptionFlowDemo.java
java -cp out ExceptionFlowDemoExpected output:
Batch read failed: Cannot read batch missingBatch
Original type: BatchNotFoundException
Runtime exception: ArithmeticException
finally: calculation finished
Exception flow check passedAfter saving the required output, clean up:
cd
ls -ld -- "$chapter11_flow_root"
rm -r -- "$chapter11_flow_root"In the expect helper, <T extends Throwable> means the result has the requested exception subtype. Its Class parameter supports the runtime type check; the next chapter explains generics. Other short snippets show selected methods and should not all be pasted into one class.
The program checks three things: checked exceptions can be converted by the upper layer, and the cause can be preserved during conversion; RuntimeException does not need to appear in throws; and finally is executed during this ordinary stack unwinding.
2. Throwable Family Tree
Java can only throw Throwable or its subclasses:
Throwable
├── Error // unchecked
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── AssertionError
└── Exception
├── RuntimeException // unchecked
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── IndexOutOfBoundsException
├── IOException // checked
└── SQLException // checked2.1 Error
Error represents severe issues in the runtime environment or program state. Business code typically cannot recover from these issues during the current operation, such as memory exhaustion or stack overflow. AssertionError is also in this branch, so the statement "all Error are bugs in the JVM" is not accurate.
Application code generally does not catch Error and continue with business processes. The top-level thread, task, or service boundary may log the failure and terminate the current unit, with the specific strategy determined by the runtime environment. Do not use catch (Throwable) to swallow Error and normal business exceptions together.
2.2 Checked exception
Every Throwable type outside RuntimeException, Error, and their subclasses is checked. Custom checked exceptions usually extend Exception. If a method can let a checked exception escape, it must catch it or declare it in its signature:
static String loadMap(Path path) throws IOException {
return Files.readString(path, StandardCharsets.UTF_8);
}The caller can recover only from a missing file and declare that other IOExceptions propagate. These snippets need the relevant java.io, java.nio.file, and StandardCharsets imports:
static void startWithFallback() throws IOException {
String map;
try {
map = loadMap(Path.of("map.txt"));
} catch (NoSuchFileException exception) {
map = "Default region,0,0";
}
System.out.println(map);
}Alternatively, let every read failure propagate from this layer:
static void startApplication() throws IOException {
String map = loadMap(Path.of("map.txt"));
System.out.println(map);
}The compiler forces you to make this decision, but it does not judge whether your handling is reasonable. An empty catch block can also pass compilation, but it will lose the failure information.
2.3 Unchecked exception
RuntimeException and its subclasses do not need to be caught or declared. Null references, illegal parameters, error indices, and violations of object state constraints are commonly expressed using this branch:
static void setCapacity(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive");
}
}Error is also an unchecked throwable. The term "unchecked" here only describes the compiler's rules, not that the exception is minor, nor that the caller should ignore it.
2.4 Choose between checked and unchecked
| Problem | Tendency toward checked | Tendency toward unchecked |
|---|---|---|
| Can the caller take explicit recovery actions | Can choose a backup file, retry the source, or request new input | Can only fix the caller's code or terminate the current operation |
| Do you want the recovery obligation to appear in the API signature | Yes | No |
| Did the failure violate the method's precondition | Usually not | Commonly expressed using IllegalArgumentException and similar constructs |
| Existing framework/API convention | Continue the checked contract | Continue the runtime contract |
“External failure means checked; programmer error means unchecked” is only a rough guide. APIs may report network failure through checked exceptions or asynchronous results, and domain failures may use checked exceptions when callers must choose a recovery action. Base the choice on what callers can do.
3. throw and throws
Two words that differ by a single letter, yet serve completely different purposes:
static int getItemCount(String batchName) throws BatchNotFoundException {
if (batchName.isBlank()) {
throw new BatchNotFoundException("Batch name may not be empty");
}
return 3;
}throw expressionthrows a Throwable object at the current location.throws Typedeclares a possible escaping exception in the method signature. Checked exceptions must be handled or declared; unchecked exceptions may also be listed there.
throws does not throw exceptions or handle them. It exposes the contract to the caller.
4. How Exceptions Propagate Along the Call Stack
Assume the call chain is:
main -> startApplication -> openBatch -> getItemCountAfter getItemCount throws an exception, the JVM looks for a catch block that can handle it in the current method. If there is no matching catch block, the current stack frame is exited and control is returned to openBatch. This process continues upward until a catch block with type compatibility is encountered; if none is found, the thread's uncaught exception handler takes over. In common command-line programs, the thread is terminated and the stack trace is printed after the exception is handled.
When the stack is unwound, each layer's finally runs according to rules. Local variables become inaccessible once their stack frame is exited. Side effects that have already occurred cannot be rolled back: files written, network requests sent, or funds deducted must be compensated for by the business design.
4.1 Catch by Type
try {
loadMap(Path.of("map.txt"));
} catch (NoSuchFileException exception) {
useDefaultMap();
} catch (IOException exception) {
reportReadFailure(exception);
}NoSuchFileException is a subclass of IOException and must be placed before the parent class catch. If written in reverse, the child class branch will never be reached, and the compiler will report an error.
Java 7+ supports multi-catch:
catch (NumberFormatException | IOException exception) {
reportBadInput(exception);
}Two candidate types cannot have a parent-child relationship, for example, NoSuchFileException | IOException is invalid. A more complete catch order and resource closing are covered in the next practical session.
5. Preserve the cause when translating exceptions
openBatch didn't want to expose the BatchNotFoundException to the interface layer, so they switched to BatchAccessException. The conversion of exceptions is fine, but discarding the original exception would lose the trail for troubleshooting:
// Root cause lost
throw new BatchAccessException("Cannot read batch " + batchName, null);
// Retain root cause
throw new BatchAccessException("Cannot read batch " + batchName, exception);Common information for Throwable:
| Method | Purpose |
|---|---|
getMessage() | A brief explanation for the current layer |
getCause() | The original exception wrapped |
getStackTrace() | Recorded stack frames, usually captured when the exception was constructed |
getSuppressed() | Secondary exceptions added during resource closing and similar processes |
Rethrowing the same exception does not normally refresh its stack trace. The top application frame may identify where it was created, not the latest throw site. Methods such as fillInStackTrace or setStackTrace explicitly change that record.
When adding context, be specific, such as material numbers or secure filenames. Avoid putting passwords, tokens, or complete personal data into exceptions. Typically, one exception is recorded by the layer that truly owns the request, task, or process boundary; throwing after logging at each layer would result in multiple identical logs.
6. The Accurate Boundary of finally
finally typically executes when the control flow exits the try/catch block, including normal termination, return, and exception propagation:
try {
openBatch("missingBatch");
} finally {
releaseReservation();
}It is not "execute regardless of what happens in the universe." If the process is forcibly terminated, Runtime.halt, powered down, or the JVM crashes, the current finally block may not have a chance to run. For reliable submission of data, it is better to hand it over to transactions, persistence protocols, or external coordination, rather than relying solely on finally.
Do not return in finally, and do not throw a new exception at random. Both can override the original return value or main exception intended by the try/catch:
static int broken() {
try {
return 1;
} finally {
return 2; // The caller can only see 2
}
}Use try-with-resources for files, connections, and other AutoCloseable resources. It correctly handles the case where failure occurs again during closure, which will be verified in the next lesson.
7. When to Catch
After catching an exception, at least one clear action must be performed:
- If the current layer can recover, for example, when
map.txtis missing, choose built-in region data. - If the current layer can abstract and retain the cause, for example, when a storage exception is converted into a domain exception.
- If the current layer has a top-level boundary, record the failure and end the current request or task.
If the current layer cannot perform any of these actions, continuing to propagate is usually more honest. catch (Exception) may be reasonable at the entry of a request or task scheduler, but in lower-level tool methods, it is common for the caller's intended errors to be hidden.
Exceptions are not suitable for replacing regular conditional checks:
Optional<Batch> batch = inventory.find("missingBatch");
batch.ifPresentOrElse(this::open, this::showMissingMessage);When "non-existence" is frequent and predictable query results, using Optional or an explicit return type is more readable. When a method promises to "open a specified batch" but cannot fulfill it, an exception is more appropriate.
8. Fixing Old Errors One by One
map.txt Does Not Exist
If the application permits default region data, catch NoSuchFileException at the layer that owns startup policy. Other I/O failures may be unrecoverable; preserve the cause and propagate them or terminate startup. Don't silently explain all IOExceptions as "file does not exist."
name Is null
The old code called name.length() when name was null, causing NullPointerException. If null violates the parameter contract, use Objects.requireNonNull(name, "name") at the method entry; if missing name is a legitimate state, use Optional or a specialized value type to express it. Regardless of the approach, it's clearer than catching NPE far away and continuing execution.
Division by Zero
Integer division by zero throws ArithmeticException. If the divisor comes from user input, validate it first and return a meaningful error message; if it comes from internal computation, the exception often reveals a logical flaw. Printing "continue running" after catching the exception doesn't fix the erroneous data.
9. Write Recovery Strategies into Code
First, implement loadMap(Path requested, Path fallback). The program should read "requested" in UTF-8; only when it throws NoSuchFileException should the fallback be attempted. Other I/O failures on "requested" should be directly converted into MapLoadException and retain the cause, and should not silently switch maps.
If both paths do not exist or the backup file fails to read, the message of MapLoadException should include two confirmed publicly accessible filenames, with the backup read exception as the cause, and the first occurrence of NoSuchFileException should remain in the suppressed list. This diagnostic chain can answer "why the backup source was enabled" and "why the backup source also failed."
Write a separate version of main -> startApplication -> openBatch -> getItemCount that catches BatchNotFoundException only in startApplication. In this version, openBatch must declare and propagate the checked exception instead of wrapping it as the opening example does. Check the four method names in the stack trace, not line numbers, which change as the source is edited.
After completion, intentionally swap the order of parent and child catch blocks to confirm that the compiler rejects unreachable branches. Then, remove the cause again from the exception conversion and observe how the test loses the root cause. Being able to explain these two failures is closer to real troubleshooting than memorizing a family tree of Throwable.
Referencing Java 17's Formal Rules
- JLS Chapter 11: Exceptions: Checked/unchecked exceptions, exception analysis, and runtime propagation rules.
- JLS 14.20: try Statement: Catch selection, finally, and try-with-resources.
Throwable: Cause, stack trace, and suppressed exception.Optional: Explicit return types when the absence is a normal result.
Next Step: Exception Handling in Practice
You've already understood how exceptions travel from getItemCount back to the caller. The next lesson puts this mechanism into real files and domain operations: how to sort multiple catch clauses, how to preserve the main exception when resource closing fails, and what context information should be carried by custom exceptions.