Exception Handling Architecture

Reviewed & published by Brayan K

You already know how to write try-catch. This lesson is about the decisions: what to throw, where to catch, how to design exceptions so a large codebase stays debuggable instead of drowning in swallowed errors.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

📚 Before You Start

This is the architecture sequel to the basics. You should already be comfortable with:

If those feel shaky, do the basic Exceptions lesson first. Here we focus on design at scale, not the syntax.

🏥 Real-World Analogy: A Hospital, Not a First-Aid Kit

The basic lesson taught you the first-aid kit: when something bleeds, slap a try-catch on it. That keeps one method alive. Architecture is running the whole hospital.

Keep this picture in mind: the goal isn't to catch errors, it's to route them.

1️⃣ Policy First: Checked vs Unchecked

Before writing a single catch, decide a policy for the whole codebase. A checked exception (extends Exception) forces every caller to either catch it or declare throws. An unchecked exception (extends RuntimeException) does not.

The modern, idiomatic rule: default to unchecked. Make an exception checked only when the caller can realistically recover and you genuinely want to force them to think about it. Forcing callers to catch every business error produces noisy code and leaky checked exceptions — a low-level IOException bubbling up through ten layers of method signatures that have nothing to do with files.

2️⃣ Designing a Custom Exception Hierarchy

Give your domain one base exception (say AppException) and derive specific types from it: ValidationException, NotFoundException, PaymentException. The payoff: a single catch (AppException e) can handle the entire family, while each subclass still carries its own data (a field name, an entity id, an error code).

Per module, add a sub-base — PaymentException, OrderException — so a caller can catch an entire module's failures with one type. The worked example below builds exactly this and shows how one loop handles the whole family.

import java.util.*;

public class Main {
    // ── Base type for your whole domain. Unchecked (RuntimeException) by
    //    default: callers shouldn't be FORCED to catch business errors. ──
    static class AppException extends RuntimeException {
        private final String code;                 // machine-readable error code
        AppException(String message, String code, Throwable cause) {
            super(message, cause);                 // pass cause -> chain preserved
            this.code = code;
        }
        AppException(String message, String code) { this(message, code, null); }
        String getCode() { return code; }
    }

    // A whole family of related errors, all sharing one base type.
    static class ValidationException extends AppException {
        private final String field;
        ValidationException(String field, String message) {
            super(message, "VALIDATION");          // code travels with the type
            this.field = field;
        }
        String getField() { return field; }
    }
    static class NotFoundException extends AppException {
        NotFoundException(String entity, long id) {
            super(entity + " " + id + " not found", "NOT_FOUND");
        }
    }
    static class PaymentException extends AppException {       // module sub-tree
        PaymentException(String message, Throwable cause) {
            super(message, "PAYMENT", cause);
        }
    }

    public static void main(String[] args) {
        // Each error knows its own code AND its own human message.
        List<AppException> errors = List.of(
            new ValidationException("email", "Invalid email format"),
            new NotFoundException("User", 42),
            new PaymentException("Card declined", new IllegalStateException("gateway 402"))
        );

        // ONE catch type (AppException) handles the whole family — that is the
        // payoff of a hierarchy. You still get each subclass's specific data.
        for (AppException e : errors) {
            System.out.println("[" + e.getCode() + "] " + e.getMessage());
            if (e instanceof ValidationException v) {
                System.out.println("    field = " + v.getField());
            }
            if (e.getCause() != null) {
                System.out.println("    cause = " + e.getCause().getMessage());
            }
        }
    }
}

3️⃣ Translation & Wrapping — Always Preserve the Cause

Exception translation means catching a low-level exception and rethrowing it as a higher-level one that fits your domain. It belongs at boundaries — the edge of a repository, service, or library — so the rest of your code never depends on SQLException or IOException directly.

The non-negotiable rule when wrapping: pass the original exception as the cause. The two-argument constructor super(message, cause) chains them together. The example walks the chain back to the root cause so you can see the original error survived.

// ✅ Keeps the chain (root cause + stack trace survive):
throw new RepositoryException("Could not load user " + id, e);

// ❌ Throws away the root cause and the original stack trace:
throw new RepositoryException(e.getMessage());
public class Main {
    // Low-level "infrastructure" exception — leaks the implementation (SQL).
    static class SqlException extends Exception {
        SqlException(String message) { super(message); }
    }

    // High-level domain exception the rest of the app understands.
    static class RepositoryException extends RuntimeException {
        RepositoryException(String message, Throwable cause) { super(message, cause); }
    }

    // Pretend this talks to a database and fails.
    static void runQuery(String sql) throws SqlException {
        throw new SqlException("ORA-00942: table USERS does not exist");
    }

    // The repository TRANSLATES the low-level checked exception into a
    // domain exception — and passes the original as the cause.
    static String loadUser(long id) {
        try {
            runQuery("SELECT * FROM users WHERE id=" + id);
            return "user-" + id;
        } catch (SqlException e) {
            // ✅ Wrap: new message for context, original kept as cause.
            throw new RepositoryException("Could not load user " + id, e);
        }
    }

    public static void main(String[] args) {
        try {
            loadUser(7);
        } catch (RepositoryException e) {
            System.out.println("Top-level message : " + e.getMessage());
            // Walk the cause chain to find the real root problem.
            Throwable root = e;
            while (root.getCause() != null) root = root.getCause();
            System.out.println("Root cause        : " + root.getMessage());
            System.out.println("Root cause type   : " + root.getClass().getSimpleName());
        }
    }
}

🎯 Your Turn #1 — Translate & Preserve the Cause

Finish the program below. You'll add a cause-preserving constructor, then translate a low-level NumberFormatException into a domain ConfigException — without losing the cause. Replace each ___.

public class Main {
    static class ConfigException extends RuntimeException {
        // 👉 1) Add a constructor that takes (String message, Throwable cause)
        //        and calls super(message, cause) so the cause is preserved.
        ___
    }

    static int parsePort(String raw) {
        try {
            return Integer.parseInt(raw);
        } catch (NumberFormatException e) {
            // 🎯 YOUR TURN — translate the low-level error into a domain one.
            // 👉 2) Throw a ConfigException with a helpful message AND pass 'e'
            //        as the cause (do NOT just use e.getMessage()).
            throw ___;
        }
    }

    public static void main(String[] args) {
        try {
            parsePort("eighty");
        } catch (ConfigException e) {
            System.out.println("Message: " + e.getMessage());
            System.out.println("Cause  : " + e.getCause().getClass().getSimpleName());
        }
    }
}

// ✅ Expected output:
// Message: Invalid port: eighty
// Cause  : NumberFormatException

4️⃣ Fail-Fast vs Fault-Tolerance, and Error Codes vs Exceptions

Fail-fast means validating at the boundary and throwing the instant something is wrong, so bad data never spreads. Fault-tolerance means surviving a failure — retrying, falling back, or degrading gracefully. Real systems do both: fail fast on contract violations (a null where one is forbidden), tolerate transient external problems (a flaky network call).

Related decision: error codes (return values) vs exceptions. For expected outcomes — "item not found", "input invalid" — returning an Optional or a Result is clearer and faster than throwing (building a stack trace is not free). Reserve exceptions for the genuinely exceptional. The next example pairs a Result type with chaining.

🎯 Your Turn #2 — Expected Failure Without an Exception

"Not on the menu" is an expected outcome, not an exceptional one — so model it as absence with Optional instead of throwing. Fill in the ___ blanks.

import java.util.Optional;

public class Main {
    // EXPECTED failure (item simply might not exist) -> no exception needed.
    static Optional<String> findItem(String[] items, String target) {
        for (String item : items) {
            if (item.equals(target)) return Optional.of(item);
        }
        // 🎯 YOUR TURN — model "not found" as absence, not an exception.
        // 👉 1) Return an EMPTY Optional instead of throwing.
        return ___;
    }

    public static void main(String[] args) {
        String[] menu = {"coffee", "tea", "juice"};

        // 👉 2) Look up "tea", then "soda". Use orElse to supply a default
        //        message when the Optional is empty.
        System.out.println(findItem(menu, "tea").orElse("not on the menu"));
        System.out.println(findItem(menu, ___).orElse("not on the menu"));
    }
}

// ✅ Expected output:
// tea
// not on the menu

5️⃣ Logging vs Rethrowing, and a Global Handler

A classic mistake is log-and-rethrow: logging an exception at every layer and rethrowing it, so one failure appears five times in the logs. Pick one responsibility per layer: either handle it here (log + recover) or add context and rethrow — not both. Log at the place that actually decides what to do about the error, usually the top.

That "top" is the global handler: a single place that catches whatever escapes and turns it into the right response. In a web app this is Spring's @ControllerAdvice; for raw threads it's Thread.setDefaultUncaughtExceptionHandler. The next example shows a thread-level global handler plus disciplined resource cleanup.

// Spring Boot: one place maps each exception type to an HTTP response
@ControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(NotFoundException.class)
    ResponseEntity<?> handle(NotFoundException e) {
        return ResponseEntity.status(404).body(e.getMessage());
    }
}

6️⃣ Resources: try-with-resources & the Cleaner Backstop

Anything that holds an OS resource — a file, socket, or connection — must be released even when an exception is thrown. try-with-resources is the right tool: declare an AutoCloseable in the try (...) header and Java closes it for you, in reverse order, on every exit path. It also handles the awkward "exception while closing" case by attaching it as a suppressed exception instead of hiding the original.

For native resources you can add a Cleaner as a last-resort safety net that runs if an object is garbage-collected without being closed. Treat it strictly as a backstop, never your primary path. The example demonstrates both, plus the global handler from the previous section.

import java.lang.ref.Cleaner;

public class Main {
    // A resource that MUST be released. AutoCloseable -> usable in try-with-resources.
    static class Connection implements AutoCloseable {
        private final String name;
        Connection(String name) {
            this.name = name;
            System.out.println("  OPEN  " + name);
        }
        void query() { System.out.println("  query on " + name); }
        @Override public void close() { System.out.println("  CLOSE " + name); }
    }

    // A safety net using Cleaner: runs IF the object is GC'd without close().
    // It's a backstop, NOT a replacement for try-with-resources.
    static final Cleaner CLEANER = Cleaner.create();
    static class Buffer implements AutoCloseable {
        private final Cleaner.Cleanable cleanable;
        Buffer() {
            // The cleanup action must NOT capture 'this' (would prevent GC).
            cleanable = CLEANER.register(this, () -> System.out.println("  cleaner ran (leak backstop)"));
        }
        @Override public void close() { cleanable.clean(); }
    }

    public static void main(String[] args) {
        System.out.println("try-with-resources (auto close, even on error):");
        // Both resources close automatically, in REVERSE order, even if query throws.
        try (Connection db = new Connection("db");
             Connection cache = new Connection("cache")) {
            db.query();
            cache.query();
        }

        System.out.println("\nexplicit close() triggers the Cleaner action:");
        try (Buffer b = new Buffer()) {
            System.out.println("  using buffer");
        }

        System.out.println("\nglobal handler for anything that escapes a thread:");
        Thread.setDefaultUncaughtExceptionHandler((t, ex) ->
            System.out.println("  [GLOBAL] " + ex.getClass().getSimpleName() + ": " + ex.getMessage()));
        Thread worker = new Thread(() -> { throw new IllegalStateException("unhandled in worker"); });
        worker.start();
        try { worker.join(); } catch (InterruptedException ignored) {}
    }
}

🧩 Mini-Challenge — Fail-Fast Validator with Translation

Support is faded now — only an outline is given. Build a small order pipeline that fails fast on bad input and translates a low-level payment error into a domain exception while preserving the cause. Run it and check it against the expected output in the comments.

public class Main {
    // 🎯 MINI-CHALLENGE: a "fail-fast" validator with exception translation
    //
    // 1. Create a base unchecked exception: OrderException(String, Throwable)
    //    that extends RuntimeException and calls super(message, cause).
    // 2. Create ValidationException extends OrderException (cause can be null).
    // 3. Write validate(int quantity, double price):
    //      - throw a ValidationException IMMEDIATELY (fail-fast) if
    //        quantity <= 0  ("quantity must be positive")
    //      - or if price < 0 ("price cannot be negative")
    // 4. Write placeOrder(...) that calls a flaky charge() which throws
    //    IllegalStateException; CATCH it and TRANSLATE to OrderException,
    //    passing the original as the cause (preserve it!).
    // 5. In main, call validate(0, 5.0) and print the message; then call
    //    placeOrder(...) and print both getMessage() and getCause().
    //
    // ✅ Expected (example):
    //   Validation failed: quantity must be positive
    //   Order failed: payment error  (cause: IllegalStateException)

    // your code here
}

📋 Quick Reference

DecisionUseWhen
Default exception kindextends RuntimeExceptionMost domain & programming errors
Force the caller to handleextends ExceptionCaller can truly recover
Wrap / translatenew MyEx(msg, cause)At repository/service boundaries
Expected "not found"Optional / ResultRoutine, expected outcomes
Catch scopecatch (SpecificEx e)Never Throwable; most specific wins
Centralise handling@ControllerAdviceMap error type → response, once
Release resourcestry (var r = ...)Any AutoCloseable; Cleaner as backstop

Pro Tips

💡 Put an error code on your base exception so a global handler can map it to an HTTP status or API response automatically.

💡 One base exception per module (PaymentException, OrderException) lets callers catch a whole module's failures with a single type.

💡 Check suppressed exceptions (e.getSuppressed()) when debugging try-with-resources — a failure during close() is recorded there, not lost.

🎉 Lesson Complete!

You've moved from handling exceptions to architecting them: a checked-vs-unchecked policy, a domain hierarchy, translation that preserves the cause, fail-fast vs fault-tolerant choices, error codes vs exceptions, a global handler, and disciplined resource cleanup with try-with-resources and a Cleaner backstop.

Next: Generics Advanced — bounded types, wildcards, and type erasure, which power the Result<T> patterns you used here.

Practice quiz

Which base class makes an exception CHECKED?

  • RuntimeException
  • Error
  • Exception (but not RuntimeException)
  • Throwable always

Answer: Exception (but not RuntimeException). A checked exception extends Exception (but not RuntimeException), forcing callers to catch it or declare throws. Extending RuntimeException makes it unchecked.

What is the modern, idiomatic default when choosing an exception kind?

  • Default to unchecked, and make it checked only when the caller can truly recover
  • Default to checked exceptions
  • Always catch Throwable
  • Always use error codes instead

Answer: Default to unchecked, and make it checked only when the caller can truly recover. Default to unchecked (extends RuntimeException); reserve checked exceptions for cases where the caller can realistically recover.

When wrapping a low-level exception, why pass the original as the cause?

  • It makes the new exception checked
  • It is required to compile
  • It silences the original exception
  • It preserves the full chain and original stack trace for debugging

Answer: It preserves the full chain and original stack trace for debugging. Passing the original as the cause via super(message, cause) keeps the chain and stack trace, so logs show both the high-level failure and the root problem.

What is exception translation, and where does it belong?

  • Converting exceptions to strings, in the UI
  • Catching a low-level exception and rethrowing a domain one, at architectural boundaries
  • Translating error messages to other languages
  • Swallowing exceptions in every layer

Answer: Catching a low-level exception and rethrowing a domain one, at architectural boundaries. Translation catches a low-level exception (SQLException, IOException) and rethrows a higher-level domain exception at boundaries like repositories and service edges.

For an expected outcome like 'item not found', what is usually best?

  • Return a value such as an Optional or Result
  • Throw a checked exception
  • Throw an Error
  • Catch Throwable

Answer: Return a value such as an Optional or Result. Expected outcomes are clearer and faster modeled as return values (Optional/Result); reserve exceptions for the genuinely exceptional.

What does throw new RepositoryException(e.getMessage()) lose compared to throw new RepositoryException(msg, e)?

  • Nothing
  • The new message
  • The original stack trace and root cause type
  • The ability to be caught

Answer: The original stack trace and root cause type. Copying only e.getMessage() discards the original stack trace and root cause type; the two-arg form preserves the chain.

What is the benefit of a single base exception like AppException for a domain?

  • It makes all exceptions checked
  • One catch (AppException e) can handle the whole family while subclasses keep their data
  • It removes the need for try/catch
  • It prevents subclassing

Answer: One catch (AppException e) can handle the whole family while subclasses keep their data. A shared base lets one catch handle the entire family, while each subclass still carries specific data like a field name or error code.

Why is catching Throwable (or Error) generally a bad idea?

  • It is slower than catching Exception
  • It is illegal in Java
  • Throwable cannot be caught
  • It traps unrecoverable errors like OutOfMemoryError and masks real bugs

Answer: It traps unrecoverable errors like OutOfMemoryError and masks real bugs. catch (Throwable) traps things like OutOfMemoryError and StackOverflowError you can't recover from; catch the most specific type you can handle.

What is the 'log-and-rethrow at every layer' anti-pattern?

  • Logging once at the top
  • Logging and rethrowing the same exception in every layer, flooding the logs
  • Never logging anything
  • Using a global handler

Answer: Logging and rethrowing the same exception in every layer, flooding the logs. Logging and rethrowing at each layer makes one failure appear many times; log once where the decision is made, usually a global handler.

Why is try-with-resources preferred over a hand-written finally for closing things?

  • It runs the body twice
  • It never throws
  • It closes every AutoCloseable in reverse order automatically and records close-time errors as suppressed
  • It avoids the need for AutoCloseable

Answer: It closes every AutoCloseable in reverse order automatically and records close-time errors as suppressed. try-with-resources auto-closes in reverse order even on exceptions and attaches a close-time failure as a suppressed exception instead of masking the original.

Continue this course

Frequently asked questions

When should an exception be checked vs unchecked?

Use checked exceptions (extends Exception) only when the caller can realistically recover and you want to force them to handle it — and even then, sparingly. Use unchecked exceptions (extends RuntimeException) for programming errors and most domain errors, because forcing every caller to catch business failures creates noisy, leaky APIs. Modern Java style (and frameworks like Spring) leans heavily on unchecked exceptions.

Why must I preserve the cause when wrapping an exception?

Passing the original exception as the cause (new MyException("context", e)) keeps the full chain and stack trace, so logs show both the high-level failure and the real root problem. If you only copy e.getMessage() into a new exception, you throw away the original stack trace and the root cause type, which makes production bugs far harder to diagnose.

What is exception translation and where does it belong?

Exception translation is catching a low-level, implementation-specific exception (like SQLException or IOException) and rethrowing it as a higher-level domain exception (like RepositoryException). It belongs at architectural boundaries — repositories, service edges, library facades — so the rest of your code depends on your domain abstractions, not on the persistence or transport technology underneath.

Should I use error codes or exceptions for failures?

Use exceptions for genuinely exceptional, unexpected conditions and for failures that should propagate up the stack. Use return values — an Optional, a Result/Either type, or a small status object — for expected outcomes like 'not found' or 'invalid input', where throwing would be both slower (stack-trace creation) and harder to read. The rule of thumb: exceptions for the exceptional, values for the expected.

What's the difference between fail-fast and fault-tolerant design?

Fail-fast means detecting an invalid state as early as possible and throwing immediately (validate inputs at the boundary, reject bad data before it spreads). Fault-tolerant means continuing despite a failure — retrying, falling back to a default, or degrading gracefully. Good systems combine both: fail fast on programmer/contract errors, tolerate transient external failures like a slow network.

Why is try-with-resources better than finally for closing things?

try-with-resources closes every AutoCloseable you open, in reverse order, automatically — even when an exception is thrown — and it correctly handles the 'exception while closing' case by attaching it as a suppressed exception instead of masking the original. A hand-written finally block is verbose and easy to get wrong (forgetting null checks, swallowing the primary exception). For last-resort native-resource cleanup, register a Cleaner as a backstop, but never rely on it as your primary close path.

Related lessons