Java Exception Handling: Checked vs Unchecked, try-with-resources, and Real Patterns
Exception handling is where codebases quietly reveal their quality. Good error paths make failures diagnosable in minutes; bad ones โ swallowed exceptions, logs full of stack traces nobody reads, catch (Exception e) {} โ turn every incident into archaeology.
Javaโs exception system is more opinionated than most languagesโ: it forces you to acknowledge certain failures at compile time. That design is controversial, half-abandoned by modern frameworks, and still worth understanding deeply โ because youโll live inside it either way.
The Mechanics: throw, try, catch, finally
An exception is an object (extending Throwable) that abandons normal control flow when thrown, unwinding the call stack until something catches it:
public long withdraw(long paise) { if (paise > balance) { throw new IllegalStateException( "Insufficient funds: requested " + paise + ", balance " + balance); } balance -= paise; return balance;}Catching:
try { account.withdraw(50_000); receiptPrinter.print(account); // skipped if withdraw throws} catch (IllegalStateException e) { ui.showError(e.getMessage());} finally { audit.log("withdraw-attempt"); // runs on success AND failure}Rules that matter:
- Control jumps at the throw point; the rest of the
tryblock never runs. - Multiple
catchblocks are checked top-down, first match wins โ so order subtypes before supertypes (catch (FileNotFoundException e)beforecatch (IOException e); the compiler enforces this). - One block for several unrelated types:
catch (SQLException | IOException e). finallyruns no matter what โ exception, return, even abreak. Its historical job, closing resources, has been superseded (next section), but it survives for cleanup like unlocking or metric timers.- An uncaught exception propagates up the entire stack; if
maindoesnโt catch it, the thread dies with the stack trace you know well.
The stack trace, incidentally, is the single most valuable debugging artifact Java produces โ read it top-down: first line is what and where, following at lines are the call path, and any Caused by: sections are the underlying root cause. Learning to read one carefully beats an hour of speculation.
The Hierarchy and the Big Split
Throwableโโโ Error โ JVM-level disasters: OutOfMemoryError, StackOverflowErrorโ Don't catch these. You can't fix them from inside.โโโ Exception โโโ (checked) โ IOException, SQLException, InterruptedException... โ Compiler FORCES callers to catch or declare โโโ RuntimeException โ unchecked: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException, ClassCastException...Checked exceptions represent anticipated environmental failures โ file missing, network down, database unreachable. A method that can throw one must declare it, and callers must deal with it or redeclare it:
public Config load(Path path) throws IOException { return parse(Files.readString(path));}Unchecked exceptions (RuntimeException subclasses) represent bugs โ null where an object was promised, index off the end, argument violating a precondition. The compiler doesnโt force handling, because the correct โhandlerโ is fixing the code.
Thatโs the theory, and as a decision rule for throwing it holds up: caller can plausibly recover โ checked; programming error โ unchecked. The practice is messier: checked exceptions compose badly with lambdas and streams (none of the functional interfaces declare them), and forcing every caller to ceremony-catch things they canโt fix produced decades of empty catch blocks. Modern frameworks (Spring, Hibernate) wrap checked exceptions into unchecked ones wholesale, and newer JVM languages dropped checked exceptions entirely. The pragmatic modern stance: throw unchecked by default in application code, reserve checked for cases where every caller genuinely has a recovery move, and always preserve the cause when translating (below).
try-with-resources: The Only Way to Close Things
Before Java 7, closing files/connections correctly required a finally dance that nearly everyone got subtly wrong (close can itself throw; multiple resources need nesting). try-with-resources fixed it:
try (var in = Files.newBufferedReader(source); var out = Files.newBufferedWriter(target)) { in.lines().forEach(line -> writeLine(out, line));} // both closed automatically, in REVERSE order, even on exceptionAnything implementing AutoCloseable qualifies โ streams, readers, JDBC connections, HTTP clients, locks via wrappers. Fine print worth knowing: if the body throws and a close also throws, the bodyโs exception wins and the close failure is attached as a suppressed exception (visible in the stack trace, retrievable via getSuppressed()) โ so no failure is silently lost.
Rule with no exceptions: acquiring a closeable resource? try-with-resources. Manual close() in finally in new code is a review comment waiting to happen.
Designing Exceptions: Messages, Chaining, Custom Types
Messages are for the 3 a.m. reader
// useless at 3 a.m.throw new IllegalArgumentException("Invalid input");
// diagnosablethrow new IllegalArgumentException( "Order quantity must be 1..%d, got %d (sku=%s)".formatted(MAX_QTY, qty, sku));Include what was expected, what was received, and identifying context. The person reading this message will not have your mental state; very possibly it will be you, next year.
Chaining: never destroy the root cause
When translating a low-level exception into a domain-level one, pass the original as the cause:
try { return repository.findOrder(id);} catch (SQLException e) { throw new OrderLookupException("Failed to load order " + id, e); // โ the 'e' matters}The stack trace then shows both layers via Caused by:. Omitting the cause โ throw new OrderLookupException("failed") โ is one of the most damaging habits in Java: the actual reason (connection timeout? bad SQL? constraint violation?) is gone forever. Same rule when logging-and-rethrowing is replaced by wrap-and-rethrow: exactly one layer should ultimately log it, with the full chain intact.
Custom exceptions: fewer than you think
public class PaymentDeclinedException extends RuntimeException { private final String declineCode;
public PaymentDeclinedException(String declineCode, String message, Throwable cause) { super(message, cause); this.declineCode = declineCode; } public String declineCode() { return declineCode; }}Create a custom type when callers will catch it specifically or when it carries structured data (a decline code, a retry-after hint). Donโt mirror every service with a FooServiceException reflexively โ a custom exception nobody catches by type is just RuntimeException with extra steps. And prefer the standard vocabulary where it fits: IllegalArgumentException (bad input), IllegalStateException (right input, wrong time), UnsupportedOperationException, NoSuchElementException โ every Java developer already knows what they mean.
The Antipattern Gallery
Each of these appears in real codebases weekly:
1. The swallow. The worst one:
try { process(order); } catch (Exception e) { } // failure erased from historyThe system misbehaves with no evidence. If a failure is genuinely ignorable (it almost never is), say so: catch the narrowest type and comment why. Minimum acceptable: log it.
2. Catch-log-rethrow at every layer. One failure โ five stack traces in the log โ on-call engineer chasing โfive errorsโ that are one. Handle or propagate; log once, at the boundary that handles it.
3. catch (Exception e) far too broad. It catches your NullPointerException bug and dresses it up as โpayment service unavailable.โ Catch the specific types you can actually handle; let bugs crash loudly to the top-level handler.
4. Exceptions as control flow:
try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; }This oneโs borderline-acceptable only because the JDK gives no tryParse โ but never design your own APIs this way. Exception construction captures the whole stack trace, ~1000ร the cost of an if; and semantically, expected outcomes arenโt exceptional. Offer a checking method (canHandle()) or return Optional.
5. Throwing raw Exception/Throwable. Declares nothing, forces callers into broad catches, spreads virally through signatures.
Where Exceptions Should Actually Be Handled
The most useful design guidance in this whole topic: most code should not catch anything. A well-shaped application handles errors at a few deliberate boundaries:
HTTP request โโถ Controller โโถ Service โโถ Repository โฒ โ โ โ โโโ throws โโ (business & infrastructure code global exception mostly just lets it fly) handler: maps exception โ status code + logged incidentFramework examples: Springโs @ControllerAdvice, a message consumerโs retry/dead-letter wrapper, a CLIโs top-level try in main. Between the throw site and that boundary, code stays clean โ no try/catch noise โ because propagation is handling. You add a local catch only when you can do something meaningful: retry with backoff, substitute a fallback value, convert to a domain result type, or add crucial context via wrap-and-rethrow.
A final production habit: make peace with InterruptedException. If you catch it (from sleep, join, blocking queues), either rethrow it or restore the flag โ Thread.currentThread().interrupt(); โ never swallow it; thread-pool shutdown depends on it. (More in the concurrency guide.)
Exceptions Meet Modern Java: Streams, Results, and Pattern Matching
Two friction points and one emerging alternative worth knowing about.
Checked exceptions inside lambdas. Files.readString throws IOException; map() takes a Function that declares nothing. The result is the ugliest idiom in modern Java:
paths.stream().map(p -> { try { return Files.readString(p); } catch (IOException e) { throw new UncheckedIOException(e); }}).toList();The standard moves: wrap into an unchecked exception (as above โ UncheckedIOException exists precisely for this), extract a helper method that does the wrapping once, or restructure so the IO happens in a plain loop and only the pure transformation streams. There is no elegant fourth option; this seam is the strongest practical argument against checked exceptions in new API design.
Result types as an alternative. For expected failures โ validation, parsing user input, business rule rejections โ many teams now return a value instead of throwing: Optional<T> when the caller just needs presence/absence, or a small sealed result type when the failure carries information:
sealed interface ParseResult permits Parsed, Malformed {}record Parsed(Config config) implements ParseResult {}record Malformed(int line, String issue) implements ParseResult {}Callers handle both cases in an exhaustive switch โ the compiler enforces that the failure path is considered, which a catch block never quite achieves (you can always justโฆ not catch). The working division: result types for outcomes you expect and route on; exceptions for failures that abandon the operation. A payment being declined is an outcome. The payment gateway being unreachable is an exception.
Frequently Asked Questions
Does try/catch slow my code down? Entering a try block costs essentially nothing on modern JVMs โ the tables are built at compile time. What costs is throwing: constructing the exception captures the full stack trace, which is why exceptions-as-control-flow is a performance issue as well as a design one.
Should I catch Error or Throwable ever? Almost never. A rare legitimate case is a top-level boundary in long-running worker frameworks that logs absolutely everything before letting the thread die. OutOfMemoryError and friends leave the JVM in a state where โhandlingโ is mostly fiction โ see the memory guide.
Where do exception messages end up โ can users see them? Assume yes eventually, via logs at minimum. So: no secrets, no internal hostnames if logs are shared, and definitely no user passwords in messages. Conversely, messages shown to end users should come from your boundary layerโs mapping, not raw e.getMessage() โ โORA-00942: table or view does not existโ is not a user experience.
What is a stack traceโs ... 23 more line? Trimming: frames in a Caused by: section that duplicate the enclosing traceโs frames are elided. The information is still all there โ read each Caused by: from its top.
Self-Check
- A
catch (IOException e)block sits abovecatch (FileNotFoundException e). What does the compiler say, and why? - In try-with-resources, the body throws
Aandclose()throwsB. What propagates, and where did the other go? - Why is
throw new ServiceException("db failed")inside acatch (SQLException e)block a bug even though it compiles and โworksโ? - Your service calls a flaky API. Where do the retry logic and the final error logging each belong โ call site, service layer, or boundary โ and why not both everywhere?
- When is
IllegalStateExceptionthe right choice overIllegalArgumentException?
(Answers: unreachable code error โ FileNotFoundException is a subtype, so it must come first. A propagates; B rides along as a suppressed exception. The root cause e was discarded โ chain it as the constructorโs cause. Retry near the call (itโs a recovery move needing local context); logging once at the boundary โ duplicated logging multiplies noise, duplicated retries multiply load. When the argument is fine but the objectโs current state makes the operation invalid โ selling from an empty inventory, shipping an unconfirmed order.)
Next: Generics โ the type-system machinery behind List<T>, bounded wildcards, PECS, and why erasure makes some things impossible.