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
- 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, throw and throws
- Classes & Inheritance — subclassing and constructors
- Interfaces & Generics — for the Result and 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 ValidationException is a sprained ankle, a DatabaseException is a cardiac arrest. One base AppException is 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 SQLException into a domain RepositoryException.
- 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());
}
}
}
}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 : 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) {}
}
}🧩 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
| 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.
🎉 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
- Previous: Java Inner Classes & Anonymous Classes
- Next: Java Generics: Bounded Types, Wildcards & Type Erasure — Upper/lower bounds, ? wildcards, and how type erasure affects runtime
- Quick reference: Java cheat sheet
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.