Java Exception Handling Best Practices
Exception handling separates robust production code from fragile hobby projects. This chapter covers battle-tested rules, patterns, and anti-patterns.
Rule 1: Never Catch Broadly
// ❌ BAD — catches everything, including bugs
try {
processOrder(order);
} catch (Exception e) {
System.out.println("Something went wrong");
}
This catches NullPointerException, ClassCastException, and every other bug — silently hiding them.
// ✅ GOOD — catch specific types
try {
processOrder(order);
} catch (InsufficientFundsException e) {
notifyUser("Not enough balance: " + e.getMessage());
} catch (InventoryException e) {
notifyUser("Item out of stock");
}
Exception: A top-level catch-all in web frameworks or
main()is acceptable — log and report, don’t ignore.
Rule 2: Never Swallow Exceptions
// ❌ WORST PRACTICE — empty catch block
try {
saveToDatabase(data);
} catch (SQLException e) {
// silently ignored — data may be lost
}
At minimum, log the exception:
// ✅ Log it
catch (SQLException e) {
logger.error("Failed to save data", e);
}
// ✅ Or rethrow it
catch (SQLException e) {
throw new PersistenceException("Save failed", e);
}
Rule 3: Fail Fast
Validate inputs and state early — before doing work:
// ❌ BAD — fails late, wastes resources
public Report generate(String userId, DateRange range) {
List<Transaction> txns = fetchTransactions(userId, range); // expensive
double total = calculateTotal(txns); // more work
if (userId == null) throw new IllegalArgumentException(); // too late!
return new Report(total);
}
// ✅ GOOD — fails immediately
public Report generate(String userId, DateRange range) {
Objects.requireNonNull(userId, "userId");
Objects.requireNonNull(range, "range");
if (range.days() > 365) {
throw new IllegalArgumentException("Range cannot exceed 1 year");
}
List<Transaction> txns = fetchTransactions(userId, range);
double total = calculateTotal(txns);
return new Report(total);
}
Rule 4: Throw Early, Catch Late
- Throw at the point where the problem is detected (close to the root cause).
- Catch at the point where you can do something meaningful about it.
// Repository — throws early
public User findById(long id) {
User user = db.query(id);
if (user == null) {
throw new UserNotFoundException(id); // throw at detection
}
return user;
}
// Controller — catches late (where recovery is possible)
public Response getUser(long id) {
try {
User user = userService.findById(id);
return Response.ok(user);
} catch (UserNotFoundException e) {
return Response.notFound(e.getMessage()); // meaningful handling
}
}
Rule 5: Preserve the Cause Chain
Always pass the original exception when wrapping:
// ❌ BAD — original exception lost
catch (SQLException e) {
throw new ServiceException("Query failed"); // no cause!
}
// ✅ GOOD — cause preserved
catch (SQLException e) {
throw new ServiceException("Query failed", e); // includes cause
}
Without the cause, debugging becomes a nightmare — you see ServiceException but not the underlying SQLException.
Rule 6: Use Meaningful Messages
// ❌ BAD
throw new RuntimeException("Error");
throw new RuntimeException("Failed");
throw new RuntimeException("Something went wrong");
// ✅ GOOD — includes context
throw new UserNotFoundException(
"User with ID %d not found in database '%s'".formatted(id, dbName));
throw new ConfigException(
"Missing required config key 'db.url' in %s".formatted(configPath));
throw new ValidationException(
"Email '%s' is invalid: must contain '@'".formatted(email));
Include: what failed, why, and relevant values.
Rule 7: Log at the Right Level
| Level | Use For | Exception? |
|---|---|---|
ERROR |
Unexpected failures, data loss | Yes — include stack trace |
WARN |
Recoverable issues, degraded service | Sometimes |
INFO |
Expected conditions (file not found → use default) | No stack trace |
DEBUG |
Detailed troubleshooting | Depends |
// ✅ Error — unexpected, needs investigation
logger.error("Payment processing failed for order {}", orderId, e);
// ✅ Warn — recoverable, but worth noting
logger.warn("Cache miss for user {}, falling back to DB", userId);
// ✅ Info — expected condition
logger.info("Config file not found, using defaults");
Rule 8: Don’t Use Exceptions for Flow Control
// ❌ BAD — using exceptions as logic branching
try {
int value = Integer.parseInt(input);
processNumber(value);
} catch (NumberFormatException e) {
processText(input);
}
// ✅ GOOD — check before doing
if (input.matches("-?\\d+")) {
processNumber(Integer.parseInt(input));
} else {
processText(input);
}
Exceptions are expensive (stack trace creation). Use conditional logic for expected paths.
Rule 9: Use try-with-resources
// ❌ BAD — manual cleanup
Connection conn = null;
try {
conn = getConnection();
// use conn
} finally {
if (conn != null) conn.close();
}
// ✅ GOOD — automatic cleanup
try (Connection conn = getConnection()) {
// use conn
}
Rule 10: Document Exceptions
/**
* Transfers money between accounts.
*
* @param from source account ID (must not be null)
* @param to destination account ID (must not be null)
* @param amount transfer amount (must be positive)
* @throws AccountNotFoundException if either account doesn't exist
* @throws InsufficientFundsException if source account balance is too low
* @throws IllegalArgumentException if amount is not positive
*/
public void transfer(String from, String to, double amount) { ... }
Anti-Pattern Catalog
1. Pokemon Exception Handling
// "Gotta catch 'em all!"
catch (Exception e) { } // catches everything, handles nothing
2. Log and Throw
// ❌ Exception gets logged multiple times as it propagates
catch (IOException e) {
logger.error("Failed", e);
throw e; // caller will also log it
}
Fix: Either log or throw, not both. The layer that handles the exception should log it.
3. Catch and Return Null
// ❌ Caller must now check for null — error is hidden
public User findUser(long id) {
try {
return db.query(id);
} catch (SQLException e) {
return null; // caller has no idea something failed
}
}
Fix: Throw a meaningful exception or return Optional.
4. Destructive Wrapping
// ❌ Wrapping without the cause
catch (IOException e) {
throw new AppException(e.getMessage()); // original stack trace LOST
}
5. Overly Broad throws
// ❌ Caller has no idea what to expect
public void doEverything() throws Exception { ... }
Exception Handling Layers
A well-structured application handles exceptions at the right layer:
| Layer | Responsibility | Example |
|---|---|---|
| Repository | Throw data-access exceptions | throw new DataAccessException(e) |
| Service | Business logic exceptions, wrap low-level | throw new OrderException(msg, e) |
| Controller | Catch and return error responses | catch → return 400/404/500 |
| Global handler | Catch-all, logging, generic error page | @ExceptionHandler in Spring |
// Spring Boot example
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e) {
return ResponseEntity.status(404)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
logger.error("Unexpected error", e);
return ResponseEntity.status(500)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
Checklist
- Catching specific exception types, not
Exception - No empty catch blocks
- Cause chain preserved when wrapping exceptions
- Fail fast — validate inputs before processing
- Using try-with-resources for all
AutoCloseableobjects - Meaningful messages with context and values
- Logging at the correct level
- Either log or throw, not both
- Documented with
@throwsJavadoc - Not using exceptions for flow control
Key Takeaways
- Never swallow — at minimum, log every exception.
- Never catch broadly —
catch (Exception e)hides bugs. - Fail fast — validate early, before doing expensive work.
- Preserve the cause — always pass the original exception when wrapping.
- Throw early, catch late — detect problems immediately, handle where meaningful.
- Log or throw, not both — avoid duplicate logging.
- try-with-resources for all resources — eliminate leak bugs.
- Document exceptions in Javadoc — callers need to know what to expect.
Module Complete!
You’ve completed the Exception Handling module. You now understand try-catch-finally, throw/throws, checked vs unchecked exceptions, custom exceptions, and production-grade best practices.