← Back to the section

Most error-handling problems in Java applications come down to a handful of recurring antipatterns. They are easy to introduce, but even easier to miss — the error is silently swallowed, and you end up debugging it in production.

Swallowed exception — an empty catch

The most dangerous antipattern: a catch intercepts an exception and does nothing with it. The program keeps running as if nothing happened, even though something has already gone wrong.

// bad
try {
    config = objectMapper.readValue(json, Config.class);
} catch (JsonProcessingException e) {
    // TODO: figure this out later
}

After a block like this, config stays null, and a NullPointerException will surface somewhere completely different. The link to the real cause is lost.

Rule: a catch must do at least one of three things — log, rethrow, or take a meaningful action (fallback, metric).

// good
try {
    config = objectMapper.readValue(json, Config.class);
} catch (JsonProcessingException e) {
    throw new ConfigurationException("Failed to parse configuration", e);
}

Lost stack trace — an exception without a cause

When you wrap one exception in another, always pass the original as the cause. Otherwise the entire call chain that led to the error vanishes without a trace.

// bad — we lose the original cause
} catch (SQLException e) {
    throw new DataAccessException("Database error"); // e is gone
}

// good — the cause is preserved
} catch (SQLException e) {
    throw new DataAccessException("Database error", e);
}

In the logs, the first variant shows only DataAccessException without any context. The second shows the full chain with the original SQLException, the table name, and the driver's error code.

Exceptions as control flow

Exceptions are an expensive mechanism: when one is created, the JVM captures the stack trace of all calls. Using them as a goto is both incorrect and slow.

// bad — an exception instead of an if
try {
    int value = Integer.parseInt(input);
    return value;
} catch (NumberFormatException e) {
    return -1; // "not a number" is a normal case, not an error
}

// good — a plain check
if (input != null && input.matches("-?\\d+")) {
    return Integer.parseInt(input);
}
return -1;

A short rule of thumb: an exception is for exceptional situations that should not normally occur. If a user enters "invalid data" — that's an expected case, so check for it explicitly.

Swallowed InterruptedException

InterruptedException is a special case: you can't just swallow it. It's a signal for the thread to stop. If you ignore it, the thread keeps running and never terminates cleanly.

// bad — the interrupt flag is lost
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // do nothing
}

// good — we restore the flag
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new TaskInterruptedException("Task interrupted", e);
}

If a method can't throw InterruptedException, the minimum is to restore the flag via Thread.currentThread().interrupt(), so that the calling code can read it.

Duplicated logs — log-and-throw

The "logged it and threw it" antipattern causes a single error to land in the log multiple times: each layer catches it, logs it, and throws it further up.

// bad — logging in every layer
} catch (SQLException e) {
    log.error("Repository error", e); // here
    throw new DataAccessException("Database error", e);
}

// ...higher up the stack...
} catch (DataAccessException e) {
    log.error("Service error", e); // and here again
    throw e;
}

As a result, one request produces three identical log entries — with different messages but the same stack trace. Finding the "real" cause becomes harder.

Rule: log once — either where you handle the error and don't rethrow, or at the top level (@RestControllerAdvice). Intermediate layers only wrap and throw further.

Checklist for correct handling

  • catch is not empty: logging, rethrow, or an explicit action
  • the original exception is always passed as the cause
  • InterruptedException — restore the flag with Thread.currentThread().interrupt()
  • normal cases ("invalid format", "not found") — checks, not exceptions
  • log the error once, at the top level; intermediate layers don't log what they rethrow

Further reading

  • Error model and exception hierarchy — how to build your own exception hierarchy in an application
  • Global error handling — @RestControllerAdvice, Problem Details, and a single logging point
  • REST API errors and Problem Details — how to correctly return errors to the client per RFC 9457