Technology  /  Java

โ˜• Java 15 guides ยท updated 2026

From the JVM to streams, generics, and concurrency โ€” the language behind most enterprise backends, taught with modern Java in mind.

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:

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 exception

Anything 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");
// diagnosable
throw 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.


Each of these appears in real codebases weekly:

1. The swallow. The worst one:

try { process(order); } catch (Exception e) { } // failure erased from history

The 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 incident

Framework 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

  1. A catch (IOException e) block sits above catch (FileNotFoundException e). What does the compiler say, and why?
  2. In try-with-resources, the body throws A and close() throws B. What propagates, and where did the other go?
  3. Why is throw new ServiceException("db failed") inside a catch (SQLException e) block a bug even though it compiles and โ€œworksโ€?
  4. 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?
  5. When is IllegalStateException the right choice over IllegalArgumentException?

(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.