Java Exception Handling Best Practices

5 minute read

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 AutoCloseable objects
  • Meaningful messages with context and values
  • Logging at the correct level
  • Either log or throw, not both
  • Documented with @throws Javadoc
  • Not using exceptions for flow control

Key Takeaways

  1. Never swallow — at minimum, log every exception.
  2. Never catch broadly — catch (Exception e) hides bugs.
  3. Fail fast — validate early, before doing expensive work.
  4. Preserve the cause — always pass the original exception when wrapping.
  5. Throw early, catch late — detect problems immediately, handle where meaningful.
  6. Log or throw, not both — avoid duplicate logging.
  7. try-with-resources for all resources — eliminate leak bugs.
  8. 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.

Updated: