Java Exception Handling Best Practices
Practical rules for exception handling in Java — fail fast, throw the right type, never swallow exceptions, and log usefully.
Java Exception Handling Best Practices
The previous chapters covered the mechanics — try, catch, finally, throw, throws, custom classes. This one is the judgment side. Two programs can use the same constructs and one is robust while the other is brittle. The difference is a small set of habits that turn into reflex with practice.
Fail fast on bad input
If a method is called with arguments it can't honor, throw immediately:
public void send(String to, String body) {
if (to == null || to.isBlank()) {
throw new IllegalArgumentException("to must be non-blank");
}
if (body == null) {
throw new NullPointerException("body");
}
// ...real work
}Don't try to "do the best you can" with null. The bug is in the caller, and the closer the exception lands to it, the easier it is to fix. Objects.requireNonNull(body, "body") is the standard one-liner for the null case.
The opposite habit — silently substituting defaults for missing input — leads to errors that surface five layers away with no clue about who passed what.
Throw the most specific type that fits
Exception is rarely the right thing to throw, and RuntimeException only when no more specific type fits. The standard library gives you a vocabulary — use it:
- Bad argument →
IllegalArgumentException - Null argument →
NullPointerException(Objects.requireNonNull) - Wrong state →
IllegalStateException - Operation not supported →
UnsupportedOperationException - Index out of range →
IndexOutOfBoundsException - Number out of range →
ArithmeticExceptionorIllegalArgumentException
For domain failures, write a custom exception instead of reusing a built-in.
Catch the most specific type you have a plan for
The symmetric rule. catch (Exception e) is a smell. It catches programming bugs (NullPointerException, IllegalStateException) and recoverable failures (IOException) and unknown library exceptions all in one bucket — and almost always handles them identically, which is almost always wrong.
// Bad — what does this even handle?
try { complex(); }
catch (Exception e) { log("failed"); }
// Better — specific cases get specific responses
try { complex(); }
catch (IOException e) { retryLater(); }
catch (ParseException e) { recordCorruptInput(e); }When you genuinely don't know what to do with a class of exception, the answer is don't catch it. Let it propagate to a handler that does.
Never swallow an exception silently
The single worst exception-handling pattern:
try { doWork(); }
catch (Exception e) { } // never write thisWhen the inevitable production bug hits, there's no stack trace, no message, no log entry — the failure simply vanished. If you genuinely intend to ignore a failure (rare, but possible — e.g. closing a resource on a cleanup path), say so explicitly:
try { connection.close(); }
catch (IOException ignored) {
// close-time failure on a cleanup path; original cause already propagating
}The variable name ignored and the comment make the intent visible to the next reader.
Log usefully, log once
Two failures of logging are common:
- Logging without enough context —
log.error("failed")tells you nothing. - Logging then rethrowing — every layer logs the same exception, and the same trace ends up in the log five times.
Pick a layer that knows the most context (usually high-level: request handler, job runner) and log there with the input that triggered the failure. Layers below it should focus on translating the exception, not logging it.
try {
userService.activate(id);
} catch (UserNotFoundException e) {
log.warn("activation failed: no user with id={}", id, e); // include the exception object as the last arg
return Response.notFound();
}Passing the exception as the last logger argument is the SLF4J convention — it ensures the full stack trace and any cause chain end up in the output.
Preserve the cause when wrapping
When you translate an exception up a layer, always pass the original as the cause:
// Good — cause is preserved
catch (IOException e) {
throw new ConfigLoadException("failed to load " + path, e);
}
// Bad — original IOException is lost
catch (IOException e) {
throw new ConfigLoadException("failed to load " + path);
}The Caused by: chain in the resulting stack trace is what lets the on-call engineer trace a domain exception back to the bytes-level failure. Lose it and a half-hour debugging session becomes a half-day.
Don't use exceptions for control flow
Throwing is expensive — building a stack trace at construction is the biggest cost. More importantly, it muddies intent. A loop that uses try/catch (NoSuchElementException) to know when to stop is hiding what it's doing:
// Bad
try {
while (true) {
process(iter.next());
}
} catch (NoSuchElementException end) { }
// Good
while (iter.hasNext()) {
process(iter.next());
}When "not found" is an ordinary outcome, return Optional<T> or a boolean. Save exceptions for the actually-exceptional.
Keep finally and try-with-resources for cleanup
finally should release things. try-with-resources should be the default for anything AutoCloseable. Don't put business logic in finally — it runs in both success and failure paths and can't tell the difference. And don't return from finally — it silently discards the original exception or return value, which is one of the harder bugs to diagnose.
Document what you throw
If a method might throw an exception that matters to callers — checked or unchecked — say so in the Javadoc:
/**
* Looks up a user by id.
*
* @throws UserNotFoundException if no user with that id exists
* @throws IllegalArgumentException if id is null or blank
*/
public User lookup(String id) { ... }The compiler enforces this for checked exceptions in the signature. For unchecked ones, the Javadoc is the only contract — and callers really do need it when the exception affects how they should use the method.
Use exceptions for failures, returns for routine outcomes
The summarising rule, and the one that ties the rest together. An exception says something went wrong I can't fix here. A return value says here's the result. When "not found" is part of normal operation, return Optional.empty(), a boolean, or a sentinel. When "the database connection dropped" happens, throw.
Code that observes this distinction is calm: the happy path looks like a straight line, the unusual path is in a different block, and the reader can tell at a glance which is which.
A worked example
A small order-processing function that pulls together the practices from this chapter — fail-fast validation, specific built-in exception types, wrapping with cause, and a single top-level handler that logs once.
Four calls, four different paths. The successful one returns normally. The two IllegalArgumentException cases (null id, zero amount) are reported with the message that explains what was wrong with the input. The simulated service failure surfaces as a domain OrderProcessingException with the original IllegalStateException linked through getCause(). Nothing is swallowed, nothing is logged twice, and every failure says exactly which value caused it.
What's next
That closes Part 8 — you have a working command of Java's exception machinery and the judgment to use it well. The next part takes a deep tour of strings — the most common type in Java code by a wide margin and one with more depth than its surface suggests. Continue to Java String class.
Practice
Which of these exception-handling habits is **most** likely to make production debugging painful?