Chapter 03 · Week 3

Exception Handling

How do we handle things going wrong?

Chapter 3 — Exception Handling

“A program that crashes on bad input is a program that hasn’t decided what to do about bad input.” — Joshua Bloch (paraphrased)

“And we know that for those who love God all things work together for good, for those who are called according to his purpose.” — Romans 8:28


Why This Matters

Coding 1 promised you, more than once, that we’d come back to exceptions. The forward-pointer was deliberate. Exception handling is the largest single topic that separates “code that runs in your terminal” from “code that runs in front of users.”

Here’s the move every program eventually has to make: something will go wrong, and your program will have to do something about it. The user types seven when you asked for a number. The file doesn’t exist. The network is down. The disk is full. A value you trusted comes back null. A list you assumed was non-empty is empty.

You have, broadly, four options when this happens:

  1. Crash. The program terminates with a stack trace. The user sees something incomprehensible. Bad outcome.
  2. Ignore. Pretend nothing happened. The program continues with corrupt or default data, and the wrong answer ships somewhere. Worst outcome — silent bugs are the bugs that survive into production.
  3. Fail fast and informatively. Detect the problem, throw an exception with a useful message, let an enclosing layer decide what to do. The right move when you don’t have enough context to recover.
  4. Recover. Catch the exception, log it, and continue with a fallback or retry. The right move when you do have enough context to recover.

The senior engineer’s discipline is to know which of (3) and (4) is right at each layer of the program — and to never, ever default to (1) or (2).

The apologetic frame: how do we handle things going wrong? This is the theodicy question — the question of why the world breaks, and what to do about it — applied at the smallest scale you’ll ever see it. Romans 8:28 says all things work together for good for those who love God. It does not say nothing goes wrong. It says brokenness is real and the response to brokenness is what matters. The same theology, in software form, shows up in the discipline of this chapter: things will go wrong. You do not get to prevent that. You do get to decide what your code does when it happens.

Coach’s Note — If Chapter 3 of Coding 1 — Chapter 2 in the accelerated edition — began your introduction to theodicy by asking “why does any of this matter?” — Coding 2’s Chapter 3 deepens the same question. Things break. The Christian doesn’t pretend otherwise. The Christian engineer also doesn’t write code that pretends otherwise. There’s a moral grain to robust software, and you’ll feel it this week.


3.1 — What Is an Exception?

An exception is a Java object that represents an abnormal condition. When something goes wrong, code throws an exception. The exception travels up the call stack until some enclosing block catches it. If nothing catches it, the program prints a stack trace and exits.

Every exception is an instance of (a subclass of) java.lang.Throwable. The hierarchy you actually care about:

Throwable
├── Error                 (JVM-level catastrophes — OutOfMemoryError, StackOverflowError)
│                         (you do not catch these)
└── Exception
    ├── RuntimeException  (unchecked — programmer errors, mostly)
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   ├── IndexOutOfBoundsException
    │   └── IllegalStateException
    └── (other Exception subclasses)  (checked — environmental errors)
        ├── IOException
        ├── SQLException
        └── (your custom exceptions, if you make them checked)

Two things to internalize from this diagram:

  • Error is for things you cannot do anything about — the JVM has run out of memory, the stack is exhausted. Don’t catch Error. Let the program die.
  • Exception is the branch you work with. It splits into checked and unchecked.

The checked/unchecked distinction is one of Java’s most distinctive language decisions, and it confuses every C++ programmer at first. We’ll address it formally in §3.4. For now: unchecked = the compiler won’t bug you about it; checked = the compiler will.


3.2 — try, catch, finally

The basic syntax. You wrap code that might throw in a try block. You handle the exception in a catch block. You put cleanup that must run regardless in a finally block.

import java.util.Scanner;

public class ParseAge {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        System.out.print("Enter your age: ");
        String input = in.nextLine();

        try {
            int age = Integer.parseInt(input);
            System.out.println("Next year you will be " + (age + 1) + ".");
        } catch (NumberFormatException e) {
            System.out.println("That wasn't a number. Got: " + input);
        } finally {
            in.close();
            System.out.println("Goodbye.");
        }
    }
}

What happens:

  • If the user types 25, parseInt succeeds, the program prints “Next year you will be 26.” Then finally runs.
  • If the user types seven, parseInt throws NumberFormatException. Control jumps immediately to the catch block. The program prints “That wasn’t a number. Got: seven.” Then finally runs.
  • Either way, in.close() runs and “Goodbye.” prints. That’s what finally guarantees.

Important details:

  • The catch parameter is conventionally named e (for “exception”). Some teams prefer ex. Pick a convention, stick with it.
  • e.getMessage() returns the message the exception was thrown with. e.getCause() returns the underlying cause if one was chained. e.printStackTrace() prints the full trace to System.err (useful for debugging; not what you want in production output).
  • finally runs even if the try block returns early, even if the catch block throws, even if no exception was thrown at all. The only thing that prevents finally from running is System.exit() or a JVM crash.

Multi-catch

You can catch multiple exception types in one catch:

try {
    // ...
} catch (NumberFormatException | NullPointerException e) {
    System.out.println("Bad input: " + e.getMessage());
}

Useful when you’d handle several different exceptions the same way.

Try-with-resources

Java’s preferred pattern for things you have to close (files, scanners, network connections):

try (Scanner in = new Scanner(System.in)) {
    System.out.print("Name: ");
    String name = in.nextLine();
    System.out.println("Hello, " + name);
}  // in.close() runs automatically here, even if an exception was thrown

This is the modern idiom. The resource is declared inside the try (…) parentheses, and Java guarantees it gets close()d when the try block exits. Cleaner than finally { in.close(); } and impossible to forget. Prefer try-with-resources whenever the resource implements AutoCloseable. (Most java.io and java.nio classes do.)

Coach’s Note — A catch with an empty body — catch (Exception e) { } — is one of the worst things you can do in Java. It silently swallows every error and continues as if nothing happened. If you find yourself writing that, you don’t have a recovery strategy — you have a hiding strategy. At minimum, log the exception. Better: don’t catch it at all; let the layer above handle it.


3.3 — Throwing Exceptions

To throw an exception, use the throw keyword followed by an exception instance.

public void deposit(double amount) {
    if (amount < 0) {
        throw new IllegalArgumentException("Deposit amount must be non-negative; got " + amount);
    }
    balance += amount;
}

A few principles:

  • Throw the most specific exception type that makes sense. IllegalArgumentException is the standard choice for “the caller passed me a bad argument.” IllegalStateException is for “the object isn’t in a state where this method is valid right now.” NullPointerException is for “an argument I expected non-null was null” — though Objects.requireNonNull is the cleaner idiom for that case.
  • Include a useful message. The message becomes the diagnostic when the exception is caught (or, worse, when it isn’t and it shows up in a stack trace). "bad input" is useless. "Deposit amount must be non-negative; got -50.0" is gold. Include the actual value whenever possible.
  • Throw early. The fail-fast principle: detect the problem at the earliest point in the code where you have enough information to detect it. Don’t let a bad value travel deep into the call stack before something else throws on it; the deeper the throw, the harder the diagnostic.

The Objects.requireNonNull shortcut:

import java.util.Objects;

public void addMember(String name) {
    Objects.requireNonNull(name, "name");
    // ... rest of method
}

This throws NullPointerException("name") if name is null. One line, fail-fast, useful message. Use this at the top of every method whose spec says “must not be null.”


3.4 — Checked vs Unchecked: The Java-Specific Distinction

This is where Java differs sharply from C++.

Unchecked exceptions

RuntimeException and its subclasses. The compiler does not force you to handle them. They represent programmer errors — bugs in the code that should be fixed, not exceptional conditions to be recovered from.

Examples:

  • NullPointerException — you dereferenced a null. Fix the code.
  • IllegalArgumentException — caller passed a bad argument. Fix the caller.
  • IndexOutOfBoundsException — you ran off the end of an array. Fix the loop.
  • IllegalStateException — you called a method when the object wasn’t in a valid state.

You can catch unchecked exceptions, but in well-designed Java you usually don’t catch them in normal code — they indicate bugs, and the right response is to find and fix the bug, not to swallow the symptom.

Checked exceptions

Any Exception that is not a RuntimeException. The compiler forces you to either catch them or declare them with throws.

Examples:

  • IOException — file not found, disk full, network down, etc. Environmental — the disk really might be full; that’s not a bug.
  • SQLException — database errors.
  • Custom checked exceptions you write yourself (rare in Phase 1; we’ll see when in §3.6).

If you call a method that throws a checked exception, the compiler refuses to compile until you either:

  1. Wrap the call in a try/catch, or
  2. Declare your own method throws the same exception, passing the buck to your caller.
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException;

public class ReadFile {
    public static String readWholeFile(String path) throws IOException {
        return Files.readString(Path.of(path));
    }

    public static void main(String[] args) {
        try {
            String content = readWholeFile("notes.txt");
            System.out.println(content);
        } catch (IOException e) {
            System.err.println("Could not read file: " + e.getMessage());
        }
    }
}

readWholeFile doesn’t handle the IOException — it declares throws IOException and passes responsibility to main. main catches and reports it.

The reason this distinction exists: Java’s designers wanted environmental failures (which can happen even in correct code) to be impossible to forget about, while programmer errors (which should be fixed, not handled) didn’t deserve compile-time pressure.

In practice, the distinction is somewhat controversial — many modern Java APIs prefer unchecked exceptions even for environmental errors. But the convention in the standard library is the one above, and you should follow it for your own code in this course.

Coach’s Note — When you call a standard-library method, look at its Javadoc. Every checked exception it throws is listed under @throws. The compiler is going to insist you handle them; reading the doc first tells you which they are and why.


3.5 — throws on Method Signatures

A method signature can declare throws ExceptionType, ExceptionType, … after the parameter list. This is part of the contract — the method is documenting that callers must be prepared to handle these exception types.

public String loadConfig(String path) throws IOException, ConfigParseException {
    // ...
}

Callers of loadConfig must either catch both IOException and ConfigParseException, or declare their own method throws them.

You only need throws for checked exceptions. Unchecked exceptions can be thrown freely without declaring; they’re considered part of “things any method might do.” But many programmers declare unchecked exceptions in Javadoc (with @throws) anyway, because it’s useful documentation.

/**
 * Loads configuration from the given path.
 *
 * @param path the file path; must not be null
 * @return the loaded configuration
 * @throws NullPointerException   if {@code path} is null
 * @throws IOException            if the file cannot be read
 * @throws ConfigParseException   if the file's contents are malformed
 */
public Config loadConfig(String path) throws IOException, ConfigParseException {
    Objects.requireNonNull(path, "path");
    // ...
}

NullPointerException doesn’t appear on the throws clause (it’s unchecked), but it’s documented in Javadoc because callers care.


3.6 — Custom Exceptions

When the standard exceptions don’t capture what’s wrong, you write your own.

/**
 * Thrown when a catechism question cannot be located by its number.
 */
public class QuestionNotFoundException extends Exception {
    private final int questionNumber;

    public QuestionNotFoundException(int questionNumber) {
        super("Catechism question not found: #" + questionNumber);
        this.questionNumber = questionNumber;
    }

    public QuestionNotFoundException(int questionNumber, Throwable cause) {
        super("Catechism question not found: #" + questionNumber, cause);
        this.questionNumber = questionNumber;
    }

    public int getQuestionNumber() {
        return questionNumber;
    }
}

A few rules of thumb:

  • Extend Exception to make it checked. Extend RuntimeException to make it unchecked. The choice depends on whether you want callers forced to handle it.
  • Always provide a constructor that takes a String message. Pass it to super(message).
  • Also provide a constructor that takes a (String, Throwable cause) for exception chaining (see §3.7). Pass both to super.
  • If the exception carries useful data (like the question number above), store it as a final field with a getter. Callers can pull it out of the exception in their catch block.
  • Name with Exception suffix. QuestionNotFoundException, not QuestionNotFound.

When to write a custom exception:

  • The standard library doesn’t have a type that means what you mean.
  • Multiple callers in your codebase want to distinguish your error from generic errors.
  • You want to attach extra data (like the question number) for callers to inspect.

When not to write one:

  • A built-in type works fine. (IllegalArgumentException covers “bad argument” perfectly. Don’t make BadArgumentException.)
  • You only throw it from one place and nobody catches it specifically.

Coach’s Note — Custom exceptions are part of your API. They are as much a part of your class’s contract as its method signatures are. Don’t introduce a new one casually. But when you have a domain-specific failure mode the caller will want to react to specifically, the custom exception is exactly the right tool.


3.7 — Exception Chaining

Sometimes you catch one exception and rethrow it as a different type. The original exception is the cause of the new one. Java lets you preserve the cause via the second constructor argument:

public Catechism loadCatechism(String path) throws CatechismLoadException {
    try {
        String text = Files.readString(Path.of(path));
        return parse(text);
    } catch (IOException e) {
        throw new CatechismLoadException("Could not read " + path, e);
    } catch (CatechismParseException e) {
        throw new CatechismLoadException("Could not parse " + path, e);
    }
}

When CatechismLoadException is caught later, its stack trace will include the original IOException (or CatechismParseException) as the “Caused by:” line. The diagnostic information survives.

Always chain exceptions when you wrap. A catch that throws a new exception without passing the original as the cause is a catch that destroys evidence. Don’t.

// BAD — original exception is lost
catch (IOException e) {
    throw new CatechismLoadException("Could not read " + path);
}

// GOOD — original exception is preserved as the cause
catch (IOException e) {
    throw new CatechismLoadException("Could not read " + path, e);
}

3.8 — Fail Fast vs Graceful Degradation

The two ends of the spectrum.

Fail fast

When something is wrong, throw immediately. Make it impossible to ignore. The reasoning: the closer the throw is to the cause, the easier the diagnostic; and continuing past a corrupt state usually makes things worse, not better.

Use fail-fast for:

  • Precondition violations. A null argument where you expected non-null. An out-of-range value. The caller is buggy; throw IllegalArgumentException so they can find the bug.
  • Invariant violations. Your class detects its own state is corrupt. Throw IllegalStateException.
  • Internal-only errors. Code that other code in your own program calls. There’s no user to be gentle with.

Graceful degradation

When something is wrong but a sensible fallback exists, recover and continue. The reasoning: at the boundary of your program (user input, file I/O, network), failures are normal and the program should respond usefully rather than crash.

Use graceful degradation for:

  • User input. Ask again. Show an error message. Don’t crash on a typo.
  • Optional resources. A missing config file might be acceptable — use defaults.
  • External services. A network call failed — retry once, then fall back to cached data.

The senior engineer’s rule: fail fast in the middle of the program; degrade gracefully at the edges. Internal code makes contracts with itself and enforces them rigorously. Edge code talks to the outside world and treats failure as a normal case.

public static void main(String[] args) {
    // Edge code: graceful degradation
    Scanner in = new Scanner(System.in);
    int age = -1;
    while (age < 0) {
        System.out.print("Your age: ");
        String input = in.nextLine();
        try {
            age = Integer.parseInt(input);
            if (age < 0) System.out.println("Age must be non-negative; try again.");
        } catch (NumberFormatException e) {
            System.out.println("Not a number; try again.");
        }
    }

    // Internal code: fail fast
    UserProfile profile = new UserProfile("Maya", age);   // throws if name is null or age < 0
    System.out.println(profile);
}

main accepts garbage input and recovers. UserProfile’s constructor refuses garbage input and throws. Different layers, different policies.

Coach’s Note — This is the most important pattern in the chapter. Most “robust” code isn’t more code — it’s the same amount of code with the right policy at each layer. Fail fast in the engine; degrade gracefully at the airlock.


3.9 — Logging

Logging is the other half of robust code. When something goes wrong (and you handle it gracefully), you still want a record of what happened — so you can find and fix the underlying problem later.

Java’s standard logging API is java.util.logging (commonly abbreviated JUL). Real production codebases more often use Logback or Log4j2, but JUL is in the JDK, requires no extra dependencies, and is fine for Phase 1.

import java.util.logging.Level;
import java.util.logging.Logger;

public class CatechismLoader {
    private static final Logger LOG = Logger.getLogger(CatechismLoader.class.getName());

    public Catechism load(String path) throws CatechismLoadException {
        LOG.info("Loading catechism from " + path);
        try {
            String text = Files.readString(Path.of(path));
            Catechism c = parse(text);
            LOG.info("Loaded " + c.size() + " questions");
            return c;
        } catch (IOException e) {
            LOG.log(Level.WARNING, "Failed to read " + path, e);
            throw new CatechismLoadException("Could not read " + path, e);
        }
    }
}

A few principles:

  • One logger per class. Logger.getLogger(ClassName.class.getName()) is the idiom. Store it as private static final LOG.
  • Pick the right level. FINE/FINER/FINEST for verbose tracing, INFO for normal flow, WARNING for “something is wrong but we’re handling it,” SEVERE for “something is very wrong.”
  • Always log the exception itself, not just its message. The log(level, message, throwable) overload includes the full stack trace.
  • Log around important decisions, not every line. A log that’s too verbose is a log nobody reads.

By default JUL writes to the console. To also write to a file — so you have a permanent audit trail of a run — attach a FileHandler to the logger:

import java.io.IOException;
import java.util.logging.FileHandler;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;

private static final Logger LOG = Logger.getLogger(CatechismLoader.class.getName());

static {
    try {
        FileHandler fileHandler = new FileHandler("app.log", true); // true = append
        fileHandler.setFormatter(new SimpleFormatter());            // plain text, not XML
        LOG.addHandler(fileHandler);
    } catch (IOException e) {
        LOG.warning("Could not open log file; logging to console only: " + e.getMessage());
    }
}

Now every record the logger handles goes to both the console and app.log. Set the formatter to SimpleFormatter unless you specifically want JUL’s default XML output. For the full set of constructor and rotation options, read the FileHandler Javadoc.

Project 3’s Hard tier asks you to add proper logging to the brittle program you’re refactoring — including writing the log to a file with FileHandler as shown above. The grader will read your log output as much as your code.


3.10 — The Theology of Things Going Wrong

The chapter’s apologetic frame: how do we handle things going wrong?

The theology of suffering (theodicy) is one of the oldest hard questions in the church. The book of Job is the canonical Old Testament treatment; the New Testament adds Romans 5:3-5 and Romans 8:18-28; the Reformers and the Lutheran confessional tradition return to it in nearly every generation. The short version: God does not promise us a world without brokenness. God promises that brokenness is not the final word.

That is not a metaphor with software. That is the direct shape of what we do.

A program that pretends nothing can go wrong is a program that is unprepared for the world. The world contains failed disks, bad input, partial networks, hostile users, and (especially in Phase 2) AI-generated code that confidently calls a nonexistent method. None of this is going away. The Christian engineer who has internalized the theology of fallen creation should — of all engineers — be the least surprised by failure and the most prepared to respond to it deliberately.

That’s the work of this chapter. You are not building a world without exceptions. You are building software that, when exceptions arise, responds — with a clear message, a useful log entry, a sensible fallback, and (where you can) a path forward.

Romans 8:28 does not say all things are good. It says all things work together for good — for those who love God and are called according to His purpose. There is a discipline implicit in that — the willingness to keep working through the brokenness, with the trust that the brokenness can be turned to use. The discipline of robust software is, in its small way, a related discipline: keep the program useful even when individual operations fail, log what went wrong so it can be addressed, and trust that the larger system can be made trustworthy despite the failures of its parts.

Coach’s Note — I want to be careful here. Robust software is not virtue, and the Christian engineer is not better at exception handling because they pray. I am saying something narrower: the Christian who has thought seriously about why the world breaks should have an unusually strong stomach for the discipline of building software that expects the world to break. The expectation isn’t pessimism. It’s realism with a long memory.


3.11 — Common Bugs (Exception Edition)

Bug: catch (Exception e) { } — empty catch that swallows everything. What happened: You silenced every error. Now bugs ship invisibly. Fix: At minimum, log the exception. Better: catch only the specific type you actually know how to handle, and let everything else propagate.


Bug: Catching Exception (or worse, Throwable) instead of the specific type. What happened: You catch too broadly. A NullPointerException you didn’t anticipate gets swallowed alongside the IOException you were trying to handle. Fix: Catch the specific type. Multi-catch if you need several.


Bug: Throwing a new exception in a catch without passing the original as the cause. What happened: Stack trace is now useless — the “Caused by:” line is missing. Fix: Always use the (String, Throwable) constructor when wrapping. throw new MyException("context", e);


Bug: Resource not closed when an exception is thrown. What happened: You wrote Scanner in = new Scanner(...); ...; in.close(); and an exception in the middle bypassed the close(). Fix: Use try-with-resources: try (Scanner in = new Scanner(...)) { ... }. Auto-closes regardless.


Bug: Using exceptions for normal control flow. Example: Throwing NoSuchElementException inside a loop to signal end-of-iteration instead of using a hasNext() check. Fix: Exceptions are expensive (the JVM captures a stack trace) and confuse callers. Use them for exceptional conditions only.


Bug: A NullPointerException with no message. What happened: null.something() or unprotected dereference. The diagnostic is useless because Java only tells you something was null, not what. Fix: Use Objects.requireNonNull(arg, "arg") at the top of every method whose contract says “must not be null.” You get NullPointerException: arg with a useful identifier.


Bug: try/catch around a single line that can throw, when the whole method should have been declared throws. What happened: You catch-and-ignore (or catch-and-rewrap-badly) deep inside a method that didn’t have enough context to decide what to do. Fix: Add throws X to the method signature. Let the caller (which has more context) decide whether to handle or propagate.


3.12 — Reps

Open the exercises for the full set. Highlights:

  • Rep 1 — Force a NumberFormatException and handle it gracefully.
  • Rep 5 — Write a custom checked exception with a chained cause.
  • Rep 7 — Refactor a brittle averaging method so it fails fast with four specific, documented exceptions.
  • Rep 9 — Add java.util.logging to a method and observe the log output.

Every rep is AI-off. Exception handling is muscle memory — train the muscle.


3.13 — This Week’s Project

You’re ready for Project 3 — The Robust Refactor, in Project 3.

You will receive a deliberately brittle Java program (download VerseLookup.java, with sample data verses.csv) — no exception handling, crashes on bad input, swallows file errors silently. The Normal tier asks you to add proper try/catch/finally, throws declarations, one custom exception, and chained exceptions. The Medium tier asks you to distinguish user errors from programmer errors with two different policies. The Hard tier asks you to add a full logging layer that produces a readable audit trail of one bad run.

This is the project where Chapter 1 (reading the brittle code), Chapter 2 (specifying what the corrected version should do), and Chapter 3 (writing the corrected version) all compound. The compounding is the point.


3.14 — Coach’s Final Word for Week 3

Coding 1 told you exceptions were coming. This was the chapter. You are now equipped with the language’s mechanism for handling brokenness deliberately.

What you do with it matters. Most production bugs you’ll meet for the rest of your career are not “the wrong logic.” They’re “the right logic that didn’t handle a case the developer didn’t think about.” Exception handling is the discipline of thinking about the cases. The discipline doesn’t go away when you start using AI in Phase 2 — if anything, it gets sharper, because AI is particularly good at producing fluent code that has not thought about the edge cases.

Five more weeks of Phase 1. Next week is testing — the rep that proves your robust code actually behaves the way you say it does.

See you on Monday.


Up next: Read the exercises and complete every rep. Then open Project 3 and refactor the brittle program. After that, Chapter 4 — testing as discipline.