Lesson 19 • Advanced
Exception Handling Architecture
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.
What You'll Learn in This Lesson
- ✓Set a clear checked-vs-unchecked policy for your codebase
- ✓Design a custom exception hierarchy for your domain
- ✓Translate and wrap exceptions while preserving the cause
- ✓Choose between fail-fast and fault-tolerant behaviour
- ✓Centralise handling with a global handler, and decide error codes vs exceptions
- ✓Manage resources with try-with-resources and a Cleaner backstop
📚 Before You Start
This is the architecture sequel to the basics. You should already be comfortable with:
- Exception Handling — try / catch / finally,
throwandthrows - Classes & Inheritance — subclassing and constructors
- Interfaces & Generics — for the
Resultand handler patterns
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.
- Triage (your hierarchy): every problem gets classified the moment it arrives — a
ValidationExceptionis a sprained ankle, aDatabaseExceptionis a cardiac arrest. One baseAppExceptionis the intake desk everyone passes through. - Translation (the boundary): the ER doesn't speak in raw lab readings to the family — it translates into a diagnosis. Your repository translates a raw
SQLExceptioninto a domainRepositoryException. - Keep the chart (the cause): the diagnosis never erases the original lab results. You always pass the original exception as the cause so the full history survives.
- One protocol desk (the global handler): instead of every doctor inventing their own paperwork, a single front desk maps each diagnosis to the right response.
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());
}
}
}
}[VALIDATION] Invalid email format
field = email
[NOT_FOUND] User 42 not found
[PAYMENT] Card declined
cause = gateway 4023️⃣ 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());
}
}
}Top-level message : Could not load user 7
Root cause : ORA-00942: table USERS does not exist
Root cause type : SqlException🎯 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 : NumberFormatException4️⃣ 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 menu5️⃣ 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) {}
}
}try-with-resources (auto close, even on error):
OPEN db
OPEN cache
query on db
query on cache
CLOSE cache
CLOSE db
explicit close() triggers the Cleaner action:
using buffer
cleaner ran (leak backstop)
global handler for anything that escapes a thread:
[GLOBAL] IllegalStateException: unhandled in worker🧩 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
}Common Errors (and the Fix)
catch (Exception e) {} hides failures and produces "it just doesn't work, no error." Fix: at minimum log it; better, handle or rethrow with context. Never catch to silence.throw new MyException(e.getMessage()) discards the original stack trace and root type. Fix: use the two-arg form new MyException("context", e) so the chain survives.throws SQLException propagating through unrelated layers couples your whole app to the database. Fix: translate it to a domain exception at the boundary and keep upper layers clean.Throwable (or Error): catch (Throwable t) traps OutOfMemoryError and StackOverflowError you can't recover from, and masks real bugs. Fix: catch the most specific exception type you can actually handle; let Error propagate.📋 Quick Reference
| Decision | Use | When |
|---|---|---|
| Default exception kind | extends RuntimeException | Most domain & programming errors |
| Force the caller to handle | extends Exception | Caller can truly recover |
| Wrap / translate | new MyEx(msg, cause) | At repository/service boundaries |
| Expected "not found" | Optional / Result | Routine, expected outcomes |
| Catch scope | catch (SpecificEx e) | Never Throwable; most specific wins |
| Centralise handling | @ControllerAdvice | Map error type → response, once |
| Release resources | try (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.
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.
🎉 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.
Sign up for free to track which lessons you've completed and get learning reminders.