Exception Best Practices
Practical guidelines for writing exception-handling code that stays diagnosable and correct: what to catch, what to preserve, what to log, and what to avoid entirely.
Java's exception mechanism is powerful, but it's easy to misuse in ways that make failures harder to diagnose rather than easier — catching too broadly, silently swallowing errors, or losing the original cause when wrapping. These practices, drawn from widely shared professional experience rather than the language specification itself, help exception-handling code stay both correct and debuggable.
Don't Catch Blindly
Catching Exception (or worse, Throwable) broadly, 'just to be safe,' hides bugs that should surface loudly — a NullPointerException from a real defect looks identical to a deliberately thrown, recoverable exception once caught this way. Catch the most specific type(s) you can actually do something meaningful about, and let everything else propagate.
// Too broad: masks programming errors as if they were expected
try {
processOrder(order);
} catch (Exception e) {
System.out.println("something went wrong");
}
// Better: catch what you can actually handle
try {
processOrder(order);
} catch (InsufficientStockException e) {
notifyCustomer(order, e.getMessage());
}Preserve Context
An exception's stack trace and message are often the only clue to what actually failed and where. Never discard them casually — re-throw the original where possible, or pass it as the cause when wrapping, so the full failure chain remains visible in logs and debuggers.
catch (SQLException e) {
// BAD: original stack trace and message are gone
throw new RuntimeException("database error");
}
catch (SQLException e) {
// GOOD: cause preserved, both messages visible
throw new RuntimeException("database error while saving order " + orderId, e);
}Don't Use Exceptions for Normal Control Flow
Exceptions are for exceptional, unexpected conditions, not for routine branching. Constructing an exception captures a stack trace (a nontrivial cost) and using try/catch for expected outcomes (like 'item not found in a normal lookup') makes code harder to read than a simple conditional or an Optional-returning method.
// Poor: using exceptions for an expected, routine outcome
try {
User u = repository.findById(id);
return u;
} catch (NotFoundException e) {
return null;
}
// Better: express "might not exist" directly
Optional<User> user = repository.findById(id); // returns Optional.empty() if absent
return user.orElse(null);Avoid Swallowing Exceptions
An empty catch block silently discards evidence that something failed, turning a diagnosable problem into a mysterious one later. At minimum, log the exception (with its stack trace, not just its message); ideally, decide explicitly whether to recover, rethrow, or convert it.
try {
cache.refresh();
} catch (IOException e) {
// BAD: silent — nobody will ever know refresh failed
}
try {
cache.refresh();
} catch (IOException e) {
logger.warn("Cache refresh failed, continuing with stale data", e);
}Choose Checked vs Unchecked Thoughtfully
Neither checked nor unchecked exceptions are universally 'better' — Java itself doesn't take a position, and even the standard library mixes both. A reasonable rule of thumb: use checked exceptions for conditions the immediate caller can be expected to recognize and recover from as part of normal operation; use unchecked exceptions for programming errors or conditions most callers cannot meaningfully recover from locally.
Overusing checked exceptions across many layers tends to produce boilerplate throws declarations and catch blocks that just rethrow; overusing unchecked exceptions for genuinely recoverable conditions can let real failures slip past callers who forgot to handle them. Match the choice to the failure, not to a blanket policy.
Logging Strategy
Log an exception once, at the point where it's finally handled (not at every layer it passes through, which produces duplicate noisy log entries for a single failure), and always log the exception object itself (not just e.getMessage()) so the stack trace is captured.
catch (IOException e) {
// Loses the stack trace — only the message is recorded
logger.error("Failed: " + e.getMessage());
}
catch (IOException e) {
// Preserves the full stack trace for diagnosis
logger.error("Failed to load configuration", e);
}Wrapping and Cause Chaining
When translating a low-level exception into a higher-level, more meaningful one (e.g. SQLException into a domain-specific RepositoryException), always pass the original as the cause via the (message, cause) constructor. getCause() then lets anyone reading the trace follow the chain back to the true root failure.
try {
return jdbcTemplate.query(sql);
} catch (SQLException e) {
throw new RepositoryException("Failed to load records", e); // cause chained
}Fail Fast and Validate Early
Throwing as close as possible to the actual invalid condition (e.g. validating constructor arguments immediately) produces a stack trace that points directly at the real problem. Letting invalid state silently propagate deeper into the system produces failures far from their true cause, which are much harder to diagnose.
public Account(String id, double balance) {
if (balance < 0) {
throw new IllegalArgumentException("balance cannot be negative: " + balance);
}
this.id = id;
this.balance = balance;
}Practical Usage: Putting It Together
class OrderService {
private static final Logger logger = Logger.getLogger(OrderService.class.getName());
void submitOrder(Order order) {
try {
validate(order); // fails fast on bad input
repository.save(order); // may throw a checked RepositoryException
} catch (RepositoryException e) {
// handled here: specific catch, cause preserved by the throw site,
// logged once with full stack trace, and translated to a clear outcome
logger.log(Level.SEVERE, "Order submission failed for " + order.getId(), e);
throw new OrderProcessingException("Could not submit order " + order.getId(), e);
}
}
}Edge Cases
Catching an exception purely to add a log statement and then immediately rethrowing the exact same object unchanged is usually unnecessary — it's better to let it propagate and log once at the boundary, unless the intermediate context genuinely adds diagnostic value not otherwise available.
Catching Error or its subtypes (like OutOfMemoryError or StackOverflowError) is rarely appropriate; these usually indicate the JVM itself is in a compromised state where continuing to run further application code is unsafe.
Rethrowing inside a finally block, or returning from one, silently discards any exception already propagating through the try block — a subtle way exceptions get accidentally swallowed.
Mental Model
Treat every exception as a message being carried up a chain of command. Good practice means: don't intercept messages you can't act on (don't catch blindly), don't tear up the message before passing it along (preserve context), don't relay routine status updates as emergency messages (don't use exceptions for control flow), and don't just throw incoming messages in the trash (don't swallow).
Quick Reference
DO:
- Catch specific types you can meaningfully act on
- Preserve the original exception as cause when wrapping
- Log the exception object (with stack trace), once, at the handling layer
- Fail fast: validate and throw close to the real problem
- Pick checked vs unchecked based on whether the caller can realistically recover
DON'T:
- catch (Exception e) { } as a default habit
- Discard the original exception when wrapping
- Use exceptions to implement routine, expected branching
- Leave a catch block empty
- Log the same exception repeatedly at every layer it passes throughKey Takeaways
Catch specific, actionable exception types rather than broad ones out of habit, so real bugs don't get silently masked.
Always preserve context: pass the original exception as the cause when wrapping, and never leave a catch block empty.
Reserve exceptions for exceptional conditions, not routine, expected control flow.
Log the full exception object once, at the layer that actually handles it, rather than repeatedly at every layer it passes through.
Neither checked nor unchecked exceptions are universally correct; choose based on whether the immediate caller can realistically recover.
try { riskyOperation(); } catch (Exception e) { // swallow everything, including bugs } try { riskyOperation(); } catch (SpecificRecoverableException e) { handle(e); }
Catching Exception broadly and doing nothing hides real programming errors alongside genuinely expected failures, making both indistinguishable.
catch (IOException e) { throw new RuntimeException("read failed"); // cause dropped } catch (IOException e) { throw new RuntimeException("read failed", e); // cause preserved }
Wrapping without the (message, cause) constructor discards the original stack trace, making the true root cause invisible in logs.
try { return Integer.parseInt(input); } catch (NumberFormatException e) { return -1; // used routinely for expected non-numeric input, every call } if (input.matches("-?\d+")) { return Integer.parseInt(input); } return -1;
If invalid input is a routine, expected case rather than truly exceptional, prefer an explicit check over relying on exception handling for normal branching — it's clearer and avoids unnecessary exception-construction cost.
Why is catching Exception broadly, as a default habit, considered poor practice?
It groups genuine programming errors together with expected, recoverable conditions, so both are handled (or silently discarded) the same way, making real bugs invisible.
What must you do when wrapping a caught exception in a new one, to avoid losing diagnostic information?
Pass the original exception as the cause (via a message-and-cause constructor) so getCause() and the printed stack trace still lead back to the root failure.
Why should exceptions generally not be used to implement routine control flow?
Constructing an exception captures a stack trace at nontrivial cost, and using try/catch for expected, everyday branching (rather than truly exceptional conditions) makes code harder to read than a direct conditional or an Optional-based approach.
What should you do at minimum instead of leaving a catch block empty?
Log the exception (including its stack trace, not just its message) so the failure is visible, and ideally make an explicit decision to recover, rethrow, or convert it.
What logging mistake loses an exception's stack trace even though something is logged?
Logging only e.getMessage() as a string instead of passing the exception object itself to the logger, which discards the stack trace.
Interview: Since Java itself doesn't declare checked or unchecked exceptions universally 'better,' what practical rule of thumb helps decide between them for a new custom exception?
Use checked exceptions for conditions the immediate caller can realistically recognize and recover from as part of normal operation, and unchecked exceptions for programming errors or conditions most callers cannot meaningfully recover from locally.
What is wrong with this code from a best-practices standpoint, even though it compiles and runs fine?
The catch block silently swallows any exception from connect() — including real bugs — with no logging or decision made; failures become invisible, and 'continuing' prints even after a serious problem.
Rewrite this poor exception handling to follow best practices: catch specifically, preserve the cause, and log once with the full stack trace.
void saveUser(User u) { try { db.save(u); } catch (SQLException e) { logger.log(Level.SEVERE, "Failed to save user " + u.getId(), e); throw new UserPersistenceException("Could not save user " + u.getId(), e); } }
The fix catches the specific SQLException instead of Exception, logs the exception object (preserving the stack trace) rather than just its message, and wraps it with the cause preserved instead of a bare new exception.
Write a validate(int age) method that fails fast with IllegalArgumentException immediately when age is negative, rather than allowing a negative age to propagate silently into later calculations.
static void validate(int age) { if (age < 0) { throw new IllegalArgumentException("age cannot be negative: " + age); } } static int nextBirthdayAge(int age) { validate(age); // fail fast, right at the boundary return age + 1; }
Validating and throwing immediately at the entry point produces a stack trace pointing directly at the true source of the bad value, instead of surfacing confusingly deep inside unrelated calculation code later.
try {
connect();
} catch (Exception e) {
}
System.out.println("continuing");void saveUser(User u) {
try {
db.save(u);
} catch (Exception e) {
System.out.println("error: " + e.getMessage());
throw new RuntimeException("save failed");
}
}