PrepZone Logo
PrepZone

Designing for Failure

Custom exceptions, what to log, the final/finally/finalize confusion, and anti-patterns to drop.

Why this matters

  • The difference between a five-minute fix and a five-hour investigation is almost always what was in the exception, and whether anything swallowed it.
  • Custom exceptions let callers handle specific failures instead of parsing error strings, which is the difference between an API and a guess.
  • final, finally and finalize sound alike, are unrelated, and get asked together constantly.
try block starts
No — try completes
Yes — matching catch runs
finally always runsClose resources here
finally runs on every path — success, handled failure, or an exception on the way out. That is why cleanup belongs there, or better still in try-with-resources.

Custom exceptions

Define one when callers need to distinguish your failure from everything else. Give it the fields a caller or a log would need.

Java
public class InsufficientFundsException extends RuntimeException {

    private final String accountId;
    private final BigDecimal requested;
    private final BigDecimal available;

    public InsufficientFundsException(String accountId, BigDecimal requested, BigDecimal available) {
        super("Account %s has %s but %s was requested"
                .formatted(accountId, available, requested));
        this.accountId = accountId;
        this.requested = requested;
        this.available = available;
    }

    public String accountId()     { return accountId; }
    public BigDecimal requested() { return requested; }
    public BigDecimal available() { return available; }

    public BigDecimal shortfall() {
        return requested.subtract(available);
    }
}

Design guidance

  • Extend RuntimeException unless the caller has a realistic recovery path. Most application failures do not.
  • Always offer a constructor taking a cause, so wrapping never loses the original.
  • Carry structured data as fields, not only in the message. A caller should not have to parse text to learn the account id.
  • End the name in Exception. This is a universal convention and deviating from it looks like a mistake.
  • Do not create one per method. A family of three or four well-named exceptions per module is plenty.

Anti-patterns to drop

The empty catch block

Java
try {
    riskyOperation();
} catch (Exception e) {
    // nothing
}

The failure happened, and now nobody will ever know. This turns a clear error into mysterious wrong behaviour somewhere else entirely. If you genuinely expect and can ignore a specific exception, say so:

Java
try {
    Files.delete(temporaryFile);
} catch (NoSuchFileException e) {
    // Already gone, which is the outcome we wanted.
}

A comment explaining why ignoring is correct is what separates this from the anti-pattern.

printStackTrace

Java
catch (IOException e) {
    e.printStackTrace();      // goes to stderr, unstructured, no context, invisible in production
}

It writes to standard error outside your logging framework, so it carries no timestamp, no log level, no request identifier, and will not appear in your log aggregation. Use the logger and pass the exception as the last argument so the framework formats the trace:

Java
catch (IOException e) {
    log.error("Failed to read config from {}", path, e);
}

Losing the cause

Java
catch (SQLException e) {
    throw new DataAccessException("Query failed");      // the real reason is gone
}

Always pass it through. This is the most expensive single habit in exception handling.

Exceptions as control flow

Java
// Wrong: an exception per iteration, each filling in a stack trace
try {
    while (true) {
        process(iterator.next());
    }
} catch (NoSuchElementException e) {
    // loop finished
}

Constructing an exception captures the stack, which is far more expensive than a comparison. Use the condition that exists for the purpose — while (iterator.hasNext()).

Catching what you cannot handle

Java
catch (Exception e) {              // also catches every bug in the block
    return defaultValue;
}

A NullPointerException from your own mistake now silently returns a default. Catch the specific types you can genuinely act on, and let the rest reach a handler that reports them.

AspectAnti-patternWhat to do instead
Empty catchFailure disappearsLog it, or comment why ignoring is correct
e.printStackTrace()Outside your logging pipelinelog.error(message, args, e)
Dropping the causeRoot cause unrecoverablePass the original as the cause
catch (Exception)Hides your own bugsCatch the specific types you handle
Exceptions in a loopStack capture per iterationUse the normal condition
throws ExceptionTells callers nothingDeclare the specific types
  • Empty catch

    Anti-patternFailure disappears
    What to do insteadLog it, or comment why ignoring is correct
  • e.printStackTrace()

    Anti-patternOutside your logging pipeline
    What to do insteadlog.error(message, args, e)
  • Dropping the cause

    Anti-patternRoot cause unrecoverable
    What to do insteadPass the original as the cause
  • catch (Exception)

    Anti-patternHides your own bugs
    What to do insteadCatch the specific types you handle
  • Exceptions in a loop

    Anti-patternStack capture per iteration
    What to do insteadUse the normal condition
  • throws Exception

    Anti-patternTells callers nothing
    What to do insteadDeclare the specific types

Every one of these makes a failure harder to diagnose later, which is the only cost that matters.

What to log, and once

Java
// Wrong: logged at every layer, producing four traces for one failure
public Order load(String id) {
    try {
        return repository.find(id);
    } catch (SQLException e) {
        log.error("Database error", e);          // logged here
        throw new OrderException("Load failed", e);   // and again by the caller, and again above that
    }
}
Java
// Right: wrap with context here, log once where it is handled
public Order load(String id) {
    try {
        return repository.find(id);
    } catch (SQLException e) {
        throw new OrderException("Could not load order " + id, e);
    }
}
  • Log where you handle, not where you catch and rethrow. One failure should produce one trace.
  • Add context as you wrap — the order id, the file path, the user. The stack trace says where; the message must say what.
  • Never log the data itself when it is sensitive. Passwords, tokens, card numbers and personal data do not belong in a log line.
  • Use the right level. error for something needing attention, warn for a recovered problem, info for normal events.

InterruptedException is special

Swallowing this one breaks a mechanism the whole platform depends on.

Java
// Wrong: the interruption request is discarded
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // ignored
}
Java
// Right: either propagate it, or restore the flag so callers can see it
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();     // restore the interrupted status
    return;                                  // and stop what you were doing
}

Catching InterruptedException clears the thread's interrupted flag. If you neither rethrow nor restore it, code further up that checks for interruption will never see the request, and a thread pool shutdown will hang.

final, finally and finalize

Three unrelated things with similar names.

AspectKeywordWhat it does
finalA modifierVariable cannot be reassigned, method cannot be overridden, class cannot be extended
finallyA blockRuns after try/catch regardless of outcome
finalize()A method on ObjectWas called before collection; deprecated since Java 9, removed in Java 18
  • final

    KeywordA modifier
    What it doesVariable cannot be reassigned, method cannot be overridden, class cannot be extended
  • finally

    KeywordA block
    What it doesRuns after try/catch regardless of outcome
  • finalize()

    KeywordA method on Object
    What it doesWas called before collection; deprecated since Java 9, removed in Java 18

They share a prefix and nothing else. finalize() should never appear in new code.

finalize() was unreliable by design — there was no guarantee it would ever run, it could resurrect objects, and it delayed collection. Use try-with-resources for deterministic cleanup. For native resources, java.lang.ref.Cleaner is the modern replacement.

A short checklist

  • Fail fast, and validate at the entry point before changing any state.
  • Include the offending value in every message.
  • Wrap with a cause, always.
  • Catch only what you can handle; let everything else reach a global handler.
  • Log once, at the point of handling, with context.
  • Use try-with-resources for anything closeable.
  • Restore the interrupt flag if you catch InterruptedException.
  • Never return or throw from finally.

Common misreadings

  • "A custom exception should extend Exception to be proper." Extend RuntimeException unless callers have a real recovery path.
  • "printStackTrace() is fine in development." It builds the habit, and it reaches production in the one class nobody reviewed.
  • "Catching broadly is defensive." It is the opposite: it hides the bugs you most want to see.
  • "Logging and rethrowing is thorough." It produces duplicate traces and makes incidents harder to read.
  • "finalize() is a safety net." It was never guaranteed to run and no longer exists.
  • "Swallowing InterruptedException is harmless." It discards a cancellation request and can hang a shutdown.

Quick recall

Everything you need if you only revisit this box.

  • Extend RuntimeException by default; offer a cause-accepting constructor; carry structured fields, not just a message.
  • Reuse IllegalArgumentException, IllegalStateException and UnsupportedOperationException where they fit.
  • Drop the anti-patterns: empty catch, printStackTrace(), lost causes, catch (Exception), exceptions as control flow, throws Exception.
  • Log once, where you handle, with context added as you wrap. Never log sensitive values.
  • Catching InterruptedException clears the flag — rethrow or call Thread.currentThread().interrupt().
  • final is a modifier, finally is a block, finalize() is a removed method. Use try-with-resources or Cleaner instead.

Test yourself

Answer these before moving on — recall is what makes it stick.