DEV Community

Puneet Gupta
Puneet Gupta

Posted on Edited on Originally published at pg-blogs.netlify.app

Error Handling Best Practices in Java

Introduction

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");
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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");