5. Exception Handling and Robustness
Every program meets conditions its normal logic cannot handle: a missing file,
a malformed input, a network that vanishes mid-request. This chapter covers
Java's mechanism for signalling and responding to those conditions, from the
Throwable hierarchy and the checked/unchecked divide through to
try-with-resources and the design decisions that separate an API people can
use safely from one that hides its failures. Exception handling is where
robustness is won or lost, because the difference between a program that fails
usefully and one that corrupts data is almost entirely a matter of these
choices.
<a id="51-exception-mechanics"></a>
5.1 Exception Mechanics
Before deciding how to handle failures, you need to know what an exception actually is, what kinds exist, and what the JVM does when one is thrown.
Errors versus Exceptions
A method that cannot do its job has to tell its caller. Returning a special value is the obvious idea, and it is what C does, but it fails in three ways: the caller can ignore the value silently, a constructor has no return value to use, and every layer in between must manually forward the failure.
// The return-code approach: every caller must remember to check.
int result = parseAge("abc");
if (result == -1) { /* handle */ } // nothing forces this line to exist
int doubled = result * 2; // forgot to check: -2, silently wrong
Java uses a separate channel. When something goes wrong, the method throws an object describing the failure, and normal execution stops until some caller catches it. The failure cannot be ignored by accident, because ignoring it terminates the thread with a printed trace.
Think of a return value as passing a note back to the person who called you, and an exception as pulling a fire alarm: everyone in the building stops what they are doing until someone trained to respond takes over.
Java divides these signals into two families with very different intent:
// An Exception: the program did something wrong and could recover.
throw new IllegalArgumentException("age must not be negative");
// An Error: the environment is broken and the program cannot sensibly continue.
// OutOfMemoryError, StackOverflowError, NoClassDefFoundError
public class Depth {
static int recurse(int n) { return recurse(n + 1); } // never terminates
public static void main(String[] args) {
try {
recurse(0);
} catch (StackOverflowError e) { // catchable, but pointless
System.out.println("caught, but the stack is still exhausted");
}
}
}
| Aspect | Error | Exception |
|---|---|---|
| Signals | A broken JVM or environment | A problem in program logic or input |
| Expected to catch | No | Yes |
| Recovery realistic | Almost never | Usually |
| Typical examples | OutOfMemoryError, StackOverflowError | IOException, IllegalArgumentException |
| Checked by compiler | No | Depends on the subclass |
Error is technically catchable, and that is exactly the trap. Catching
OutOfMemoryError gives you a handler that needs memory to run, and catching
Throwable at the top of a loop turns a fatal condition into an infinite
retry. The rule is simple: catch Exception subtypes, let Error reach the
top.
Key Takeaways
- Exceptions are a separate signalling channel, so failures cannot be ignored by forgetting a return-value check.
Errormeans the environment is broken;Exceptionmeans the program or its input is.- Errors are catchable but should not be caught, because the handler cannot fix the condition.
StackOverflowErrorandOutOfMemoryErrorare the two you will actually see, and both indicate a bug rather than bad input.
🧪 Practice
- Write a method that recurses without a base case and observe the
StackOverflowErrorit produces.- Rewrite a method that returns
-1on failure so it throws an exception instead, and compare how a forgetful caller behaves in each version.- Interview: Why is catching
Erroralmost always wrong? (Hint: ask what resources your catch block itself needs in order to run.)
The Throwable Hierarchy
Every throwable thing in Java is an object, and all of them descend from one
class. Knowing the shape of that tree is what lets you predict which catch
clauses will fire and which exceptions the compiler will force you to declare.
Object
|
Throwable <- the only throwable root
/ \
Error Exception
| / \
OutOfMemoryError RuntimeException IOException
StackOverflowError | FileNotFoundException
NoClassDefFoundError | SQLException
|
NullPointerException
IllegalArgumentException
IndexOutOfBoundsException
ArithmeticException
ClassCastException
UNCHECKED: Error + RuntimeException and their subclasses
CHECKED: everything else under Exception
Only a Throwable can appear after throw or in a catch clause. The
compiler enforces this:
// throw new String("oops"); // ERROR: incompatible types
// catch (Integer e) { } // ERROR: not a subclass of Throwable
Throwable itself provides the members every exception inherits:
| Member | Purpose |
|---|---|
getMessage() | The human-readable detail text |
getLocalizedMessage() | A localised variant, overridable |
getCause() | The underlying throwable, or null |
getStackTrace() | The call stack as an array of StackTraceElement |
printStackTrace() | Dumps the trace to System.err |
getSuppressed() | Exceptions suppressed by try-with-resources |
addSuppressed(Throwable) | Records a suppressed exception |
try {
Files.readString(Path.of("missing.txt"));
} catch (IOException e) {
System.out.println(e.getClass().getName()); // java.nio.file.NoSuchFileException
System.out.println(e.getMessage()); // missing.txt
System.out.println(e.getCause()); // null: nothing underneath
}
Because catch matches by subtype, position in this tree decides everything. A
catch (Exception e) clause catches IOException and NullPointerException
alike, while catch (IOException e) catches neither RuntimeException nor
Error:
Throwable t = new FileNotFoundException("x");
System.out.println(t instanceof IOException); // true
System.out.println(t instanceof Exception); // true
System.out.println(t instanceof RuntimeException);// false
Key Takeaways
Throwableis the root; only its subclasses can be thrown or caught.ErrorandExceptionare its two branches, andRuntimeExceptionis a branch ofException.- Unchecked means
Error,RuntimeException, and their subclasses; everything else underExceptionis checked.catchmatches by subtype, so a clause catches the named type and all its descendants.getMessage,getCause,getStackTrace, andgetSuppressedare available on every throwable.
🧪 Practice
- Print the full ancestry of
FileNotFoundExceptionby walkinggetSuperclass()from itsClassobject.- Write
instanceofchecks proving thatArithmeticExceptionis unchecked andSQLExceptionis checked.- Interview: Where exactly is the line between checked and unchecked drawn in the hierarchy? (Hint: name the two subtrees that are unchecked.)
Checked versus Unchecked Exceptions
Java is unusual in making the compiler participate. For some exceptions it demands that you either handle them or declare them; for others it says nothing. The split is a design statement, not a technical one.
A checked exception represents a condition a correct program should anticipate: the file might genuinely be missing, the network might genuinely be down. The compiler insists the caller acknowledge it, which is the handle-or-declare rule.
// Will not compile: IOException is checked and neither handled nor declared.
public String readConfig(Path path) {
return Files.readString(path); // ERROR: unhandled exception IOException
}
// Option 1: handle it here.
public String readConfigOrDefault(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
return ""; // a genuine recovery
}
}
// Option 2: declare it and let the caller decide.
public String readConfig(Path path) throws IOException {
return Files.readString(path);
}
An unchecked exception represents a programming mistake: a null reference, a bad index, an argument that violates the method's documented contract. The compiler stays silent because the correct fix is to change the code, not to add a handler.
public double divide(int a, int b) {
return a / b; // ArithmeticException if b == 0
} // no throws clause needed or wanted
int[] values = new int[3];
// values[5] = 1; // ArrayIndexOutOfBoundsException at runtime
| Question | Checked | Unchecked |
|---|---|---|
| Compiler enforcement | Handle or declare | None |
| Root types | Exception, not RuntimeException | RuntimeException, Error |
| Represents | An anticipated external failure | A bug in the code |
| Caller can realistically recover | Usually yes | Usually no |
Appears in throws | Required | Optional, documentation only |
| Examples | IOException, SQLException, ClassNotFoundException | NullPointerException, IllegalArgumentException, IllegalStateException |
The rule of thumb that has held up best: if the caller can do something useful about it, make it checked; if the only sensible response is to fix the code, make it unchecked. Section 5.4 returns to this as an API design decision, including the criticism that checked exceptions do not compose well with lambdas and streams.
One subtlety worth internalising now: overriding a method may narrow the checked exceptions it declares, but never widen them, because callers of the supertype were promised a limited set.
class Base { void run() throws IOException { } }
class Narrow extends Base { @Override void run() throws FileNotFoundException { } } // OK
class NoneAtAll extends Base { @Override void run() { } } // OK
// class Wider extends Base { @Override void run() throws Exception { } } // ERROR
Unchecked exceptions are exempt from that rule and may be added freely by an override.
Key Takeaways
- Checked exceptions must be handled or declared; unchecked ones need neither.
RuntimeExceptionandErrorand their subclasses are unchecked; everything else underExceptionis checked.- Checked signals an anticipated external failure; unchecked signals a programming bug.
- An override may narrow or drop declared checked exceptions but never widen them.
- Unchecked exceptions may be thrown from anywhere without declaration.
🧪 Practice
- Write a method calling
Files.readStringand make it compile in both ways: once by catching, once by declaring.- Classify
NumberFormatException,InterruptedException, andClassCastExceptionas checked or unchecked, then verify against the Javadoc.- Interview: Why can an overriding method not declare a broader checked exception than the method it overrides? (Hint: think about a caller holding a reference of the supertype.)
Stack Unwinding and Propagation
When a method throws, the JVM does not simply stop. It searches for a handler, and the search is what gives exceptions their reach across the call stack.
Each method call pushes a frame onto the thread's stack. On a throw, the
JVM inspects the current frame for a matching catch. If there is none, it
discards that frame, which is called unwinding, and repeats with the
caller. This continues until a handler is found or the stack is empty.
main() main() main()
-> process() -> process() (handler runs here)
-> parse() throws [discarded]
X [discarded]
unwinding upward handler found in process()
public class Propagation {
static int parse(String text) {
return Integer.parseInt(text); // throws NumberFormatException
}
static int process(String text) { // no catch here: it just passes up
System.out.println("before");
int value = parse(text);
System.out.println("after"); // NEVER runs when parse throws
return value;
}
public static void main(String[] args) {
try {
process("abc");
} catch (NumberFormatException e) { // found three frames up
System.out.println("handled: " + e.getMessage());
}
}
}
// Output: before / handled: For input string: "abc"
Two consequences matter in practice. First, any code after the throwing call in
an unwound frame is skipped entirely, which is why cleanup belongs in finally
or try-with-resources rather than on the line after the risky call. Second, a
method need not mention an exception to propagate it; unchecked exceptions
travel upward through any number of frames silently.
If no frame handles it, the thread's default handler prints the trace and the
thread dies. In the main thread that ends the program with a non-zero exit
status:
public static void main(String[] args) {
Integer.parseInt("abc"); // nothing catches it
}
// Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
// at java.base/java.lang.Integer.parseInt(Integer.java:652)
// ...
// Process exits with status 1
Unwinding is per thread. An exception in one thread never propagates into another, which is why an uncaught exception in a worker thread can kill that worker while the rest of the program continues unaware:
Thread worker = new Thread(() -> { throw new IllegalStateException("worker died"); });
worker.setUncaughtExceptionHandler((t, e) -> // install a handler, or the
System.out.println(t.getName() + " failed: " + e.getMessage()));
worker.start();
Key Takeaways
- A throw causes the JVM to search each stack frame outward for a matching
catch, discarding frames as it goes.- Code after the throwing statement in an unwound frame never executes.
- Exceptions propagate through methods that do not mention them, especially unchecked ones.
- An unhandled exception terminates the thread and prints a trace; in
mainit ends the program.- Unwinding is confined to one thread; use an uncaught exception handler for worker threads.
🧪 Practice
- Write three nested methods, throw from the innermost, catch in the outermost, and print a line in each to trace what runs.
- Add a statement after the throwing call in the middle method and confirm it never executes.
- Interview: A method calls something that throws an unchecked exception but declares nothing. How does the caller find out? (Hint: what does the compiler verify, and what does the JVM do at runtime?)
Reading and Interpreting Stack Traces
A stack trace is the single most useful diagnostic Java produces, and reading it well is a learnable skill. It is a snapshot of the call stack at the moment the exception was constructed, printed innermost first.
Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"String.length()" because "name" is null
at com.example.UserService.validate(UserService.java:42) <- where it broke
at com.example.UserService.register(UserService.java:28) <- who called that
at com.example.Main.main(Main.java:12) <- the entry point
Caused by: java.sql.SQLException: connection refused <- the root cause
at com.example.Repository.connect(Repository.java:55)
... 3 more <- repeated frames
Read it in four passes:
- The exception type tells you the category of failure.
NullPointerExceptionis a bug;SQLExceptionis an environment problem. - The message carries the specifics. Since Java 14, helpful
NullPointerExceptionmessages name the exact expression that was null. - The first frame mentioning your own package is usually where to look. The top frames are often library internals that merely detected the problem.
- The last
Caused bysection is the original failure. Everything above it is wrapping, so read a chained trace from the bottom up.
public class TraceDemo {
public static void main(String[] args) {
try {
level1();
} catch (RuntimeException e) {
e.printStackTrace(); // the full trace to stderr
System.out.println("top: " + e.getMessage());
System.out.println("root: " + rootCause(e).getMessage());
StackTraceElement origin = e.getStackTrace()[0]; // innermost frame
System.out.println(origin.getClassName() + "." + origin.getMethodName()
+ " line " + origin.getLineNumber());
}
}
static Throwable rootCause(Throwable t) {
while (t.getCause() != null) t = t.getCause(); // walk to the bottom
return t;
}
static void level1() {
try {
level2();
} catch (IllegalStateException e) {
throw new RuntimeException("level1 failed", e); // wraps, keeps cause
}
}
static void level2() { throw new IllegalStateException("original problem"); }
}
Two artefacts confuse newcomers. ... 23 more means those frames are identical
to the ones already printed above, and were elided for brevity. And a trace
with no line numbers, showing (Unknown Source), means the class was compiled
without debug information, so you cannot map it back to source lines.
Three habits make traces far more useful:
- Never call
printStackTrace()in production code; log the throwable through your logging framework so it lands with context and a timestamp. - Never throw away the cause when wrapping, or the bottom of the trace disappears.
- Never catch and rethrow a new exception without the original as its cause.
Key Takeaways
- A stack trace lists frames innermost first, with the failing line at the top.
- The first frame in your own package is usually the place to investigate.
Caused bysections read bottom-up: the last one is the original failure.... N moremeans frames identical to those already shown were elided.- Log throwables through a logger rather than
printStackTrace, and always preserve the cause.
🧪 Practice
- Produce a three-level nested exception with causes and read the trace from the bottom up.
- Write a
rootCause(Throwable)helper and test it on a chained exception.- Interview: Given a 60-line trace whose top ten frames are all framework classes, where do you start looking? (Hint: which frames did your team actually write?)
<a id="52-handling-and-declaring"></a>
5.2 Handling and Declaring
This subchapter covers the syntax you use every day: catching what you can handle, declaring what you cannot, and raising exceptions of your own.
try, catch, and finally Blocks
The try statement marks a region where a failure is anticipated, and attaches
handlers to it. The shape is three-part, though only two parts are ever needed
at once.
try {
// guarded region: statements that might throw
} catch (SpecificException e) {
// recovery: runs only if that exception type was thrown
} finally {
// cleanup: runs no matter how the try block exits
}
A try needs at least one catch or a finally, or, since Java 7, a
resource list. A bare try { } does not compile.
public static int readCount(Path path) {
try {
String text = Files.readString(path); // may throw IOException
return Integer.parseInt(text.trim()); // may throw NumberFormatException
} catch (IOException e) {
System.out.println("could not read: " + e.getMessage());
return 0; // a real recovery value
} catch (NumberFormatException e) {
System.out.println("not a number: " + e.getMessage());
return 0;
}
}
Scope matters more than beginners expect: a variable declared inside try is
invisible in catch and finally, because the compiler cannot prove it was
ever assigned.
try {
String text = Files.readString(path);
} catch (IOException e) {
// System.out.println(text); // ERROR: cannot find symbol
}
String text = null; // declare outside to share it
try {
text = Files.readString(path);
} catch (IOException e) {
System.out.println("value so far: " + text);
}
finally always runs. It executes after a normal exit, after a caught
exception, and even while an uncaught exception is unwinding through the frame.
It is the mechanism that guarantees cleanup:
public static void demo() {
try {
System.out.println("try");
throw new IllegalStateException("boom");
} catch (IllegalStateException e) {
System.out.println("catch");
return; // finally still runs BEFORE returning
} finally {
System.out.println("finally");
}
}
// Output: try / catch / finally
There are exactly two ways to escape it: System.exit(...), which stops the
JVM outright, and the abrupt death of the thread or the JVM itself.
The dangerous interaction is a return or throw inside finally, which
discards whatever the try or catch was doing. This swallows exceptions
silently and is one of the classic Java bugs:
public static int broken() {
try {
throw new IllegalStateException("real problem");
} finally {
return 42; // the exception vanishes; caller sees 42
}
}
Never return or throw from finally. Modern compilers and linters warn about
it, and try-with-resources removes most reasons to write a finally at all.
Key Takeaways
tryneeds at least onecatch, afinally, or a resource list.- Variables declared inside
tryare not visible incatchorfinally.finallyruns on every exit path, includingreturnand an exception propagating through.- Only
System.exitor JVM death skips afinallyblock.- Returning or throwing from
finallydiscards the original outcome and hides bugs.
🧪 Practice
- Write a method whose
tryreturns a value and whosefinallyprints a line, and determine the order of execution.- Declare a variable inside
tryand try to read it incatch; fix the error.- Interview: What does a method return if
tryreturns 1 andfinallyreturns 2? (Hint: which one executes last, and what happens to the first result?)
Multi-Catch Blocks
Before Java 7, handling several exception types the same way meant repeating the handler body or catching something too broad. Both are bad: duplication drifts, and an overbroad catch swallows unrelated failures.
// The pre-Java 7 problem: identical bodies.
try {
doWork();
} catch (IOException e) {
log.error("work failed", e);
notifyAdmin(e);
} catch (SQLException e) {
log.error("work failed", e); // exactly the same, duplicated
notifyAdmin(e);
}
Multi-catch lists alternatives separated by |:
try {
doWork();
} catch (IOException | SQLException e) { // one handler, two types
log.error("work failed", e);
notifyAdmin(e);
}
Three rules govern it:
- The listed types must not be in a subtype relationship with each other. Listing a type and its ancestor is redundant and rejected.
- The parameter is implicitly final, so it cannot be reassigned inside the block.
- The parameter's static type is the least upper bound of the alternatives, so only members common to all of them are accessible.
// catch (IOException | FileNotFoundException e) { }
// ERROR: FileNotFoundException is a subclass of IOException
try {
doWork();
} catch (IOException | SQLException e) {
// e = new IOException(); // ERROR: parameter is implicitly final
System.out.println(e.getMessage());// fine: declared on Throwable
// e.getSQLState(); // ERROR: only on SQLException
}
When the handlers genuinely differ, keep them separate. Multi-catch is for shared responses, not for shortening code that should branch:
try {
processFile(path);
} catch (FileNotFoundException e) {
createDefaultFile(path); // a specific recovery
} catch (IOException | SecurityException e) { // both are unrecoverable here
throw new ProcessingException("cannot process " + path, e);
}
If you need type-specific behaviour inside a multi-catch, the pattern-matching
instanceof from chapter 3 keeps it readable, though separate clauses are
usually clearer.
Key Takeaways
- Multi-catch handles several exception types with one block, written
catch (A | B e).- The alternatives may not be related by inheritance.
- The parameter is implicitly final and typed as the common supertype of the alternatives.
- Only members declared on that common supertype are accessible.
- Use it only when the response is genuinely identical for every listed type.
🧪 Practice
- Convert two identical catch blocks into a single multi-catch.
- Try listing
Exception | IOExceptionin one clause and explain the error.- Inside a
catch (IOException | SQLException e)block, attempt to call a method that exists only onSQLExceptionand explain why it fails.
Catch Ordering and Specificity
Multiple catch clauses are tested top to bottom, and the first one whose
type matches wins. That makes their order part of the program's meaning, not a
stylistic choice.
Because a clause matches the named type and all its subtypes, a broad clause placed early absorbs everything below it. The compiler catches the obvious version of this mistake:
try {
readFile();
} catch (Exception e) { // catches everything
handle(e);
} catch (IOException e) { // ERROR: already caught above
handleIo(e); // unreachable code
}
Ordering must therefore run most specific first:
try {
readFile();
} catch (FileNotFoundException e) { // most specific subtype
createDefault();
} catch (IOException e) { // its parent
log.warn("io failure", e);
} catch (Exception e) { // the broad safety net, last
log.error("unexpected", e);
}
throw FileNotFoundException
|
v
catch (FileNotFoundException) -> MATCH, this block runs, search stops
catch (IOException) -> never consulted
catch (Exception) -> never consulted
Note that the compiler only rejects the unreachable case for related types. Unrelated clauses can be written in any order and the compiler says nothing, which is why the specificity habit matters even when it is not enforced:
try {
work();
} catch (SQLException e) {
handleSql(e); // unrelated to IOException
} catch (IOException e) {
handleIo(e); // order between these two is irrelevant
}
Two practical guidelines follow. A catch-all catch (Exception e) is
acceptable as a last clause at an application boundary, such as a request
handler or main, where the alternative is a crashed thread. It is a mistake
deep in business logic, where it hides bugs that should surface. And never
write catch (Throwable t) unless you intend to catch OutOfMemoryError too,
which you almost certainly do not.
Key Takeaways
- Catch clauses are evaluated in order, and the first matching type wins.
- A clause matches the declared type and every subtype of it.
- Placing a supertype before its subtype is a compile error for unreachable code.
- Order from most specific to most general.
- Reserve
catch (Exception e)for boundaries, and avoidcatch (Throwable t)entirely.
🧪 Practice
- Write a try with three ordered catch clauses and trigger each one in turn.
- Put
catch (Exception e)first and read the compiler error for the clause below it.- Interview: Why does the compiler reject one ordering but stay silent about two unrelated clauses? (Hint: what can it prove about reachability in each case?)
The throws Clause
Not every method should handle its own failures. A low-level file reader has no
idea whether a missing file means "use a default", "prompt the user", or "abort
the deployment". Only the caller knows. The throws clause is how a method
declines to decide and pushes the choice upward.
public String readConfig(Path path) throws IOException { // I might fail this way
return Files.readString(path); // no try needed here
}
The clause becomes part of the method's published contract. Callers must then handle or declare it themselves, so the obligation travels up the call chain until someone takes responsibility.
public void startup() throws IOException { // passing it further up
String config = readConfig(Path.of("app.conf"));
}
public static void main(String[] args) { // main may declare it too
try {
new App().startup();
} catch (IOException e) { // the buck stops here
System.err.println("cannot start: " + e.getMessage());
System.exit(1);
}
}
A method may declare several exceptions, and may declare unchecked ones purely as documentation:
public void transfer(Account from, Account to, BigDecimal amount)
throws InsufficientFundsException, AccountFrozenException {
...
}
/**
* @throws IllegalArgumentException if amount is negative
*/
public void withdraw(BigDecimal amount) { // unchecked: not in the clause
if (amount.signum() < 0) throw new IllegalArgumentException("negative");
}
Declaring unchecked exceptions in throws is legal but unconventional; the
Javadoc @throws tag is the usual place for them.
| Element | Keyword | Where it appears | Meaning |
|---|---|---|---|
| Declaration | throws | Method signature | This method may propagate it |
| Statement | throw | Method body | Raise this exception now |
That one-letter difference is a perennial source of confusion, and the table is worth memorising.
Two design cautions. Declaring throws Exception is almost always wrong: it
tells the caller nothing and forces them into an overbroad catch. And a long
throws list often signals that a method is doing too much, or that the
exceptions should be translated into one meaningful type, which section 5.4
covers.
Key Takeaways
throwsdeclares that a method may propagate an exception instead of handling it.- Checked exceptions must be declared; unchecked ones may be, for documentation.
- The obligation propagates to every caller until one handles it.
throwsdeclares,throwraises; the two are different constructs.- Avoid
throws Exception, which destroys the information the clause exists to carry.
🧪 Practice
- Write a three-method chain that declares
IOExceptionall the way up tomain, then handle it there.- Add an unchecked exception to a
throwsclause and confirm callers are not forced to handle it.- Interview: What is wrong with
public void process() throws Exception? (Hint: what must every caller now write, and what have they learned about the failure?)
Throwing Exceptions Explicitly
So far the exceptions came from the library. Your own code raises them with
throw, which takes a single Throwable instance and immediately begins
unwinding.
throw new IllegalArgumentException("radius must be positive: " + radius);
// ^ ^ ^
// | | the detail message, for the trace
// | an instance: the exception object is created here
// the statement: unwinding starts immediately
The overwhelmingly common case is validating arguments and state at the top of a method, so a bad call fails at the boundary rather than corrupting data deeper in:
public class BankAccount {
private BigDecimal balance;
private boolean closed;
public void withdraw(BigDecimal amount) {
if (amount == null) {
throw new NullPointerException("amount must not be null");
}
if (amount.signum() <= 0) { // bad ARGUMENT
throw new IllegalArgumentException("amount must be positive: " + amount);
}
if (closed) { // bad STATE
throw new IllegalStateException("account is closed");
}
if (amount.compareTo(balance) > 0) { // domain rule
throw new InsufficientFundsException(balance, amount);
}
balance = balance.subtract(amount); // only now, mutate
}
}
Note the ordering: every check happens before any field is modified, so a rejected call leaves the object exactly as it was. That property is called failure atomicity and it is what makes exceptions safe to recover from.
Choosing the right standard type is most of the skill:
| Situation | Throw |
|---|---|
| An argument is invalid | IllegalArgumentException |
| An argument is null and must not be | NullPointerException |
| The object is in the wrong state for this call | IllegalStateException |
| An index is out of range | IndexOutOfBoundsException |
| The operation is not supported at all | UnsupportedOperationException |
| A domain rule was violated | A custom exception (see 5.4) |
The java.util.Objects helpers make null and range checks one-liners that
throw the right type with a sensible message:
this.name = Objects.requireNonNull(name, "name"); // NPE with message
Objects.checkIndex(index, size); // IndexOutOfBounds
this.age = Objects.requireNonNullElse(age, 0); // with a fallback
Two habits separate useful exceptions from useless ones. Put the offending
value in the message, not just the rule: "amount must be positive: -5"
tells you what happened, "invalid amount" does not. And throw as early as
possible, because the stack trace then points at the caller who made the
mistake rather than at some distant consequence.
Key Takeaways
throwraises an exception instance and starts unwinding immediately.- Validate arguments and state before mutating anything, preserving failure atomicity.
- Use
IllegalArgumentExceptionfor bad arguments andIllegalStateExceptionfor a bad receiver state.- Include the offending value in the message, not just the violated rule.
Objects.requireNonNullandObjects.checkIndexthrow the conventional types with good messages.
🧪 Practice
- Add argument validation to a
Rectangleconstructor that rejects non-positive dimensions with informative messages.- Write a
Stackclass whosepop()throwsIllegalStateExceptionwhen empty, and test both paths.- Interview: When would you choose
IllegalStateExceptionoverIllegalArgumentException? (Hint: is the problem with what was passed in, or with the object being called?)
Rethrowing and Exception Chaining
A layer often needs to react to a failure without being the right place to resolve it: log it, add context, convert it into the vocabulary of its own abstraction, then pass it on. Java offers three ways to do that, and choosing wrongly destroys diagnostic information.
Rethrow the same exception when you only wanted a side effect:
try {
repository.save(order);
} catch (SQLException e) {
metrics.increment("save.failure"); // react
throw e; // then let it continue upward
}
Wrap it in a new exception when the caller should not know about the lower layer's types. The original must be passed as the cause, which is what chaining means:
try {
repository.save(order);
} catch (SQLException e) {
throw new OrderPersistenceException("could not save order " + order.id(), e);
// ^
// the cause: keeps the entire original trace attached
}
The two-argument constructor is the standard shape, inherited from
Throwable(String, Throwable). Without it, the trace stops at your wrapper and
the real reason is gone forever:
// Destroys the evidence. Never do this.
catch (SQLException e) {
throw new OrderPersistenceException("could not save order"); // cause lost
}
A chained trace prints both halves, root cause last:
OrderPersistenceException: could not save order 1042
at com.example.OrderService.save(OrderService.java:31)
at com.example.Api.handle(Api.java:18)
Caused by: java.sql.SQLException: connection refused
at com.example.Repository.save(Repository.java:55)
... 2 more
For exceptions you write yourself, provide the chaining constructors so callers can use them:
public class OrderPersistenceException extends RuntimeException {
public OrderPersistenceException(String message) { super(message); }
public OrderPersistenceException(String message, Throwable cause) { super(message, cause); }
}
If a class was written without them, initCause retrofits the link once:
SomeLegacyException e = new SomeLegacyException("failed");
e.initCause(original); // allowed exactly once, and only if cause is null
throw e;
A final refinement: since Java 7, precise rethrow lets a broad catch declare the narrow types actually possible, so the method signature stays honest.
public void run() throws IOException, SQLException { // not "throws Exception"
try {
doWork(); // declares IOException and SQLException
} catch (Exception e) { // broad catch...
log.error("failed", e);
throw e; // ...but the compiler knows the real set
}
}
Key Takeaways
- Rethrow the same exception when you only needed a side effect such as logging or metrics.
- Wrap when the caller should not depend on a lower layer's exception types.
- Always pass the original as the cause; a wrapper without a cause deletes the real trace.
- Give custom exceptions both a message constructor and a message-plus-cause constructor.
- Precise rethrow lets a
catch (Exception e)block still declare only the specific checked types possible.
🧪 Practice
- Catch a
NumberFormatExceptionand rethrow it wrapped in a custom exception with the cause attached; print the full trace.- Repeat without passing the cause and compare what the trace tells you.
- Interview: Why is a wrapper exception without a cause considered harmful? (Hint: what does the person debugging at 3am lose?)
<a id="53-resource-management"></a>
5.3 Resource Management
Exceptions interact badly with manual cleanup, so Java provides a construct that closes resources automatically and correctly.
try-with-resources Syntax
A resource is anything holding an operating-system handle: a file, a socket, a database connection. The operating system grants a limited number of these, so failing to release one is a slow leak that eventually takes the process down.
Done by hand, correct cleanup is surprisingly hard. Here is the fully correct pre-Java 7 version for a single resource:
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("data.txt"));
return reader.readLine();
} finally {
if (reader != null) { // the constructor may have thrown
try {
reader.close(); // close itself throws IOException
} catch (IOException ignored) {
// and an exception here would mask the real one
}
}
}
Six lines of ceremony for one resource, and it gets worse with two. try-with-
resources reduces it to a declaration in the try header:
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
return reader.readLine();
} // close() is called automatically, always
Any type implementing AutoCloseable, introduced in 4.4, may be declared
there. Several resources are separated by semicolons, and they close in
reverse order, which matches how dependencies are usually layered:
try (FileInputStream in = new FileInputStream("in.txt");
FileOutputStream out = new FileOutputStream("out.txt")) { // opened second
in.transferTo(out);
} // out closes FIRST, then in
The construct still accepts catch and finally, and the closing happens
before either runs, so a handler sees an already-released resource:
try (Connection conn = dataSource.getConnection()) {
conn.execute("UPDATE accounts SET balance = 0");
} catch (SQLException e) { // conn is already closed by this point
log.error("update failed", e);
} finally {
log.info("attempt complete");
}
enter try -> open A -> open B -> run body
|
+-------------------+------------------+
| normal exit exception |
v v
close B, close A close B, close A
| |
v v
finally catch, then finally
Resources must be final or effectively final. Since Java 9 an existing variable can be listed directly, which is useful when the resource was created elsewhere:
BufferedReader reader = openReader(); // effectively final
try (reader) { // Java 9+: no redundant declaration
process(reader);
}
One caveat: a resource that is null at the point of closing is skipped
silently, but a null in the header does not cause an error, so guard where
the value may legitimately be absent.
Key Takeaways
- try-with-resources closes every declared resource automatically, on both normal and exceptional exit.
- Resources must implement
AutoCloseableand are declared in thetryheader, separated by semicolons.- They close in reverse declaration order, before any
catchorfinallyruns.- A resource variable must be final or effectively final; Java 9 allows naming an existing one.
- It replaces the error-prone null-check-and-nested-try
finallyidiom entirely.
🧪 Practice
- Rewrite a manual
finally-based file read as try-with-resources.- Open two resources in one header, throw inside the body, and print messages proving the closing order.
- Interview: Why does try-with-resources close resources before running the
catchblock? (Hint: what state should the handler be able to assume?)
Implementing AutoCloseable
Your own classes can participate. Implementing AutoCloseable is what makes a
type usable in a try-with-resources header, and it is worth doing for anything
that acquires something needing release: a lock, a connection, a temporary
file, even a timer.
public interface AutoCloseable {
void close() throws Exception; // the only method
}
The single method is all the compiler needs. Declaring a narrower exception, or
none at all, is a kindness to callers, since the try statement propagates
whatever close declares:
public class ManagedConnection implements AutoCloseable {
private final String endpoint;
private boolean open = true;
public ManagedConnection(String endpoint) {
this.endpoint = endpoint;
System.out.println("opened " + endpoint);
}
public void send(String data) {
if (!open) throw new IllegalStateException("connection is closed");
System.out.println(endpoint + " <- " + data);
}
@Override
public void close() { // narrowed: throws nothing at all
if (!open) return; // IDEMPOTENT: safe to call twice
open = false;
System.out.println("closed " + endpoint);
}
}
try (ManagedConnection conn = new ManagedConnection("db-1")) {
conn.send("SELECT 1");
}
// opened db-1 / db-1 <- SELECT 1 / closed db-1
Three obligations come with the interface, and the first is the one most often missed:
- Be idempotent.
close()may be called more than once, so a second call must be harmless. Theopenguard above is the standard shape. - Avoid throwing. An exception from
closecompetes with whatever the body was already throwing. If cleanup can genuinely fail, consider logging instead of propagating. - Release everything, even on failure. If
closereleases several things, a failure releasing the first must not skip the rest.
@Override
public void close() {
try {
flushBuffers();
} finally {
releaseHandle(); // runs even if flushBuffers() threw
}
}
java.io.Closeable is the older, narrower relative: it extends AutoCloseable
and restricts the exception to IOException. Implement Closeable for I/O
types and AutoCloseable for everything else.
| Interface | Package | close() throws | Idempotence |
|---|---|---|---|
AutoCloseable | java.lang | Exception | Strongly recommended |
Closeable | java.io | IOException | Required by contract |
A pattern worth knowing is the scoped action, where close performs the
end of an operation rather than releasing a handle:
public class Timed implements AutoCloseable {
private final String label;
private final long start = System.nanoTime();
public Timed(String label) { this.label = label; }
@Override
public void close() {
System.out.printf("%s took %.2f ms%n", label,
(System.nanoTime() - start) / 1_000_000.0);
}
}
try (Timed t = new Timed("import")) { // the timer stops automatically
runImport();
}
Key Takeaways
- Implementing
AutoCloseablemakes any class usable in try-with-resources.- Make
close()idempotent, because it may be invoked more than once.- Narrow or remove the declared exception so callers are not forced into broad handling.
- Release every held resource in
close, usingfinallyif the cleanup itself can fail.Closeableis the I/O-specific subtype restricted toIOException.
🧪 Practice
- Write a
TempFileclass that deletes its file inclose()and use it in try-with-resources.- Call
close()twice on your class directly and make sure nothing breaks.- Interview: Why should
close()declare a narrower exception thanException? (Hint: what is every caller of your try block forced to write otherwise?)
Suppressed Exceptions
Two failures can happen in one try statement: the body throws, and then
close() throws while cleaning up. Only one exception can propagate, so
something must give way. The question is which.
The hand-written finally idiom gets this exactly wrong. An exception from
close replaces the original, and the real failure disappears:
BufferedReader reader = new BufferedReader(new FileReader("data.txt"));
try {
throw new IllegalStateException("the REAL problem");
} finally {
reader.close(); // if this throws, the IllegalStateException is LOST
}
try-with-resources inverts the priority. The body's exception wins and
propagates, while the exception from close is attached to it as a
suppressed exception rather than discarded:
public class Failing implements AutoCloseable {
@Override
public void close() { throw new IllegalStateException("close failed"); }
}
try (Failing f = new Failing()) {
throw new RuntimeException("body failed");
} catch (RuntimeException e) {
System.out.println("primary: " + e.getMessage()); // body failed
for (Throwable s : e.getSuppressed()) { // the others
System.out.println("suppressed: " + s.getMessage()); // close failed
}
}
Printed, the trace shows both:
Exception in thread "main" java.lang.RuntimeException: body failed
at com.example.Demo.main(Demo.java:5)
Suppressed: java.lang.IllegalStateException: close failed
at com.example.Failing.close(Failing.java:3)
at com.example.Demo.main(Demo.java:4)
The priority ordering is worth stating plainly:
| Body throws | close() throws | Propagates | Suppressed |
|---|---|---|---|
| No | No | Nothing | Nothing |
| Yes | No | Body exception | Nothing |
| No | Yes | Close exception | Nothing |
| Yes | Yes | Body exception | The close exception |
With several resources, every failing close is suppressed onto the same
primary exception, so getSuppressed() can return more than one element.
You can record a suppressed exception manually with addSuppressed, which is
occasionally useful when writing cleanup logic by hand:
RuntimeException primary = new RuntimeException("primary failure");
primary.addSuppressed(new IllegalStateException("cleanup also failed"));
throw primary;
The practical lesson: when debugging, read the Suppressed: lines. They often
explain why the primary failure happened, particularly with connections and
transactions.
Key Takeaways
- When both the body and
close()throw, the body's exception propagates.- The exception from
closeis attached as a suppressed exception, not discarded.- Retrieve them with
getSuppressed(), which returns an array because several resources may fail.- A hand-written
finallyloses the original exception instead; this is the main reason to prefer try-with-resources.addSuppressedlets you record secondary failures in your own cleanup code.
🧪 Practice
- Write a resource whose
close()throws, throw from the body too, and print both the primary and the suppressed exception.- Do the same with two failing resources and count the suppressed entries.
- Interview: Why does the body's exception take priority over the one from
close()? (Hint: which of the two tells you what the program was actually trying to do?)
Resource Leaks and Cleanup Pitfalls
A resource leak is not dramatic. Nothing fails at the moment of the leak; the
program simply uses one more handle than it returns, and the failure appears
hours later as Too many open files or an exhausted connection pool, far from
the code responsible.
The pitfalls below cause most of them.
Forgetting to close at all. The oldest bug, and the reason try-with-resources exists.
// Leak: nothing closes this reader, especially if readLine() throws.
BufferedReader reader = new BufferedReader(new FileReader(path));
String line = reader.readLine();
Closing inside try instead of finally. If the body throws, the close
line is skipped by unwinding.
// Leak on any exception before close().
Connection conn = dataSource.getConnection();
try {
conn.execute(sql); // if this throws...
conn.close(); // ...this never runs
} catch (SQLException e) { log.error("failed", e); }
Nested constructors in one resource declaration. This one is subtle and catches experienced developers. If the outer constructor throws, the inner resource was already created and is now unreachable:
// If the BufferedReader constructor fails, the FileReader leaks.
try (BufferedReader r = new BufferedReader(new FileReader(path))) { ... }
// Safe: each resource is declared separately, so each is tracked.
try (FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr)) { ... }
In practice the wrapping case is low risk, because BufferedReader's
constructor does little, but the separate-declaration form is free insurance
and is the habit worth having.
Unclosed streams from the NIO API. Files.lines, Files.list, and
Files.walk all return streams backed by an open file handle. Unlike most
streams, these must be closed.
// Leak: the underlying file handle stays open.
Files.lines(path).forEach(System.out::println);
// Correct: the stream is a resource.
try (Stream<String> lines = Files.lines(path)) {
lines.forEach(System.out::println);
}
Loops that open without closing. A leak inside a loop exhausts handles quickly, so put the resource inside the loop body:
for (Path path : paths) {
try (BufferedReader reader = Files.newBufferedReader(path)) { // per file
process(reader);
}
}
| Pitfall | Fix |
|---|---|
| No cleanup at all | try-with-resources |
close() inside the try body | Move it to the resource header |
| Nested constructors in one header | Declare each resource separately |
Files.lines / walk / list | Treat the returned stream as a resource |
| Opening inside a loop | Put the try-with-resources inside the loop |
| Returning an open resource | Document it and make the caller close it |
A last note on finalize(): it was the old escape hatch for forgotten cleanup,
it never ran reliably, and it is deprecated for removal. Never use it as a
safety net. If a class holds a resource, the contract is that the caller closes
it, and try-with-resources is how they do so.
Key Takeaways
- Leaks surface far from their cause, as exhausted file handles or connection pools.
- Any resource acquired outside a try-with-resources header is a leak waiting for an exception.
- Declare wrapped resources separately so each one is tracked independently.
- Streams from
Files.lines,Files.list, andFiles.walkhold file handles and must be closed.- Never rely on
finalize(); it is unreliable and deprecated for removal.
🧪 Practice
- Find the leak in a method that calls
close()as the last statement of itstryblock, and fix it.- Rewrite
Files.lines(path).count()so the file handle is released.- Interview: Why is a resource leak harder to diagnose than a
NullPointerException? (Hint: compare where the symptom appears to where the cause is.)
<a id="54-designing-exception-strategies"></a>
5.4 Designing Exception Strategies
Syntax is the easy half. This subchapter is about the decisions that make exceptions useful to the people who call your code and to the people who debug it later.
Creating Custom Exception Classes
The standard exceptions cover generic problems: a bad argument, a bad index, a
bad state. They say nothing about your domain. When a transfer fails because
the balance is too low, IllegalArgumentException loses the two facts a caller
needs, which are how much was available and how much was requested.
A custom exception is an ordinary class that extends an existing throwable, carries domain-specific fields, and can be caught by name.
public class InsufficientFundsException extends RuntimeException {
private final BigDecimal available; // domain data, not just a string
private final BigDecimal requested;
public InsufficientFundsException(BigDecimal available, BigDecimal requested) {
super(String.format("insufficient funds: available %s, requested %s",
available, requested)); // a useful default message
this.available = available;
this.requested = requested;
}
public BigDecimal available() { return available; }
public BigDecimal requested() { return requested; }
public BigDecimal shortfall() { return requested.subtract(available); }
}
The payoff is at the call site, where the handler can act on the data rather than parse a message:
try {
account.withdraw(amount);
} catch (InsufficientFundsException e) {
offerOverdraft(e.shortfall()); // precise, typed recovery
}
Choosing the parent class is the first decision. Extend RuntimeException for
unchecked and Exception for checked, following the guidance in the next
topic. Do not extend Error, and do not extend Throwable directly.
public class ConfigurationException extends Exception { // checked
public ConfigurationException(String message) { super(message); }
public ConfigurationException(String message, Throwable cause) { super(message, cause); }
}
Provide the conventional constructors. At minimum, a message constructor and a message-plus-cause constructor, so callers can chain properly as described in 5.2:
| Constructor | Why it exists |
|---|---|
(String message) | The common case |
(String message, Throwable cause) | Wrapping a lower-level failure |
(Throwable cause) | Rare, when the cause's message suffices |
| Domain-specific parameters | Carries structured data for handlers |
A few conventions keep a codebase coherent:
- Name the class for the condition and end it with
Exception:OrderNotFoundException, notOrderErrororBadOrder. - Make it
Serializable-safe by keeping fields simple, and declareserialVersionUIDif the class will cross a boundary. - Consider a small hierarchy with a shared base, so callers can catch broadly or narrowly as they prefer.
public abstract class BankingException extends RuntimeException { ... }
public class InsufficientFundsException extends BankingException { ... }
public class AccountFrozenException extends BankingException { ... }
// Callers choose their granularity:
catch (InsufficientFundsException e) { ... } // one specific case
catch (BankingException e) { ... } // anything from this domain
Do not create a custom exception where a standard one says exactly the same
thing. A class called NullParameterException adds nothing over
NullPointerException and forces every reader to look it up.
Key Takeaways
- Create a custom exception when the domain condition carries information the standard types cannot express.
- Extend
RuntimeExceptionfor unchecked andExceptionfor checked; neverErrororThrowable.- Always supply message and message-plus-cause constructors.
- Carry structured fields so handlers can act on data instead of parsing messages.
- A shared base class lets callers catch a whole domain or a single case.
🧪 Practice
- Write an
OrderNotFoundExceptioncarrying the missing order id, and throw it from a lookup method.- Give it both conventional constructors and wrap a
SQLExceptionin it.- Interview: When is a custom exception unnecessary? (Hint: compare it against what
IllegalArgumentExceptionalready communicates.)
Choosing Checked or Unchecked for an API
This is the most consequential exception decision you make, because it appears in your method signatures and cannot be changed later without breaking every caller.
The original design intent is clear enough. Checked means "a well-written caller can and should recover"; unchecked means "this is a bug, fix the code". Applied literally:
// Checked: the caller can plausibly retry, use a default, or prompt.
public Config load(Path path) throws ConfigurationException { ... }
// Unchecked: passing null is a programming error, not a runtime condition.
public void register(User user) {
Objects.requireNonNull(user, "user");
}
Experience has complicated this. Checked exceptions have three real costs:
- They leak through abstractions. Adding a checked exception to an interface method breaks every implementation and every caller.
- They do not compose with lambdas. None of the standard functional interfaces declare checked exceptions, so streams and checked exceptions mix badly.
- They invite swallowing. Faced with a forced
catch, tired developers write an empty block, which is worse than no checking at all.
// The lambda problem: this does not compile.
List<String> contents = paths.stream()
.map(p -> Files.readString(p)) // ERROR: unhandled IOException
.toList();
// The workaround everyone ends up writing:
List<String> contents = paths.stream()
.map(p -> {
try {
return Files.readString(p);
} catch (IOException e) {
throw new UncheckedIOException(e); // the JDK's own escape hatch
}
})
.toList();
UncheckedIOException exists in the JDK precisely because the library authors
hit this wall themselves, which is a strong signal about how the trade-off has
aged.
A decision procedure that works in practice:
Can the caller do something OTHER than log and rethrow?
no -> UNCHECKED
yes v
Is the condition caused by a programming mistake?
yes -> UNCHECKED
no v
Will this method be used from a lambda or stream?
yes -> UNCHECKED (or provide both variants)
no -> CHECKED is defensible
| Consideration | Favours checked | Favours unchecked |
|---|---|---|
| Caller has a real recovery | Yes | |
| Condition is a programming bug | Yes | |
| Used from streams and lambdas | Yes | |
| Public library API | Sometimes | Sometimes |
| Internal application code | Usually | |
| Failure is expected and frequent | Yes |
Modern practice has shifted noticeably toward unchecked. Spring, Hibernate, and
most contemporary frameworks expose unchecked exceptions almost exclusively,
translating the checked ones from lower layers. The JDK itself cannot change,
so IOException and SQLException remain checked and you will handle them
often.
Whichever you choose, document it. An unchecked exception is invisible in
the signature, so the Javadoc @throws tag is the only place a caller learns
about it:
/**
* Withdraws the given amount.
*
* @throws IllegalArgumentException if amount is not positive
* @throws InsufficientFundsException if the balance is too low
*/
public void withdraw(BigDecimal amount) { ... }
Key Takeaways
- Checked means the caller has a realistic recovery; unchecked means the code needs fixing.
- Checked exceptions leak through abstraction layers and do not work with lambdas or streams.
UncheckedIOExceptionexists because the JDK hit this problem in its own stream API.- Modern frameworks favour unchecked exceptions with translation at layer boundaries.
- Document unchecked exceptions with
@throws, since the signature does not reveal them.
🧪 Practice
- Write the same repository method twice, once throwing a checked exception and once unchecked, and compare the caller code.
- Try calling a method that throws
IOExceptionfrom inside astream.mapand resolve the compiler error two different ways.- Interview: Why do modern frameworks prefer unchecked exceptions? (Hint: consider what happens to an interface when one implementation needs a new checked exception.)
Exception Translation Across Layers
A layered application has a persistence layer, a service layer, and a web
layer. If the web layer catches SQLException, the layering is a fiction: the
top of the application now depends on the database technology, and swapping to
a document store breaks code three layers away.
Exception translation is the discipline of converting an exception into the vocabulary of the layer that is propagating it, keeping the original as the cause.
Web layer catches OrderNotFoundException -> HTTP 404
^
| throws OrderNotFoundException (domain vocabulary)
Service layer catches DataAccessException
^
| throws DataAccessException (persistence vocabulary)
Repository layer catches SQLException
^
| throws SQLException (JDBC vocabulary)
JDBC driver
// Repository: translates the technology exception into a persistence one.
public class JdbcOrderRepository implements OrderRepository {
@Override
public Optional<Order> findById(long id) {
try {
return Optional.ofNullable(jdbc.queryForObject(SQL, mapper, id));
} catch (SQLException e) {
throw new DataAccessException("query failed for order " + id, e);
// ^ cause
}
}
}
// Service: translates persistence failure into domain vocabulary.
public class OrderService {
public Order require(long id) {
return repository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// Web: translates domain failure into a protocol response.
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<Problem> handle(OrderNotFoundException e) {
return ResponseEntity.status(404).body(new Problem(e.getMessage()));
}
Each layer's callers now depend only on types they can reason about, and the full chain of causes is still in the trace for whoever debugs it.
| Layer | Speaks the language of | Typical exception |
|---|---|---|
| Driver | The protocol | SQLException, IOException |
| Repository | Persistence | DataAccessException |
| Service | The business domain | OrderNotFoundException |
| Web or UI | The transport | An HTTP status or error view |
Two refinements are worth knowing. Exception translation is not always wrapping. Sometimes the right translation discards the original because it carries no useful information, though this should be deliberate and rare, since you lose the trace. And do not translate at every single call; translate at the layer boundary, which is usually where an interface is defined.
The common mistake is translating without a cause, which produces a clean architecture and an undebuggable one:
catch (SQLException e) {
throw new DataAccessException("query failed"); // cause dropped: don't
}
Key Takeaways
- Translate exceptions at layer boundaries so callers depend only on vocabulary they understand.
- Always pass the original exception as the cause to preserve the full trace.
- Translation keeps persistence and transport details from leaking into business logic.
- Translate at interface boundaries, not at every call site.
- Spring's
DataAccessExceptionhierarchy is a large-scale example of this pattern.
🧪 Practice
- Write a repository method that catches
SQLExceptionand rethrows a domain exception with the cause attached.- Print the resulting trace and identify both the wrapper and the root cause.
- Interview: What breaks if a web controller catches
SQLExceptiondirectly? (Hint: what happens when the team replaces the database?)
Fail-Fast Validation and Preconditions
A bug is cheapest to find at the moment it is introduced. A method that accepts a bad argument and stores it will fail later, somewhere else, with a stack trace pointing at innocent code. Fail-fast means detecting the problem at the boundary and throwing immediately, so the trace names the actual culprit.
Compare the two:
// Fails late: the null is stored and explodes somewhere unrelated.
public class Order {
private Customer customer;
public Order(Customer customer) { this.customer = customer; }
public String label() { return customer.name(); } // NPE here, hours later
}
// Fails fast: the trace points at whoever passed null.
public class Order {
private final Customer customer;
public Order(Customer customer) {
this.customer = Objects.requireNonNull(customer, "customer");
}
}
Preconditions are the conditions a caller must satisfy. Check them first, before any state changes, which preserves the failure atomicity introduced in 5.2:
public void schedule(Task task, Instant when, int priority) {
Objects.requireNonNull(task, "task");
Objects.requireNonNull(when, "when");
if (when.isBefore(Instant.now())) {
throw new IllegalArgumentException("when must be in the future: " + when);
}
if (priority < 1 || priority > 10) {
throw new IllegalArgumentException("priority must be 1..10: " + priority);
}
if (shutdown) {
throw new IllegalStateException("scheduler is shut down");
}
queue.add(new ScheduledTask(task, when, priority)); // only now, mutate
}
The java.util.Objects helpers cover the routine cases and throw the
conventional types:
Objects.requireNonNull(value, "value"); // NullPointerException
Objects.requireNonNullElse(value, fallback); // substitute a default
Objects.checkIndex(index, length); // IndexOutOfBounds
Objects.checkFromToIndex(from, to, length); // range checks
Validation belongs in constructors too, because an object that cannot be created in an invalid state can never be found in one:
public record Percentage(int value) {
public Percentage { // compact constructor
if (value < 0 || value > 100) {
throw new IllegalArgumentException("must be 0..100: " + value);
}
}
}
A word on assert. Java's assert statement is disabled at runtime unless the
JVM is started with -ea, so it is a development-time check for internal
invariants, never a substitute for validating public input:
public void process(int[] sorted) {
assert isSorted(sorted) : "input must be sorted"; // internal invariant only
}
| Check type | Use | Throws |
|---|---|---|
| Public argument check | Always, in public methods | IllegalArgumentException / NullPointerException |
| Receiver state check | Before operations needing a state | IllegalStateException |
| Internal invariant | Private methods, development only | assert |
| External input | Parsing, deserialisation, requests | A domain or validation exception |
Key Takeaways
- Validate at the boundary so the stack trace names the caller that made the mistake.
- Check every precondition before mutating any state, preserving failure atomicity.
- Use
Objects.requireNonNullandObjects.checkIndexfor the conventional types and messages.- Validate in constructors so invalid objects cannot exist.
assertis for internal invariants only, because it is disabled by default.
🧪 Practice
- Add fail-fast validation to a
DateRangeclass rejecting an end before its start.- Write a method that mutates a field before validating, then reorder it and explain what improved.
- Interview: Why is
assertunsuitable for validating public method arguments? (Hint: what does the JVM do with assertions by default?)
Anti-Patterns: Swallowing and Overbroad Catches
Most exception damage comes from a handful of habits that look harmless in a code review and are devastating in production. Each one below removes information that someone will later need.
Swallowing. An empty catch block discards the failure entirely. The program continues in an unknown state, and the evidence is gone.
try {
saveToDatabase(order);
} catch (SQLException e) {
// nothing here
}
// Execution continues as if the save succeeded. It did not.
If an exception really is expected and ignorable, say so explicitly, name the
variable ignored, and comment why:
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); // restore the flag, then continue
}
Logging and continuing. Subtler than swallowing, and more common. The log line makes it look handled, but the caller still proceeds on the assumption the operation succeeded.
try {
saveToDatabase(order);
} catch (SQLException e) {
log.error("save failed", e); // logged, but the caller is not told
} // -> rethrow, or return a result
Logging and rethrowing. This one produces duplicate stack traces, one per layer, and makes logs unreadable. Log at the boundary where the exception is finally handled, not on the way past.
catch (SQLException e) {
log.error("failed", e); // logged here...
throw new DataAccessException("failed", e); // ...and again at the top
}
Catching too broadly. catch (Exception e) deep in business logic swallows
NullPointerException and every other bug along with the condition you meant
to handle.
try {
return Integer.parseInt(input);
} catch (Exception e) { // also catches NPE, and anything else
return 0;
}
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) { // only the condition you understand
return 0;
}
Using exceptions for control flow. Exceptions are expensive to construct, because filling in the stack trace walks the stack, and they obscure intent.
// Abuse: an exception as a loop terminator.
try {
int i = 0;
while (true) { process(array[i++]); }
} catch (ArrayIndexOutOfBoundsException e) { /* done */ }
// Correct: a condition.
for (int value : array) { process(value); }
Catching NullPointerException. Almost always a sign that a null check
belongs somewhere earlier. Catching it hides the missing validation.
| Anti-pattern | Why it hurts | Instead |
|---|---|---|
| Empty catch | The failure disappears entirely | Handle, rethrow, or document why |
| Log and continue | The caller acts on false success | Rethrow or return a failure result |
| Log at every layer | Duplicate traces, unreadable logs | Log once, at the boundary |
catch (Exception e) | Swallows unrelated bugs | Catch the specific type |
catch (Throwable t) | Catches OutOfMemoryError too | Never do it |
| Exceptions as control flow | Slow and unclear | Use a conditional |
Catching NullPointerException | Hides a missing check | Validate earlier |
Key Takeaways
- An empty catch block destroys the only evidence of a failure; never leave one unexplained.
- Logging without rethrowing lets the caller proceed as if the operation succeeded.
- Log an exception once, at the boundary where it is genuinely handled.
- Catch the narrowest type you actually understand, and never
Throwable.- Exceptions are for exceptional conditions, not for terminating loops or branching.
🧪 Practice
- Find and fix an empty catch block, choosing between handling, rethrowing, and documenting.
- Rewrite an
ArrayIndexOutOfBoundsException-terminated loop as a normal loop and measure the difference in a tight benchmark.- Interview: Why is logging and rethrowing at every layer a problem? (Hint: count how many times one failure appears in the log file.)
Optional as an Alternative to Null Returns
Not every absence is exceptional. A lookup that finds nothing is an ordinary
outcome, and throwing for it is both slow and misleading. Yet returning null
pushes the problem to the caller, who has no reminder to check.
// The caller cannot tell from the signature that null is possible.
public User findByEmail(String email) {
return database.query(email); // may return null
}
User user = findByEmail("a@b.com");
System.out.println(user.name()); // NullPointerException, eventually
Optional<T> makes the absence part of the type. A method returning
Optional<User> states in its signature that there may be no user, and the
caller cannot reach the value without acknowledging that.
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(database.query(email)); // wraps a possible null
}
Optional<User> found = findByEmail("a@b.com");
String name = found.map(User::name) // transform if present
.orElse("unknown"); // otherwise a default
found.ifPresent(user -> sendWelcome(user)); // act only if present
User required = findByEmail("a@b.com")
.orElseThrow(() -> new UserNotFoundException("a@b.com")); // demand it
The core methods, which chapter 8 covers in full:
| Method | Behaviour |
|---|---|
Optional.of(value) | Wraps a non-null value; throws if null |
Optional.ofNullable(v) | Wraps a value that may be null |
Optional.empty() | The explicitly empty instance |
isPresent() / isEmpty() | Tests, though usually there is a better option |
orElse(default) | The value, or a fallback |
orElseGet(supplier) | The value, or a lazily computed fallback |
orElseThrow(supplier) | The value, or throw the supplied exception |
map / flatMap / filter | Transform without unwrapping |
ifPresent(consumer) | Run an action only when present |
Choosing between the three strategies is the real lesson:
| Situation | Return |
|---|---|
| Absence is normal and the caller decides | Optional<T> |
| Absence is a violated expectation | Throw |
| Absence has an obvious neutral value | Empty list, empty string, zero |
Optional has clear boundaries, and misusing it is a common overcorrection:
// Do NOT use Optional for fields: it is not Serializable and adds allocation.
private Optional<String> nickname; // wrong
// Do NOT use it for parameters: callers must wrap, and null is still possible.
public void update(Optional<String> name); // wrong
// Do NOT return Optional of a collection: return an empty collection instead.
public Optional<List<Order>> orders(); // wrong
public List<Order> orders(); // right, possibly empty
// Do NOT unwrap blindly: this is the NullPointerException you were avoiding.
String name = findByEmail(email).get(); // NoSuchElementException if empty
Optional is designed as a return type, and specifically for the case where a caller must be reminded that nothing may come back.
Key Takeaways
Optional<T>makes possible absence visible in the method signature.- Use it as a return type, not for fields, parameters, or collections.
- Prefer
map,orElse,orElseGet, andifPresenttoisPresentplusget.- Return an empty collection rather than an
Optionalwrapping one.- Throw when absence violates an expectation, and return
Optionalwhen it is a normal outcome.
🧪 Practice
- Convert a lookup method returning
nullinto one returningOptionaland update its callers.- Chain
map,filter, andorElseto get a user's uppercase city name or the string"UNKNOWN".- Interview: Why should
Optionalnot be used for method parameters? (Hint: how many states can anOptionalparameter be in, and how many did you want?)
<a id="6-generics"></a>