Exception Handling

Best Practices & Anti-Patterns

15 min Lesson 9 of 14

Best Practices & Anti-Patterns

You now know how to use every exception mechanism Java offers. This lesson is about using them well. Poor exception handling is one of the most common sources of mysterious bugs, silent data corruption, and production outages. The four principles below — never swallow exceptions, fail fast, catch specific types, and log with context — will make your code trustworthy and your debugging sessions short.

1. Never Swallow Exceptions

Swallowing an exception means catching it and doing nothing — no logging, no rethrow, no recovery. The error disappears silently and the program continues in a broken state.

// BAD — the exception is gone; you will never know what went wrong try { loadConfig("app.properties"); } catch (IOException e) { // intentionally left empty -- DO NOT DO THIS }

Why is this dangerous? Suppose loadConfig fails because the file is missing. The program continues with default (or null) values, produces wrong output, and crashes somewhere completely unrelated — making the root cause nearly impossible to find.

Empty catch blocks are a code smell. Most IDEs and tools like SpotBugs flag them. If you genuinely cannot handle an exception, at minimum log it or rethrow it as an unchecked exception so the failure is visible.

The minimal safe alternatives:

// Option A — log it, then rethrow as unchecked try { loadConfig("app.properties"); } catch (IOException e) { System.err.println("Config load failed: " + e.getMessage()); throw new RuntimeException("Cannot start without config", e); } // Option B — log and recover deliberately (document WHY you are recovering) try { loadConfig("app.properties"); } catch (IOException e) { System.err.println("Config missing, using defaults: " + e.getMessage()); applyDefaults(); }

Option B is only acceptable when you have a real recovery strategy and you document it clearly.

2. Fail Fast

"Fail fast" means detecting problems as early as possible and stopping immediately rather than allowing bad state to propagate. The longer a program runs with corrupted data, the harder it is to trace the damage back to its source.

// BAD — validates nothing; NullPointerException surfaces 10 calls later public void processOrder(Order order) { double tax = order.getTotal() * TAX_RATE; shipOrder(order); } // GOOD — guard at the entry point, fail immediately with a clear message public void processOrder(Order order) { if (order == null) { throw new IllegalArgumentException("Order must not be null"); } if (order.getTotal() < 0) { throw new IllegalArgumentException("Order total cannot be negative: " + order.getTotal()); } double tax = order.getTotal() * TAX_RATE; shipOrder(order); }
Use Objects.requireNonNull for null checks. It throws a NullPointerException with your message and is instantly recognisable to other developers:

Objects.requireNonNull(order, "order must not be null");

Fail-fast validation is especially critical in constructors and public API methods, where invalid state can spread to many callers.

3. Catch Specific Types — Not Exception or Throwable

Catching a broad type hides all the detail you need to respond correctly.

// BAD — catches EVERYTHING, including OutOfMemoryError and programming bugs try { processPayment(card, amount); } catch (Exception e) { System.out.println("Something went wrong"); }

If processPayment throws a NullPointerException because you forgot to initialise a field, this catch block hides the bug. If it throws an OutOfMemoryError, continuing is actively harmful.

// GOOD — handle each failure mode deliberately try { processPayment(card, amount); } catch (InsufficientFundsException e) { notifyUser("Your card was declined: insufficient funds."); } catch (NetworkTimeoutException e) { scheduleRetry(card, amount); log.warn("Payment timed out, retry scheduled", e); } catch (PaymentGatewayException e) { log.error("Gateway error during payment", e); throw new ServiceUnavailableException("Payment service is down", e); }

Each branch does the right thing for that specific failure. You cannot do that when everything is an Exception.

Never catch Throwable unless you are writing framework infrastructure (e.g., a thread-pool supervisor). Throwable includes Error subclasses like OutOfMemoryError and StackOverflowError — conditions the JVM itself is in trouble with. Swallowing those makes things worse.

4. Log With Context

When an exception does occur, a bare e.getMessage() is rarely enough to diagnose it in production. Always include the values that led to the failure.

// BAD — "User not found" tells you nothing about which user or what called this } catch (UserNotFoundException e) { log.error("User not found"); } // GOOD — include the ID, the operation, and preserve the stack trace } catch (UserNotFoundException e) { log.error("Failed to load user with id={} during order processing", userId, e); }

Always pass the exception object as the last argument to your logger so the full stack trace is recorded. Stack traces show you the exact line of code that failed — dropping them is like deleting a crash report.

Use a real logging framework — SLF4J with Logback or Log4j2 — not System.out.println. Production logs need levels (DEBUG/INFO/WARN/ERROR), timestamps, thread names, and file rotation. println provides none of that.

Putting It All Together

Here is a realistic method that applies all four principles:

import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); public void submitOrder(String userId, Order order) { // Fail fast — validate at the boundary Objects.requireNonNull(userId, "userId must not be null"); Objects.requireNonNull(order, "order must not be null"); if (order.getTotal() < 0) { throw new IllegalArgumentException("Order total is negative: " + order.getTotal()); } try { // Catch specific types paymentGateway.charge(userId, order.getTotal()); } catch (InsufficientFundsException e) { // Log with context — include userId and amount log.warn("Charge declined for user={} amount={}", userId, order.getTotal(), e); throw new OrderDeclinedException("Payment declined for user " + userId, e); } catch (GatewayUnavailableException e) { log.error("Payment gateway down for user={}", userId, e); throw new ServiceException("Payment service unavailable", e); } // Never swallow — all paths either recover explicitly or rethrow } }

Quick Reference: Anti-Patterns to Avoid

  • Empty catch block — exceptions disappear silently.
  • Catching Exception or Throwable — masks bugs and JVM errors.
  • Logging without the exception object — stack trace is lost.
  • Logging and rethrowing without wrapping — the same error gets logged twice at every layer.
  • Using exceptions for normal flow control — e.g., throwing an exception when a list is empty instead of returning an empty list. Exceptions are slow and semantically wrong for expected outcomes.
  • Catching a checked exception just to satisfy the compiler — wrap it in a meaningful unchecked exception instead of swallowing it.
Log-and-rethrow duplication: if you log an exception and then rethrow it, every layer above that does the same, and your log file fills up with the same stack trace five times. The rule: log once, at the layer that handles it. All other layers just rethrow (wrapped if necessary).

Summary

Good exception handling is not about writing more try/catch blocks — it is about writing intentional ones. Never swallow exceptions; always let failures be visible. Fail fast by validating inputs at the boundary. Catch the most specific type that matches the failure so you can respond correctly. Log with enough context — including the exception object — that a developer can diagnose the problem from the log alone. These habits separate robust, maintainable code from fragile code that silently corrupts state and produces baffling bugs months later.