← Back to the section

JAVA · EXCEPTION SAFETY Phase 14. Exception antipatterns

Common Pitfalls in Exception Handling

Java

Preserve the failure cause, thread interruption and useful logs instead of silently losing the error.

1. An empty catch erases the failure

1 / 5

A caught exception must be handled, translated or rethrown. Continuing as though the operation succeeded corrupts the result.

  • Do not return null as a fake result.
  • Fallback needs explicit semantics.
  • A metric alone does not handle the failure.
SQLException catch { }no handling "Success"false result

2. A translated exception preserves its cause

2 / 5

throw new OrderException("...", e) retains the original stack trace. Dropping the cause leaves only the wrapper location.

  • The message adds business context.
  • The cause shows the technical origin.
  • Do not expose SQL and parameters to clients.
OrderExceptionmessage: create order failedcause ↓ SQLExceptionoriginal stack tracePgConnection.execute Both contexts remain available for diagnosis

3. InterruptedException restores the interrupt flag

3 / 5

If the method does not propagate interruption, call Thread.currentThread().interrupt(). Otherwise shutdown and cancellation stop working.

  • Do not turn interruption into an ordinary retry.
  • Finish work cooperatively.
  • Release resources in finally.
sleep / wait catch InterruptedExceptioncurrentThread().interrupt()return / cleanup Worker stops

4. Log-and-throw duplicates one failure

4 / 5

One exception passes through repository, service and advice. Logging it at every layer creates three entries for the same cause.

  • Log where you handle the error.
  • Add context while propagating without logging again.
  • Use a correlation ID for related events.
Repositorythrow Servicethrow Global handlerone ERROR + traceId One failure, one primary log entry

5. Exceptions do not replace ordinary result branches

5 / 5

Expected outcomes such as no search match belong in Optional or a result type. Exceptions represent a violated expectation.

findOrder(id) No matching rowOptional.empty() Database unavailableDataAccessException

← 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.

The two most common ones share a single price — the log entry stops pointing at the failure point: here is the same SQLException under three variants of catch in the repository.

POST /orders — failure in JDBC driver, frame 4 of 4 frame 1OrderController frame 2OrderService frame 3OrderRepository frame 4JDBC driverSQLExceptionfailure point catch (SQLException e) { }repository returned null, NPE in the controllerlog shows frame 1, not a word about SQL3 frames to the failure throw new OrderStorageException(msg)log shows frame 3, trace from repositoryno table name, no driver code1 frame to the failure throw new OrderStorageException(msg, e)log shows Caused by: SQLException, frame 4table orders and driver code are visible0 — the cause is in the entry only a cause in the constructor points the log at the failure

The same error: an empty catch moves the search three frames above the failure point, a wrapper without cause moves it one, and only cause leaves a log entry with the real cause.

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