Chapter 9: Exceptions

effective-java exceptions java best-practices

Book: Effective Java, 3rd Edition — Joshua Bloch
Status: 🟩 Complete
Difficulty: Medium-Hard
Items: 69-77 (9 items)
Time to complete: ~60 min


Overview

Exceptions are one of the most misused features in Java. Done well, they make programs more reliable, readable, and maintainable. Done poorly, they introduce performance cliffs, obscure bugs, and create APIs that are painful to use. This chapter covers the full exception lifecycle: when to throw, what to throw, what information to include, and when to swallow silently (almost never). The rules here are especially relevant during code review and API design — they separate senior engineers from juniors more than almost any other topic.

The central tension in this chapter is between checked exceptions (which enforce handling at compile time, but impose API burden) and unchecked exceptions (which are more flexible but can be ignored accidentally). Bloch’s advice cuts through this debate with concrete rules tied to recoverability. This chapter also intersects strongly with ch04-classes-and-interfaces (API design) and ch08-methods (method contracts).


Items

Item 69: Use Exceptions Only for Exceptional Conditions

The Problem

Exceptions carry significant overhead and exist to signal unexpected conditions. Using them for ordinary control flow is an antipattern that harms performance, readability, and correctness. A classic misuse is the “loop over array using exception as termination”:

// BAD: Using exceptions for control flow
try {
    int i = 0;
    while (true) {
        range[i++].climb();
    }
} catch (ArrayIndexOutOfBoundsException e) {
    // "clever" loop termination — don't do this
}

This is worse in every way: it is ~2x slower than a standard loop (JVM optimizations for exception paths are much weaker), it obscures intent, and it will silently swallow a real ArrayIndexOutOfBoundsException thrown from inside climb().

Another common case: iterators that throw instead of returning false from hasNext().

// BAD: state-dependent method without state-testing method
// Caller is forced to use exception for flow
try {
    while (true) {
        iterator.next();
    }
} catch (NoSuchElementException e) {
    // done
}

The Solution

Write APIs so that normal code paths require no exceptions. Provide state-testing methods (like hasNext()) or return Optional/sentinel values for expected absence:

// GOOD: Proper iteration with state-testing method
for (Iterator<Foo> i = collection.iterator(); i.hasNext(); ) {
    Foo foo = i.next();
    // use foo
}
 
// GOOD: Standard for-each (preferred when possible)
for (Foo foo : collection) {
    // use foo
}
 
// GOOD: Using Optional for expected absence
Optional<User> user = repository.findById(id);
user.ifPresent(u -> process(u));
// or:
User u = user.orElseThrow(() -> new UserNotFoundException(id));

For the array loop case:

// GOOD: Explicit bounds check, normal loop
for (int i = 0; i < range.length; i++) {
    range[i].climb();
}

Why This Works

The JVM and JIT optimize normal control flow heavily. Exception construction, stack unwinding, and handler lookup bypass these optimizations. More importantly, clear code with hasNext() is self-documenting. State-testing methods also compose cleanly with streams and lambdas.

When to Apply / When NOT to Apply

  • Apply: whenever you are tempted to catch an exception as part of a loop condition or normal flow.
  • Do NOT apply: do not always prefer Optional — for performance-critical hot paths with primitives, Optional adds allocation overhead. In those cases, sentinel values (like -1 for “not found” in index searches) are still appropriate.
  • Caveat: if the object’s state can change between a state-testing call and the state-dependent call (concurrent environment), an Optional or atomic return is better than hasNext()/next() because you avoid a TOCTOU (time-of-check/time-of-use) race.

Java 17 Update

No structural language changes. However, the widespread adoption of Streams and Optional since Java 8 has reinforced this item’s advice. Modern Java APIs consistently use Optional for “value or absent” rather than null or exception. Pattern matching for instanceof (Java 16 stable) and sealed types (Java 17) provide additional ways to express state without exceptions.


Item 70: Use Checked Exceptions for Recoverable Conditions and Runtime Exceptions for Programming Errors

The Problem

Many developers default to one or the other: either checked exceptions everywhere (making callers write boilerplate try/catch) or unchecked exceptions everywhere (allowing errors to propagate silently). Both extremes are wrong.

// BAD: Using RuntimeException for a recoverable I/O condition
public void readConfig(String path) {
    // Caller has no compiler-enforced obligation to handle this
    throw new RuntimeException("Config not found: " + path);
}
 
// BAD: Using checked exception for a programming error
public int getElement(int[] array, int index) throws ArrayAccessException {
    if (index < 0 || index >= array.length) {
        throw new ArrayAccessException("Bad index: " + index); // checked!
    }
    return array[index];
}

The Solution

Follow Bloch’s decision rule:

  • Checked exception: the caller can reasonably be expected to recover. The compiler enforces that the caller handles or propagates it.
  • RuntimeException (unchecked): a programming error — precondition violation, illegal state, etc. The caller cannot realistically recover; the program should crash and log.
  • Error: reserved for JVM-level failures (OutOfMemoryError, StackOverflowError). Never throw or catch Error directly in application code.
// GOOD: Checked exception for recoverable I/O failure
public Config readConfig(Path path) throws IOException {
    // Caller must decide: retry, use default, or propagate
    if (!Files.exists(path)) {
        throw new FileNotFoundException("Config not found: " + path);
    }
    return parse(Files.readString(path));
}
 
// GOOD: Unchecked for programming error (precondition violation)
public int getElement(int[] array, int index) {
    Objects.checkIndex(index, array.length); // throws IndexOutOfBoundsException
    return array[index];
}
 
// GOOD: Checked for external resource issues
public void sendEmail(String address, String body) throws MessagingException {
    // Caller can retry, log, notify user — it's recoverable
}

Why This Works

The contract between caller and implementor is encoded in the type system. When you declare a checked exception, you are saying “this can fail in a way you should plan for.” When you throw IllegalArgumentException, you are saying “you called this wrong — fix your code.” This distinction is load-bearing for API readability and correctness.

When to Apply / When NOT to Apply

  • Apply: in library/API code where callers you don’t control will use your methods.
  • Nuance: if there is nothing reasonable the caller can do (no retry strategy, no fallback), a checked exception just forces boilerplate. In that case, prefer unchecked and document it.
  • Internal/private code: unchecked is usually fine because you control both sides.

Java 17 Update

The checked vs. unchecked debate is structurally unchanged. However, the rise of functional interfaces and streams has created practical pressure against checked exceptions: Function<T, R>, Supplier<T>, Consumer<T> etc. do not declare checked exceptions. This means checked exceptions cannot be used inside lambdas without wrapping:

// Problem: checked exception in lambda
List<Path> paths = ...;
paths.stream()
     .map(p -> Files.readString(p))  // compile error: IOException not handled
     .collect(toList());
 
// Workaround option 1: wrap in unchecked
paths.stream()
     .map(p -> {
         try { return Files.readString(p); }
         catch (IOException e) { throw new UncheckedIOException(e); }
     })
     .collect(toList());
 
// Workaround option 2: use a utility method
paths.stream()
     .map(Unchecked.function(Files::readString))  // using ThrowingFunction pattern
     .collect(toList());

This friction is well-known and has reinforced the modern trend toward unchecked exceptions in new Java APIs.


Item 71: Avoid Unnecessary Use of Checked Exceptions

The Problem

Checked exceptions impose a burden on the caller: every caller must either handle or propagate the exception. When overused, they lead to catch blocks that do nothing useful, or massive throws clauses that pollute method signatures.

// BAD: Checked exception where caller can do nothing useful
public void loadPlugin(String name) throws PluginNotFoundException {
    // If this throws, the application cannot recover gracefully —
    // it's a deployment/config problem, not a runtime-recoverable event
}
 
// Caller forced to write:
try {
    loadPlugin("core");
} catch (PluginNotFoundException e) {
    throw new RuntimeException(e); // just wraps and re-throws — useless catch
}

The Solution

Before adding a checked exception, ask: “Is there something the caller can reasonably do when this happens?” If not, make it unchecked. Two refactoring options:

Option A: Convert to unchecked exception

// GOOD: If the caller cannot recover, unchecked is cleaner
public void loadPlugin(String name) {
    if (!pluginRegistry.contains(name)) {
        throw new IllegalArgumentException("Unknown plugin: " + name);
    }
    // ...
}

Option B: Return Optional instead of throwing

// GOOD: Optional makes absence explicit without forcing exception handling
public Optional<Plugin> findPlugin(String name) {
    return Optional.ofNullable(pluginRegistry.get(name));
}
 
// Caller code is now clean:
Plugin plugin = findPlugin("core")
    .orElseThrow(() -> new IllegalStateException("Core plugin not found"));

Option C: Split into state-testing + state-dependent (see Item 69)

// GOOD: Caller can check before calling
public boolean hasPlugin(String name) { ... }
public Plugin getPlugin(String name) { ... } // unchecked if not found

Why This Works

Every checked exception in a method signature is part of the API contract. Over time, these accumulate and become legacy burden — removing a checked exception is a binary-incompatible change (callers may catch it specifically). Start with fewer checked exceptions and add them only when the recovery story is clear.

When to Apply / When NOT to Apply

  • Apply: when you have a single checked exception in a method that a caller almost always wraps. That’s a sign it should be unchecked.
  • Do NOT apply: IOException, SQLException are well-justified checked exceptions — there is a real, common recovery path (retry, fallback, user notification). Don’t convert these to unchecked without thought.
  • Do NOT apply: if the method is part of a critical transaction path where callers must handle failures — the compiler enforcement is the point.

Java 17 Update

Stream APIs do not support checked exceptions natively. This has made the tension described above mainstream — many library authors now provide two versions of methods (one with checked, one without) or use UncheckedIOException as a wrapper. Java’s own NIO.2 APIs are a good model: Files.readString() throws IOException, but Files.lines() wraps IOExceptions from the underlying stream in UncheckedIOException.


Item 72: Favor the Use of Standard Exceptions

The Problem

Defining custom exception classes for common programming errors reinvents the wheel, pollutes the class hierarchy, and forces callers to learn new types.

// BAD: Custom exceptions for standard conditions
public class NegativeAmountException extends RuntimeException { ... }
public class EmptyCollectionException extends RuntimeException { ... }
public class InvalidStateException extends RuntimeException { ... }
 
// These replace perfectly good standard exceptions

The Solution

Use standard JDK exceptions whenever they fit. Key exceptions and their canonical uses:

// IllegalArgumentException: invalid parameter value
public void setAge(int age) {
    if (age < 0 || age > 150) {
        throw new IllegalArgumentException("Age out of range: " + age);
    }
}
 
// IllegalStateException: object state does not permit the call
public void start() {
    if (running) {
        throw new IllegalStateException("Server already running");
    }
}
 
// NullPointerException: null where prohibited
public void process(String input) {
    Objects.requireNonNull(input, "input must not be null");
}
 
// IndexOutOfBoundsException: index out of range
public T get(int index) {
    Objects.checkIndex(index, size); // throws IndexOutOfBoundsException
    return elements[index];
}
 
// UnsupportedOperationException: operation not supported
// (common in unmodifiable views, partial implementations)
@Override
public void add(int index, E element) {
    throw new UnsupportedOperationException("Unmodifiable list");
}
 
// ConcurrentModificationException: illegal concurrent modification
// ArithmeticException: arithmetic failure (e.g., divide by zero)
// NoSuchElementException: iterator exhausted

Standard Exception Quick-Reference:

ExceptionWhen to Use
IllegalArgumentExceptionParameter value inappropriate
IllegalStateExceptionObject state inappropriate for method
NullPointerExceptionNull where prohibited
IndexOutOfBoundsExceptionIndex out of range
UnsupportedOperationExceptionMethod not implemented/supported
ConcurrentModificationExceptionIllegal concurrent modification detected
ArithmeticExceptionArithmetic failure (e.g., / by zero)
NoSuchElementExceptionNo element at iterator position

Why This Works

Standard exceptions are instantly recognizable. They come with established meaning, documentation, and existing handler code. Reusing them reduces the cognitive load on callers and improves interoperability with frameworks.

When to Apply / When NOT to Apply

  • Apply: almost always for programming errors / precondition violations.
  • Custom exceptions ARE justified when: the error carries domain-specific state (e.g., PaymentDeclinedException with a declineCode field), when callers need to catch a specific type as part of a protocol, or when the domain vocabulary is rich enough that the standard name would be misleading.
  • Subclassing is fine: class InvalidUserIdException extends IllegalArgumentException is reasonable — you get the standard type hierarchy and can add fields.

Java 17 Update

Objects.checkIndex(int index, int length) was added in Java 9 — prefer it over manual bounds checks. Java 14 added helpful NullPointerExceptions (JEP 358) that show which variable was null without additional message strings.


Item 73: Throw Exceptions Appropriate to the Abstraction

The Problem

Exception translation is a real problem: low-level exceptions leak through abstraction layers and confuse callers. If your repository layer throws SQLException to the service layer, your service layer is now coupled to the persistence mechanism.

// BAD: SQLException leaking out of business logic layer
public class UserService {
    public User findById(long id) throws SQLException { // BAD: SQL detail
        return userDao.find(id);
    }
}
 
// Caller (Controller):
try {
    User user = userService.findById(42L);
} catch (SQLException e) {
    // Controller now knows about SQL — wrong abstraction
}

Another form: a list implementation that throws CloneNotSupportedException when appending. The caller wanted to add an element — they did not expect a cloning exception.

The Solution

Exception translation: catch lower-level exceptions and rethrow as appropriate higher-level exceptions.

// GOOD: Exception translation in the DAO layer
public class UserRepository {
    public User findById(long id) {
        try {
            return jdbcTemplate.queryForObject(SQL, mapper, id);
        } catch (EmptyResultDataAccessException e) {
            return null; // or throw domain-specific NotFoundException
        } catch (DataAccessException e) {
            throw new RepositoryException("Failed to find user: " + id, e); // chained
        }
    }
}
 
// Service layer stays clean:
public class UserService {
    public User findById(long id) {
        User user = repository.findById(id);
        if (user == null) throw new UserNotFoundException(id);
        return user;
    }
}

Exception chaining — always preserve the original cause:

// GOOD: Chained exception preserves root cause for diagnostics
try {
    lowLevelOperation();
} catch (LowLevelException e) {
    throw new HighLevelException("Failed to complete operation", e); // e is the cause
}
 
// Recovery of the cause:
catch (HighLevelException e) {
    Throwable cause = e.getCause(); // LowLevelException accessible for logging
}

Why This Works

Each layer of abstraction should only expose concepts from its own level. Exception translation enforces this discipline. Chaining ensures that diagnostic information is not lost — the original cause is preserved in the exception’s cause chain and will appear in stack traces.

When to Apply / When NOT to Apply

  • Apply: at every layer boundary (DAO → Service, Service → Controller, library → application).
  • Do NOT over-translate: if the exception IS appropriate at the higher level (e.g., IOException from a network component can propagate through a network service layer), translation adds noise without value.
  • Logging at translation point: if you translate, consider also logging the original exception at DEBUG level so that “exception ignored” doesn’t silently swallow information.

Java 17 Update

No structural changes. Spring and other frameworks have formalized this with their own exception hierarchy translations (e.g., DataAccessException). The pattern remains the same.


Item 74: Document All Exceptions Thrown by Each Method

The Problem

Undocumented exceptions are invisible parts of the method contract. Callers cannot handle what they cannot see. Conversely, a generic @throws Exception is nearly useless.

// BAD: No documentation — callers are in the dark
public void transferFunds(Account from, Account to, BigDecimal amount) {
    // Can throw: IllegalArgumentException, InsufficientFundsException,
    // AccountFrozenException, ConcurrentModificationException...
    // Caller has no idea
}
 
// BAD: Lazy "throws Exception" tells callers nothing
/**
 * @throws Exception if something goes wrong
 */
public void process() throws Exception { ... }

The Solution

Document every exception — both checked and unchecked — with @throws in Javadoc. For unchecked exceptions (which cannot be listed in the throws clause), document them separately. Be specific about the conditions:

/**
 * Transfers the specified amount from the source account to the destination.
 *
 * @param from   the account to debit; must not be null
 * @param to     the account to credit; must not be null
 * @param amount the amount to transfer; must be positive
 *
 * @throws IllegalArgumentException     if {@code amount} is not positive,
 *                                      or if {@code from} and {@code to} are the same account
 * @throws NullPointerException         if {@code from} or {@code to} is null
 * @throws InsufficientFundsException   if {@code from} has insufficient balance
 * @throws AccountFrozenException       if either account is frozen
 */
public void transferFunds(Account from, Account to, BigDecimal amount)
        throws InsufficientFundsException, AccountFrozenException {
    Objects.requireNonNull(from, "from");
    Objects.requireNonNull(to, "to");
    if (amount.compareTo(BigDecimal.ZERO) <= 0) {
        throw new IllegalArgumentException("Amount must be positive: " + amount);
    }
    // ...
}

For interfaces, document exceptions on the interface method:

/**
 * @throws UnsupportedOperationException if this list does not support the add operation
 * @throws ClassCastException            if the element's class prevents it from being added
 * @throws NullPointerException          if the specified element is null and this list
 *                                       does not permit null elements
 */
boolean add(E e);

Why This Works

Documentation is the API. Callers generate their error handling from what they can read. If you document @throws NullPointerException if from is null, every reader immediately knows to null-check before calling. This prevents defensive over-catching (catch (Exception e)) and enables precise handling.

When to Apply / When NOT to Apply

  • Apply: always for public API. Always for protected methods in superclasses (subclasses and callers depend on them).
  • Apply to unchecked exceptions too, especially for commonly thrown ones like NullPointerException and IllegalArgumentException.
  • Private methods: full Javadoc is less critical, but inline comments explaining exception conditions are still valuable.
  • Do NOT document internal implementation details — say what condition triggers the exception, not how it is implemented.

Java 17 Update

No language changes. Tooling (IDE inspections, Checkstyle, PMD) has improved at flagging undocumented exceptions. Java 14’s helpful NPE messages (JEP 358) reduce the urgency of documenting which parameter causes NPE, but documentation is still best practice.


Item 75: Include Failure-Capture Information in Detail Messages

The Problem

Exception messages that say “Index out of bounds” or “Operation failed” are nearly useless for diagnosing production failures. Without the actual values involved, a developer reading a log must reproduce the scenario to understand what happened.

// BAD: Useless detail message
throw new IndexOutOfBoundsException("Index out of bounds");
 
// BAD: No context
throw new IllegalStateException("Connection failed");
 
// BAD: Generic message, no values captured
public class BankAccount {
    public void withdraw(BigDecimal amount) {
        if (amount.compareTo(balance) > 0) {
            throw new InsufficientFundsException("Insufficient funds"); // which amount? which balance?
        }
    }
}

The Solution

Include all values that are relevant to the failure. The message should contain everything needed to reproduce or understand the problem from the log alone:

// GOOD: Specific values captured
throw new IndexOutOfBoundsException(
    "Index " + index + " out of bounds for length " + length);
// (Java 9+ IndexOutOfBoundsException(int index) does this automatically)
 
// GOOD: Connection context included
throw new IllegalStateException(
    "Connection to " + host + ":" + port + " failed after " + retries + " retries");
 
// GOOD: All relevant values in the exception
public class BankAccount {
    private final long accountId;
    private BigDecimal balance;
 
    public void withdraw(BigDecimal amount) {
        if (amount.compareTo(balance) > 0) {
            throw new InsufficientFundsException(accountId, amount, balance);
        }
        balance = balance.subtract(amount);
    }
}
 
// Custom exception captures structured data
public class InsufficientFundsException extends RuntimeException {
    private final long accountId;
    private final BigDecimal requested;
    private final BigDecimal available;
 
    public InsufficientFundsException(long accountId, BigDecimal requested, BigDecimal available) {
        super(String.format(
            "Account %d: requested %.2f but only %.2f available",
            accountId, requested, available));
        this.accountId = accountId;
        this.requested = requested;
        this.available = available;
    }
 
    // Accessors for programmatic use
    public long getAccountId() { return accountId; }
    public BigDecimal getRequested() { return requested; }
    public BigDecimal getAvailable() { return available; }
}

Why This Works

When an exception is caught at a handler far from the throw site (or logged in production), the values at the time of the exception are gone. The message is the only record. Including them enables diagnosis without reproduction.

Note: don’t confuse the detail message (for developers) with the user-facing message. Never show raw exception messages to end users — translate them to user-friendly strings separately.

When to Apply / When NOT to Apply

  • Apply: always for domain exceptions, and for any exception in production code.
  • Security note: do NOT include passwords, security tokens, PII, or credit card numbers in exception messages. These end up in logs and crash reports.
  • Do NOT write essays — include the values, not a description of what the method was trying to do.

Java 17 Update

Java 9 added IndexOutOfBoundsException(int index) and Objects.checkIndex() which generate useful messages automatically. Java 14 (JEP 358) adds helpful NPE messages from the JVM itself (“Cannot invoke X because Y is null”), reducing the need to manually craft NPE messages.


Item 76: Strive for Failure Atomicity

The Problem

Failure atomicity means: a method that fails should leave the object in the state it was in before the call. Without this, a half-executed operation can leave an object in a corrupt, inconsistent state — and subsequent calls will behave unpredictably.

// BAD: Not failure atomic — modifies size before checking element
public class Stack<E> {
    private Object[] elements;
    private int size = 0;
 
    public E pop() {
        if (size == 0)
            throw new EmptyStackException();
        E result = (E) elements[--size]; // decrements size first
        elements[size] = null;
        return result;
    }
    // If the cast throws ClassCastException, size is already decremented — CORRUPT STATE
}
 
// More subtle: two operations, first succeeds, second fails
public void addTransaction(Account from, Account to, BigDecimal amount) {
    from.debit(amount);   // succeeds — money gone
    to.credit(amount);    // throws — money is now lost!
}

The Solution

Several strategies for failure atomicity:

Strategy 1: Check preconditions before mutation

// GOOD: All parameter checks before any state changes
public E pop() {
    if (size == 0)
        throw new EmptyStackException(); // check before mutation
    E result = (E) elements[--size];
    elements[size] = null;
    return result;
    // The cast is safe because we only put E objects in — but if it weren't,
    // reorder: compute first, mutate after
}
 
// GOOD: Check all preconditions before any side effects
public void transferFunds(Account from, Account to, BigDecimal amount) {
    // All checks first
    if (amount.compareTo(BigDecimal.ZERO) <= 0)
        throw new IllegalArgumentException("Amount must be positive");
    if (from.getBalance().compareTo(amount) < 0)
        throw new InsufficientFundsException(...);
    if (to.isFrozen())
        throw new AccountFrozenException(...);
    // Now mutate
    from.debit(amount);
    to.credit(amount);
}

Strategy 2: Operate on a temporary copy, swap only on success

// GOOD: Sort a copy, replace original only if successful
public void sort(List<String> list) {
    List<String> temp = new ArrayList<>(list); // defensive copy
    Collections.sort(temp);                     // may throw
    list.clear();
    list.addAll(temp);                          // only if sort succeeded
}

Strategy 3: Write recovery code (rollback)

// GOOD: Undo state changes on failure (used when check-first is impractical)
public void addToSet(Set<E> set, E element) {
    boolean added = set.add(element);
    try {
        doSomethingThatMayFail(element);
    } catch (Exception e) {
        if (added) set.remove(element); // rollback
        throw e;
    }
}

Strategy 4: Immutable objects (naturally failure atomic)

// GOOD: Immutable — no partial state to corrupt
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;
 
    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new CurrencyMismatchException(this.currency, other.currency);
        return new Money(this.amount.add(other.amount), this.currency);
        // original Money unchanged; new object created only on success
    }
}

Why This Works

Failure atomicity makes it possible to catch exceptions and retry or continue. Without it, caught exceptions must be treated as fatal because the object state is unknown. This multiplies defensive coding overhead throughout the codebase.

When to Apply / When NOT to Apply

  • Apply: for mutable domain objects whenever possible.
  • Cannot always apply: concurrent operations where multiple threads can observe intermediate state — full atomicity requires synchronization or immutability. See ch10-concurrency.
  • Cost may exceed benefit: if rollback is prohibitively expensive (e.g., network operations where side effects are external), document clearly that the method is NOT failure-atomic and specify what state the object is in after failure.

Java 17 Update

No structural changes. The record feature (Java 16+) makes immutable data classes trivial to write, which naturally gives failure atomicity to those types:

// Records are inherently failure atomic — fully constructed or not at all
record Money(BigDecimal amount, Currency currency) {
    Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        if (amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Amount cannot be negative: " + amount);
    }
}

Item 77: Don’t Ignore Exceptions

The Problem

An empty catch block is almost always a bug. It silently absorbs a signal that something went wrong and allows the program to continue in an unknown state.

// BAD: Silent exception swallowing
try {
    FileInputStream fis = new FileInputStream("config.properties");
    // ...
} catch (IOException e) {
    // ??? No log, no rethrow, no comment — problem completely hidden
}
 
// BAD: Even with a log, continuing normally after failure is wrong
try {
    database.commit();
} catch (SQLException e) {
    log.warn("Commit failed"); // logged but not rethrown — data silently lost
}
 
// BAD: Wrapping in RuntimeException but still swallowing
try {
    riskyOperation();
} catch (Exception e) {
    // forgot to throw new RuntimeException(e);
}

The Solution

At a minimum, log the exception. Better: rethrow it. If you genuinely must ignore it, document WHY:

// GOOD: Rethrow as unchecked if you can't handle it
try {
    return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
    throw new IllegalStateException("Failed to serialize: " + value, e);
}
 
// GOOD: Handle with genuine logic
try {
    config = loadConfig(path);
} catch (IOException e) {
    log.warn("Could not load config from {}, using defaults: {}", path, e.getMessage());
    config = Config.defaultConfig();
}
 
// GOOD: The only legitimate empty catch — explain why
Future<Integer> f = executor.submit(task);
try {
    f.get(1, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    // Timeout is expected on the happy path — we check result separately
    // This exception carries no diagnostic value here
}
 
// ACCEPTABLE: Closing resources in finally — but use try-with-resources instead
try {
    connection.close();
} catch (SQLException e) {
    // Ignore: we're in cleanup, nothing to do — but log it
    log.debug("Error closing connection (ignored)", e);
}

Why This Works

An ignored exception is a lie: the code says “I handled it” but actually discarded the signal. Bugs that cause ignored exceptions often don’t surface until much later — in a completely different part of the system — making them exponentially harder to diagnose. At minimum, every catch block should have a line explaining what is happening.

When to Apply / When NOT to Apply

  • Apply: always. There is almost no case where a fully empty catch block is correct.
  • If you must have an empty catch (extremely rare), add a comment naming the exception variable (not just e) to make clear it was intentional: } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
  • Re-interrupt after catching InterruptedException: catching InterruptedException clears the interrupt flag. Always call Thread.currentThread().interrupt() to restore it.
// GOOD: Handling InterruptedException correctly
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // restore interrupt status
    log.info("Thread interrupted during sleep");
    return; // or handle shutdown
}

Java 17 Update

Try-with-resources (Java 7+) eliminates the most common source of swallowed exceptions in finally blocks. Java 9’s Cleaner API further reduces the need for manual resource cleanup. Use try-with-resources everywhere instead of manual finally close patterns.


Interview Questions & Exercises

Q1: When should you use a checked exception vs. an unchecked exception?

Context: Asked in almost every senior Java interview, often as a warm-up for API design questions.
Answer: Use a checked exception when the caller can reasonably be expected to recover from the failure — the compiler enforces that the caller plans for it. Use an unchecked (RuntimeException) when the exception signals a programming error or a condition where no reasonable recovery is possible. The canonical test: “Is there a realistic recovery path?” For IOException — yes, retry or fall back. For IllegalArgumentException — no, fix the calling code. Errors (like OutOfMemoryError) are reserved for JVM-level failures and should not be caught or thrown in application code.
Follow-up: “What changed about this decision with Java streams?” Checked exceptions cannot be used in standard functional interfaces, which has pushed modern Java APIs toward unchecked exceptions and Optional.


Q2: What is exception translation and why is it important?

Context: Asked when discussing layered architecture or Spring/JPA code.
Answer: Exception translation means catching a lower-level exception and rethrowing it as a higher-level one that matches the current abstraction layer. For example, a repository should not let SQLException escape into the service layer — it should translate it to a domain exception. This keeps layers decoupled: the service layer does not need to know about SQL. Always use exception chaining (new HighLevelException("msg", cause)) to preserve the original exception for diagnostics.
Follow-up: “How does Spring handle this?” Spring’s @Repository annotation triggers PersistenceExceptionTranslationPostProcessor, which automatically translates JPA/Hibernate exceptions to Spring’s DataAccessException hierarchy.


Q3: What is failure atomicity? Why does it matter, and what are the strategies to achieve it?

Context: Asked in senior/principal interviews around API design and reliability.
Answer: Failure atomicity means a method that throws an exception leaves the object in the state it was in before the call. Without it, a caught exception means the object may be in a corrupt, half-modified state. Four strategies: (1) check preconditions before any mutation; (2) operate on a defensive copy and swap only on success; (3) write rollback/recovery code to undo mutations on failure; (4) use immutable objects, which are naturally failure atomic. Immutable objects are the most elegant solution.
Follow-up: “Can you always achieve failure atomicity?” No — concurrent operations and external side effects (network calls, DB writes) can make it impossible or prohibitively expensive. In those cases, document the failure behavior clearly.


Q4: What information should you include in an exception’s detail message?

Context: Asked during code review discussions or debugging exercises.
Answer: Include all values relevant to diagnosing the failure — the actual values passed (not just their names), the limits that were violated, and any contextual identifiers (account IDs, resource URLs, etc.). Example: instead of “Index out of bounds”, use “Index 42 out of bounds for length 10”. Do NOT include passwords, tokens, or PII. The detail message is for developers reading logs, not for end users — translate separately for user-facing messages.
Follow-up: “What JVM feature in Java 14 helps with this?” JEP 358 (Helpful NullPointerExceptions) — the JVM now generates messages like “Cannot invoke String.length() because ‘str’ is null” automatically.


Q5: What happens when you catch InterruptedException and do nothing with it?

Context: Common in threading questions and code review scenarios.
Answer: Catching InterruptedException clears the thread’s interrupt flag. If you silently catch it and do nothing, you have hidden the interrupt signal from the rest of the call stack — anything further up that checks Thread.isInterrupted() will not see it. This can prevent graceful shutdown in thread pools and executors. The correct approaches are: (1) let InterruptedException propagate (re-declare in your throws clause); (2) if you must catch it, call Thread.currentThread().interrupt() to restore the interrupt status before returning or continuing.
Follow-up: “When is it acceptable to swallow InterruptedException?” In a main thread that runs a task loop and handles interrupts by exiting — but even then, you should log it and terminate cleanly.


Q6: Why is an empty catch block almost always wrong?

Context: Asked in code review or “what’s wrong with this code” exercises.
Answer: An empty catch block silently discards the exception signal. The program continues as if nothing went wrong, but it has entered an unknown state. Bugs that cause silently swallowed exceptions often manifest much later in completely unrelated code, making them extremely hard to diagnose. The minimum acceptable response is logging the exception. If you genuinely intend to ignore an exception (extremely rare), name the variable ignored and add a comment explaining why.
Follow-up: “When would you argue an empty catch is acceptable?” For close() calls inside finally blocks when you’re already propagating another exception — but the solution is try-with-resources, which handles this correctly and automatically (it appends suppressed exceptions).


Q7: A method declares throws Exception. What are the problems with this?

Context: Common in junior-to-senior level reviews, especially in test or legacy code.
Answer: Three major problems: (1) It tells callers nothing useful about what can fail or why — they must catch Exception which also catches RuntimeException and degrades error handling. (2) It locks in a vague contract that is hard to refine later — adding a specific exception later is a compatible change, but removing throws Exception may break callers. (3) It encourages callers to write broad catch (Exception e) blocks which accidentally swallow programming errors. The fix: declare the specific exceptions that can be thrown, and document them with @throws.
Follow-up: “Is throws Exception ever OK?” In test helper methods and infrastructure code where you don’t want to deal with exception translation — but even then, throws IOException or similar is better than the most general form.


Q8: Describe a situation where you would create a custom exception class instead of using a standard JDK exception.

Context: Asked in API/library design discussions.
Answer: Create a custom exception when: (1) the exception carries domain-specific state that callers need programmatically (e.g., InsufficientFundsException with getRequested() and getAvailable() methods); (2) callers need to catch this specific type as part of a protocol (e.g., a retry framework that catches RetryableException); (3) the domain vocabulary is rich enough that a standard exception name would be misleading. When creating custom exceptions, always extend the most specific applicable standard exception (e.g., extend IllegalArgumentException rather than RuntimeException directly) so callers can catch at either level.
Follow-up: “Should a custom exception be checked or unchecked?” Apply the same rule: checked if callers can realistically recover, unchecked if it signals a programming error or non-recoverable condition.


Key Takeaways

  • Exceptions are for exceptional conditions — never use them for ordinary control flow (no loop-termination-by-exception patterns).
  • Checked = recoverable, unchecked = programming error. When in doubt, ask if the caller has a realistic recovery path.
  • Checked exceptions and Java streams don’t mix well — use UncheckedIOException or similar wrappers when using streams.
  • Favor standard JDK exceptions (IllegalArgumentException, IllegalStateException, NullPointerException) over custom types unless you need domain-specific state.
  • Exception translation is required at every abstraction layer boundary — low-level exceptions must not leak upward. Always chain the original cause.
  • Detail messages must contain the actual values involved, not just descriptions — they are the only diagnostic record in production.
  • Failure atomicity: leave the object unchanged if the method throws. Preferred strategy: check preconditions before all mutations.
  • Never ignore exceptions silently. At minimum, log them. For InterruptedException, always restore the interrupt flag with Thread.currentThread().interrupt().
  • Document every exception — checked and unchecked — with @throws Javadoc. The exceptions are part of the API contract.
  • Records (Java 16+) provide failure atomicity for free — consider immutable data types to eliminate partial-mutation bugs.

Last Updated: 2026-05-10