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.
Learn Exception Handling Architecture in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick…
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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.
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.
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.
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.
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 ___ .
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.
"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.
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.
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.
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.
💡 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.
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.