Error Handling Best Practices in Java
PG Blog
- 5 minutes read - 859 wordsIntroduction
Error handling is where a lot of otherwise-good Java code quietly goes wrong. Exceptions get swallowed, null leaks across boundaries, and stack traces arrive with no context about what the program was trying to do.
Good error handling is not about catching everything. It is about being deliberate: fail fast on programmer errors, recover gracefully from expected failures, and never lose the information a future on-call engineer will need.
This post covers the practices that hold up in real, long-lived Java systems.
Checked vs Unchecked: Choose on Recoverability
Java is the rare language with checked exceptions, and the debate around them never ends. A simple rule cuts through it:
- Checked exceptions — for conditions a caller can reasonably be expected to recover from (a file not found, a remote service temporarily down).
- Unchecked exceptions (
RuntimeException) — for programming errors the caller cannot sensibly recover from (a null argument, an illegal state, a broken invariant).
// Programmer error — fail fast, don't force callers to catch it
public Order placeOrder(Customer customer, List<Item> items) {
Objects.requireNonNull(customer, "customer must not be null");
if (items.isEmpty()) {
throw new IllegalArgumentException("cannot place an order with no items");
}
// ...
}
Validating at the boundary and throwing immediately keeps the failure close to its cause, where the stack trace is still meaningful.
Never Swallow Exceptions
The single most damaging anti-pattern in Java is the empty catch block:
// DON'T: the error vanishes without a trace
try {
charge(payment);
} catch (PaymentException e) {
// ignored
}
If you catch an exception, you must do one of three things: handle it, rethrow it (often wrapped with context), or log it with enough detail to act on. Doing none of them turns a real failure into a silent data-corruption bug that surfaces days later.
try {
charge(payment);
} catch (PaymentException e) {
throw new OrderProcessingException(
"failed to charge payment for order " + orderId, e);
}
Note the e passed as the cause — never drop the original exception. The chained cause is what preserves the root stack trace.
Wrap to Add Context, Not to Hide It
As an error propagates up through layers, each boundary should add context that the layer below did not have:
public Report generateReport(long accountId) {
try {
var data = repository.loadTransactions(accountId);
return renderer.render(data);
} catch (DataAccessException e) {
// The repository knew "DB timeout"; we add "for which account, doing what"
throw new ReportException(
"could not generate report for account " + accountId, e);
}
}
By the time this reaches a log, the message reads like a sentence: what failed, for which entity, because of the underlying cause. That is the difference between a five-minute fix and a two-hour investigation.
Make Absence Explicit with Optional
A large share of Java’s historical NullPointerException pain comes from methods that return null to mean “nothing found.” Optional makes that contract explicit in the type signature.
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(users.get(email));
}
// Caller cannot forget the empty case — the compiler makes them handle it
String name = findByEmail(email)
.map(User::name)
.orElse("unknown");
Use Optional for return types where absence is a normal outcome. Do not use it for fields or method parameters — it adds overhead and reads awkwardly there.
Result Types for Expected Failures
Exceptions are for the exceptional. When a failure is a routine, expected outcome — validation, parsing user input — a Result type can be clearer and cheaper than throwing. Sealed types (Java 17+) model this cleanly:
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message) implements Result<T> {}
Result<Integer> parsePort(String raw) {
try {
int port = Integer.parseInt(raw);
return (port >= 1 && port <= 65535)
? new Ok<>(port)
: new Err<>("port out of range: " + port);
} catch (NumberFormatException e) {
return new Err<>("not a number: " + raw);
}
}
The caller then handles both cases exhaustively via pattern matching — no hidden control flow, no forgotten catch.
Clean Up Reliably with try-with-resources
Any object holding a resource (files, sockets, DB connections) should implement AutoCloseable and be used in a try-with-resources block. It guarantees cleanup even when an exception is thrown mid-operation:
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement(SQL)) {
return statement.executeQuery();
} // both closed automatically, in reverse order, even on exception
This eliminates the leaked-connection bugs that plague hand-written finally blocks.
Practical Checklist
| Practice | Why it matters |
|---|---|
| Validate inputs at the boundary, fail fast | Failure stays close to its cause |
| Never swallow exceptions | Silent failures are the hardest bugs |
| Always chain the original cause | Preserves the root stack trace |
| Wrap to add context per layer | Logs read like sentences |
Return Optional, not null | Absence becomes compiler-enforced |
| Use Result types for expected failures | Exceptions stay exceptional |
| Use try-with-resources | No leaked connections or files |
Final Thoughts
Robust error handling in Java is not about defensive try/catch sprinkled everywhere. It is about intent: decide whether a failure is recoverable, fail fast when it is not, preserve context when it propagates, and make absence and expected failure visible in your types.
Do that consistently, and your stack traces stop being mysteries — they start being instructions.