Java Notes
Complete guide to exception handling best practices in Java — anti-patterns to avoid, proper exception design, logging strategies, retry patterns, and production-grade error handling techniques.
Why Best Practices Matter
Poor exception handling is one of the leading causes of:
- Silent failures (bugs that go unnoticed for months)
- Security vulnerabilities (stack traces exposed to users)
- Resource leaks (connections, files left open)
- Debugging nightmares (lost context, swallowed exceptions)
This chapter covers battle-tested patterns used in production Java applications.
Anti-Pattern 1: Swallowing Exceptions
The WORST thing you can do — catching an exception and doing nothing:
// ❌ TERRIBLE: Exception is silently swallowed
try {
saveToDatabase(record);
} catch (SQLException e) {
// Nothing here! Data loss goes unnoticed!
}
// ❌ STILL BAD: Logging without useful context
try {
saveToDatabase(record);
} catch (SQLException e) {
e.printStackTrace(); // Goes to stderr, often lost in production
}
// ✅ GOOD: Meaningful handling
try {
saveToDatabase(record);
} catch (SQLException e) {
logger.error("Failed to save record ID={}: {}",
record.getId(), e.getMessage(), e);
// Option A: Re-throw as runtime exception
throw new DataPersistenceException("Save failed for: " + record.getId(), e);
// Option B: Add to retry queue
// retryQueue.add(record);
// Option C: Return failure result
// return Result.failure(e.getMessage());
}Anti-Pattern 2: Catching Exception or Throwable
// ❌ BAD: Catches EVERYTHING including bugs
try {
processData(input);
} catch (Exception e) {
System.out.println("Error: " + e.getMessage());
}
// This catches NullPointerException, ClassCastException, etc.
// Those indicate BUGS that should be fixed, not hidden!
// ✅ GOOD: Catch specific exceptions
try {
processData(input);
} catch (FileNotFoundException e) {
logger.warn("Input file not found: {}", input);
useDefaultData();
} catch (ParseException e) {
logger.error("Cannot parse data from: {}", input, e);
throw new InvalidInputException("Unparseable input", e);
}When catching Exception IS acceptable:
// ✅ OK in top-level handlers (web controllers, thread entry points)
@Override
public void run() {
try {
doWork();
} catch (Exception e) {
// Top-level: nothing above us to handle it
logger.error("Worker thread failed", e);
reportToMonitoring(e);
}
}Anti-Pattern 3: Using Exceptions for Flow Control
Anti-Pattern 4: Losing Exception Context
// ❌ BAD: Original cause is lost forever
try {
connectToDatabase();
} catch (SQLException e) {
throw new RuntimeException("Connection failed");
// WHERE did it fail? WHAT was the SQL error? Gone!
}
// ❌ BAD: Only message preserved, stack trace lost
try {
connectToDatabase();
} catch (SQLException e) {
throw new RuntimeException("Connection failed: " + e.getMessage());
// Message is there, but the ORIGINAL stack trace is lost
}
// ✅ GOOD: Preserve full exception chain
try {
connectToDatabase();
} catch (SQLException e) {
throw new RuntimeException("Connection failed", e); // Cause preserved!
}Best Practice 1: Use Try-With-Resources
Always use try-with-resources for anything that implements AutoCloseable:
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Best Practice 2: Fail Fast with Validation
Detect problems early and throw meaningful exceptions:
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Output:
| Caught | IllegalArgumentException - orderId cannot be null or blank |
| Caught | NullPointerException - items list cannot be null |
| Caught | IllegalArgumentException - items list cannot be empty |
Best Practice 3: Exception Translation (Abstraction Layers)
Each layer should throw exceptions appropriate to its abstraction level:
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Output:
| User | Alice |
| Error | User not found: USR999 |
| Error | Cannot retrieve user data |
| Root cause | Connection timeout |
Best Practice 4: Proper Logging
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Best Practice 5: Retry Pattern with Exponential Backoff
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Output:
| Attempt 1 failed | Connection timeout (attempt 1). Retrying in 100ms... |
| Attempt 2 failed | Connection timeout (attempt 2). Retrying in 200ms... |
| Result | Success on attempt 3 |
Best Practice 6: Use Specific Exception Types
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Output:
| NULL | Order ID cannot be null |
| INVALID | Order ID cannot be empty |
| INVALID | Invalid order ID format. Expected: ORD-<digits>, got: INVALID |
| Processing order | ORD-12345 |
Best Practice 7: Document Exceptions in Javadoc
/**
* Transfers funds between two accounts.
*
* @param fromAccount source account ID (must not be null)
* @param toAccount destination account ID (must not be null)
* @param amount transfer amount (must be positive)
* @return transaction ID of the successful transfer
*
* @throws NullPointerException if fromAccount or toAccount is null
* @throws IllegalArgumentException if amount is zero or negative
* @throws InsufficientFundsException if source account has insufficient balance
* @throws AccountNotFoundException if either account does not exist
* @throws TransferLimitExceededException if daily transfer limit is exceeded
*/
public String transferFunds(String fromAccount, String toAccount, double amount)
throws InsufficientFundsException, AccountNotFoundException,
TransferLimitExceededException {
// Implementation...
return "TXN-" + System.currentTimeMillis();
}Q8: Why should you never catch Throwable or Exception generically?
A: Catching Throwable also catches Errors (OutOfMemoryError, StackOverflowError) which shouldn't be caught. Catching Exception catches all checked AND unchecked exceptions, hiding bugs (NPE, ClassCastException). Always catch the most specific exception type — it documents expectations and enables appropriate recovery.
Q9: What is the "throw early, catch late" principle?
A: "Throw early": validate inputs at the start of methods and throw immediately when preconditions fail — don't wait for a confusing failure later. "Catch late": let exceptions propagate up to the layer that has enough context to handle them meaningfully (usually the service/controller layer, not deep utility methods).
Q10: How should exceptions be logged in production?
A: (1) Log the full stack trace with logger.error("msg", exception) — never e.getMessage() alone. (2) Include context (user ID, request ID, input values). (3) Use structured logging. (4) Don't log AND throw the same exception (causes duplicate logs). (5) Log at the appropriate level (error for unrecoverable, warn for recoverable).
Q11: What are suppressed exceptions in try-with-resources?
A: If both the try block and close() throw exceptions, Java 7+ keeps the try block's exception as primary and attaches the close() exception as "suppressed" (accessible via getSuppressed()). This prevents losing the original exception while still recording the cleanup failure.
Q12: Why should you avoid returning from catch or finally blocks?
A: Returning from catch can mask the exception — the caller doesn't know an error occurred. Returning from finally silently overwrites any exception or return value from try/catch. Both make debugging extremely difficult. Let exceptions propagate naturally; use finally only for cleanup, never for returning values.
Summary: The Exception Handling Checklist
Try it in WoHoCode
Edit the program, then run it here.
Run this example to see its output.
Interview Questions
Q1: What are the most common exception handling anti-patterns?
Answer: (1) Swallowing exceptions (empty catch), (2) catching Exception/Throwable broadly, (3) using exceptions for normal flow control, (4) losing exception context when re-throwing, (5) logging and re-throwing (double-logging), (6) returning null instead of throwing.
Q2: When is it acceptable to catch Exception?
Answer: Only in top-level handlers: thread entry points (run() methods), web controller endpoints, event handlers, and main methods. These are "last resort" handlers where there's nothing above to propagate to.
Q3: What is exception translation and when should you use it?
Answer: Exception translation means catching a low-level exception and throwing a higher-level one (with the original as cause). Use it at layer boundaries — a service layer shouldn't expose SQLException to controllers; it should throw a ServiceException with the SQL exception as the cause.
Q4: How should exceptions be handled in a multi-threaded application?
Answer: Each thread should have its own top-level exception handler. Use Thread.setUncaughtExceptionHandler() for unexpected exceptions. With ExecutorService, exceptions from tasks are captured in the Future — check with future.get() which throws ExecutionException wrapping the original.
Q5: Should you log an exception AND re-throw it?
Answer: Generally NO — this causes double-logging (once where you catch/log, once where the re-thrown exception is eventually handled). Either handle it (log + recovery) OR re-throw it (let the caller handle). The exception: logging at DEBUG level before re-throwing, when the caller might log at a different level.
Q6: What is the "fail fast" principle?
Answer: Detect and report errors as early as possible. Validate inputs at method entry, check preconditions before operations. This makes bugs easier to find (close to the source) and prevents corrupted state from propagating through the system.
Continue the lesson path