Skip to content

VOL 01 / CH 11 / LESSON 01

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”:

java
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:

bash
chapter11_flow_root=$(mktemp -d)
readonly chapter11_flow_root
mkdir -p "$chapter11_flow_root/out"
cd "$chapter11_flow_root"

Save as ExceptionFlowDemo.java:

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:

bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out ExceptionFlowDemo.java
java -cp out ExceptionFlowDemo

Expected output:

text
Batch read failed: Cannot read batch missingBatch
Original type: BatchNotFoundException
Runtime exception: ArithmeticException
finally: calculation finished
Exception flow check passed

After saving the required output, clean up:

bash
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:

text
Throwable
├── Error                         // unchecked
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── AssertionError
└── Exception
    ├── RuntimeException          // unchecked
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   └── IndexOutOfBoundsException
    ├── IOException               // checked
    └── SQLException              // checked

2.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:

java
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:

java
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:

java
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:

java
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 ​

ProblemTendency toward checkedTendency toward unchecked
Can the caller take explicit recovery actionsCan choose a backup file, retry the source, or request new inputCan only fix the caller's code or terminate the current operation
Do you want the recovery obligation to appear in the API signatureYesNo
Did the failure violate the method's preconditionUsually notCommonly expressed using IllegalArgumentException and similar constructs
Existing framework/API conventionContinue the checked contractContinue 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:

java
static int getItemCount(String batchName) throws BatchNotFoundException {
    if (batchName.isBlank()) {
        throw new BatchNotFoundException("Batch name may not be empty");
    }
    return 3;
}
  • throw expression throws a Throwable object at the current location.
  • throws Type declares 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:

text
main -> startApplication -> openBatch -> getItemCount

After 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 ​

java
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:

java
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:

java
// 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:

MethodPurpose
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:

java
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:

java
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:

  1. If the current layer can recover, for example, when map.txt is missing, choose built-in region data.
  2. If the current layer can abstract and retain the cause, for example, when a storage exception is converted into a domain exception.
  3. 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:

java
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 ​

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.

Continue learning exception handling in practice

Built with VitePress | Software Systems Atlas