Ch. 7 · Java

Java Exception Handling: Where to Catch, Wrap and Rethrow

Checked vs unchecked exceptions in Java, where to catch, when to wrap with a cause, and how to avoid swallowing failures at the wrong layer.

~6 min readintermediateupdated Oct 6, 2026

Interviewers phrase this in many ways: “What is the difference between checked and unchecked exceptions?”, “Where should you catch an exception?”, or “What is wrong with this catch block?” followed by catch (Exception e) { e.printStackTrace(); }. Underneath, they are asking whether you can decide which layer of an application owns a failure.

Poor exception handling is one of the most common review comments in Java codebases. Swallowed exceptions hide outages, lost causes make stack traces useless, and catching too broadly turns programming errors into silent wrong answers. This guide gives you a principled answer.

Before you start

You should know try, catch, finally and throw, and the class hierarchy: Throwable splits into Error and Exception, and RuntimeException is the subclass of Exception that the compiler does not force you to declare. Basic layered architecture (controller, service, repository) helps for the scenario.

The short answer

Checked exceptions (subclasses of Exception but not RuntimeException) must be declared or caught and represent conditions a caller might reasonably recover from, such as a missing file. Unchecked exceptions represent programming errors or broken invariants, such as NullPointerException or IllegalArgumentException. Catch an exception only where you can recover, translate it into the abstraction of the current layer, or add context; when you wrap, always keep the original as the cause. Everything else should propagate to a single boundary handler that logs it once and returns a safe response.

How it works

The compiler enforces checked exceptions through method signatures:

String readConfig(Path path) throws IOException {   // must be declared
    return Files.readString(path);
}
java

Callers either handle IOException or declare it themselves. Unchecked exceptions propagate without declarations. Error subclasses such as OutOfMemoryError signal JVM-level problems that applications should almost never catch.

Three legitimate reasons exist for a catch block:

  1. Recover. You have a real alternative: retry, use a default, try a secondary source.
  2. Translate. A low-level exception leaks implementation details into a higher layer. Convert it into a domain exception, keeping the cause.
  3. Add context. Wrap with a message that says what the code was trying to do, such as which order id failed.

Logging and rethrowing at every layer is not on the list. It produces the same stack trace five times in the logs.

Exception chaining preserves the root cause:

public Customer load(String id) {
    try {
        return customerDao.find(id);          // declared as: throws SQLException
    } catch (SQLException e) {
        throw new CustomerLookupException("Could not load customer " + id, e); // cause kept
    }
}
java

The resulting stack trace prints both, with a Caused by: section that points to the original SQL failure.

Step-by-step walkthrough

Step 1: Classify the failure

Ask who can fix it. If the caller could reasonably react (a user entering a bad file path), a checked exception or a result type communicates that. If the failure means the code is wrong (null argument, impossible state), throw an unchecked exception and let it propagate:

public Money withdraw(Money amount) {
    Objects.requireNonNull(amount, "amount");
    if (amount.isNegative()) {
        throw new IllegalArgumentException("amount must be positive: " + amount);
    }
    if (balance.lessThan(amount)) {
        throw new InsufficientFundsException(accountId, amount);   // domain rule
    }
    balance = balance.minus(amount);
    return balance;
}
java

Step 2: Translate at layer boundaries

A service should not throw SQLException to a controller, and a REST client should not throw Jackson parsing exceptions to business code. Define a small set of domain exceptions per layer:

public class PaymentGatewayException extends RuntimeException {
    public PaymentGatewayException(String message, Throwable cause) {
        super(message, cause);
    }
}
java

Many modern codebases, including Spring, prefer unchecked domain exceptions, because checked ones force every intermediate method to declare details it cannot act on.

Step 3: Handle once at the boundary

Pick one place per entry point to turn unexpected exceptions into a response and a log entry. In Spring Boot 3 that is a @RestControllerAdvice:

@RestControllerAdvice
class ApiErrors {
    private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);

    @ExceptionHandler(InsufficientFundsException.class)
    ProblemDetail insufficient(InsufficientFundsException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception e) {
        log.error("Unhandled error", e);   // logged once, with full stack trace
        return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
java

For background workers the boundary is the loop that pulls messages: catch, log, and decide whether to retry or dead-letter.

Step 4: Preserve interruption and secondary failures

If you catch InterruptedException, restore the flag with Thread.currentThread().interrupt() before translating it. If cleanup code can also throw, attach its exception with addSuppressed rather than replacing the primary one; try-with-resources does this automatically.

Worked scenario

A nightly import job processes 50,000 rows. The original code:

for (Row row : rows) {
    try {
        importRow(row);
    } catch (Exception e) {
        // ignore bad rows
    }
}
java

One night a database credential rotates. Every importRow throws a connection exception, every one is swallowed, and the job reports success after importing nothing. The empty catch also hides genuine bugs like NullPointerException in the mapping code.

The fix separates expected per-row failures from failures that should stop the job:

int failed = 0;
for (Row row : rows) {
    try {
        importRow(row);
    } catch (InvalidRowException e) {          // expected, recoverable per row
        failed++;
        log.warn("Skipping row {}: {}", row.number(), e.getMessage());
    }
}
if (failed > rows.size() / 10) {
    throw new ImportFailedException("Too many invalid rows: " + failed);
}
java

importRow throws InvalidRowException only for data problems it detected. Connection errors and bugs propagate out of the loop, fail the job, and alert someone. The catch is narrow, the recovery is real, and the summary check guards against silent partial imports.

Common mistake

  • Catching Exception or Throwable deep in the code. It also catches bugs and, for Throwable, errors like OutOfMemoryError.
  • Dropping the cause. throw new ServiceException(e.getMessage()) loses the original stack trace. Pass e as the cause.
  • Log and rethrow everywhere. Duplicate stack traces make incidents harder to read. Log at the boundary.
  • Using exceptions for normal control flow. Throwing to exit a loop or signal “not found” in a hot path is slow and obscure; return Optional or a result.
  • Returning from finally. A return in finally discards any exception thrown in the try block.

Verify the behavior

Test that translation keeps the cause and that the boundary maps exceptions correctly:

import static org.junit.jupiter.api.Assertions.*;
import java.sql.SQLException;
import org.junit.jupiter.api.Test;

class TranslationTest {
    static class LookupException extends RuntimeException {
        LookupException(String m, Throwable c) { super(m, c); }
    }

    static String load(String id) {
        try {
            throw new SQLException("connection refused");
        } catch (SQLException e) {
            throw new LookupException("Could not load " + id, e);
        }
    }

    @Test
    void causeIsPreserved() {
        var ex = assertThrows(LookupException.class, () -> load("c-1"));
        assertEquals("Could not load c-1", ex.getMessage());
        assertInstanceOf(SQLException.class, ex.getCause());
    }
}
java

In a Spring Boot app, a @WebMvcTest with MockMvc can assert that a thrown domain exception becomes the expected HTTP status.

Follow-up questions

Is it ever right to catch RuntimeException? At a boundary, yes: a worker loop or request handler should not die because of one bad message. Deep in business code, rarely.

What happens to an exception thrown in finally? It replaces the original exception, which is lost unless you add it as suppressed. try-with-resources avoids this.

Why does Spring translate exceptions into DataAccessException? So service code depends on a stable, unchecked hierarchy instead of vendor-specific SQLException codes, while the original exception stays available as the cause.

Should custom exceptions be checked or unchecked? Unchecked by default for domain errors in layered applications; checked when the immediate caller is expected to handle it, as with IOException in a file utility.

Interview exercise

A method calls an external API and catches IOException by returning null. Callers then fail with NullPointerException far from the call. Rewrite the contract in two ways and explain when each fits.

Answer and reasoning

Option one: make absence explicit with Optional<Response> if “no response” is a normal, expected outcome, for example a cache lookup that may miss. Callers then must decide what to do, and the compiler nudges them through the API.

Option two: translate the failure, throw new ExternalServiceException("Pricing API call failed for " + sku, e), if an I/O error is unexpected and the caller cannot do anything useful locally. The boundary handler turns it into a 503 or a retry, and the cause preserves the network details.

Returning null combines the worst of both: it hides the cause and moves the failure to an unrelated line. The interviewer is looking for the decision rule: expected absence is a value, unexpected failure is an exception that keeps its cause.

Continue learning

Practise more with the Java interview questions and the Java MCQs. Related notes: try-with-resources and cleanup, Optional as an absence contract and fixing NullPointerException. The official tutorial on Java exceptions covers the language rules.

More in Java

read ✓Java · easy

Checked vs Unchecked Exceptions in Java

Checked exceptions must be caught or declared; unchecked ones needn't be. See the compiler rules, the lambda problem and when to use each.

~7 min readread →
read ✓Java · hard

Java CompletableFuture Error Composition

How exceptions travel through CompletableFuture chains, when to use exceptionally, handle or whenComplete, and why causes arrive wrapped.

~7 min readread →
read ✓Java · easy

Java Immutable Objects and Defensive Copies

How to make a class immutable in Java: final fields, no setters, defensive copies of mutable inputs and outputs, and safe publication.

~6 min readread →
esc