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:
import java.io.*;
import java.nio.file.*;
public class ResourceManagement {
// ❌ OLD WAY: Verbose, error-prone
static String readFileOld(String path) throws IOException {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(path));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// What do we do here? Log? Ignore? Awkward!
}
}
}
}
// ✅ MODERN WAY: Clean, safe, handles suppressed exceptions
static String readFileNew(String path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(Path.of(path))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
}
}
// ✅ EVEN BETTER: Use NIO utilities
static String readFileBest(String path) throws IOException {
return Files.readString(Path.of(path)); // Java 11+
}
public static void main(String[] args) {
try {
String content = readFileNew("example.txt");
System.out.println(content);
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
}
}File content: Hello, World! Resource closed automatically.
Best Practice 2: Fail Fast with Validation
Detect problems early and throw meaningful exceptions:
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:
// Layer diagram:
// Controller → Service → Repository → Database
//
// Each layer translates exceptions:
// Controller catches ServiceException
// Service catches DataAccessException, throws ServiceException
// Repository catches SQLException, throws DataAccessException
class DataAccessException extends Exception {
public DataAccessException(String msg, Throwable cause) { super(msg, cause); }
}
class ServiceException extends Exception {
public ServiceException(String msg, Throwable cause) { super(msg, cause); }
public ServiceException(String msg) { super(msg); }
}
class UserRepository {
// Repository layer: translates SQL to DataAccess exceptions
String findById(String id) throws DataAccessException {
try {
// Simulating database query
if (id.equals("error")) {
throw new java.sql.SQLException("Connection timeout");
}
return id.equals("USR001") ? "Alice" : null;
} catch (java.sql.SQLException e) {
throw new DataAccessException("Failed to query user: " + id, e);
}
}
}
class UserService {
private UserRepository repo = new UserRepository();
// Service layer: translates DataAccess to Service exceptions
String getUserName(String userId) throws ServiceException {
try {
String name = repo.findById(userId);
if (name == null) {
throw new ServiceException("User not found: " + userId);
}
return name;
} catch (DataAccessException e) {
throw new ServiceException("Cannot retrieve user data", e);
}
}
}
public class LayeredException {
public static void main(String[] args) {
UserService service = new UserService();
// Test 1: Success
try {
System.out.println("User: " + service.getUserName("USR001"));
} catch (ServiceException e) {
System.out.println("Error: " + e.getMessage());
}
// Test 2: Not found
try {
service.getUserName("USR999");
} catch (ServiceException e) {
System.out.println("Error: " + e.getMessage());
}
// Test 3: Database error
try {
service.getUserName("error");
} catch (ServiceException e) {
System.out.println("Error: " + e.getMessage());
System.out.println(" Root cause: " + e.getCause().getCause().getMessage());
}
}
}Output:
| User | Alice |
| Error | User not found: USR999 |
| Error | Cannot retrieve user data |
| Root cause | Connection timeout |
Best Practice 4: Proper Logging
import java.util.logging.*;
public class ExceptionLogging {
private static final Logger logger = Logger.getLogger(ExceptionLogging.class.getName());
public static void main(String[] args) {
// Configure simple console handler
logger.setLevel(Level.ALL);
ConsoleHandler handler = new ConsoleHandler();
handler.setLevel(Level.ALL);
logger.addHandler(handler);
// ❌ BAD: Just printing the message
try {
riskyOperation();
} catch (Exception e) {
System.out.println("Error: " + e.getMessage());
// No stack trace! No context! Can't debug!
}
// ❌ BAD: printStackTrace() to stderr
try {
riskyOperation();
} catch (Exception e) {
e.printStackTrace(); // Goes to stderr, not to log file
}
// ✅ GOOD: Proper logging with context and exception
try {
String result = processUser("USR001");
logger.info("Processed: " + result);
} catch (Exception e) {
// Logger captures: timestamp, level, class, message, AND full stack trace
logger.log(Level.SEVERE, "Failed to process user USR001", e);
}
}
static void riskyOperation() {
throw new RuntimeException("Something went wrong",
new java.io.IOException("Network timeout"));
}
static String processUser(String id) {
if ("USR001".equals(id)) {
throw new RuntimeException("User locked");
}
return "OK";
}
}ERROR [main] com.example.Service - Failed to process order #1234 java.io.IOException: Connection timeout at com.example.Service.processOrder(Service.java:45)
Best Practice 5: Retry Pattern with Exponential Backoff
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
public class SpecificExceptions {
// ❌ BAD: Generic exception for everything
static void processOrderBad(String orderId) throws Exception {
if (orderId == null) throw new Exception("null order");
if (orderId.isEmpty()) throw new Exception("empty order");
// Caller can't distinguish between different errors!
}
// ✅ GOOD: Specific exceptions for different cases
static void processOrderGood(String orderId) {
if (orderId == null) {
throw new NullPointerException("Order ID cannot be null");
}
if (orderId.isEmpty()) {
throw new IllegalArgumentException("Order ID cannot be empty");
}
if (!orderId.matches("ORD-\\d+")) {
throw new IllegalArgumentException(
"Invalid order ID format. Expected: ORD-<digits>, got: " + orderId);
}
System.out.println("Processing order: " + orderId);
}
public static void main(String[] args) {
String[] orders = {null, "", "INVALID", "ORD-12345"};
for (String order : orders) {
try {
processOrderGood(order);
} catch (NullPointerException e) {
System.out.println("NULL: " + e.getMessage());
} catch (IllegalArgumentException e) {
System.out.println("INVALID: " + e.getMessage());
}
}
}
}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
public class BestPracticesSummary {
public static void main(String[] args) {
System.out.println("=== Exception Handling Best Practices Checklist ===");
System.out.println();
System.out.println("✓ 1. NEVER swallow exceptions (empty catch blocks)");
System.out.println("✓ 2. Catch specific exceptions, not Exception/Throwable");
System.out.println("✓ 3. Use try-with-resources for all AutoCloseable resources");
System.out.println("✓ 4. Fail fast — validate inputs at method entry");
System.out.println("✓ 5. Preserve exception chains (pass cause in constructor)");
System.out.println("✓ 6. Translate exceptions between abstraction layers");
System.out.println("✓ 7. Log exceptions with full context and stack trace");
System.out.println("✓ 8. Don't use exceptions for flow control");
System.out.println("✓ 9. Don't return null — throw or use Optional");
System.out.println("✓ 10. Don't put return/throw in finally blocks");
System.out.println("✓ 11. Document exceptions with @throws Javadoc");
System.out.println("✓ 12. Use unchecked for programming errors");
System.out.println("✓ 13. Use checked for recoverable conditions");
System.out.println("✓ 14. Clean up resources even when exceptions occur");
System.out.println("✓ 15. Never expose stack traces to end users");
}
}=== Exception Handling Best Practices Checklist === ✓ Use specific exception types ✓ Never swallow exceptions ✓ Use try-with-resources for AutoCloseable ✓ Log exceptions with full stack trace ✓ Throw early, catch late ✓ Use custom exceptions for business logic ✓ Document exceptions with @throws ✓ Don't use exceptions for flow control
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.
Exam Focus
Revise definitions, diagrams, examples, and short-answer points for Exception Handling Best Practices.
Interview Use
Prepare one clear explanation, one practical example, and one common mistake for this Java Master Course topic.
Search Terms
java-master-course, java master course, java, master, course, exception, handling, best
Related Java Master Course Topics