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,finallyandfinalizesound alike, are unrelated, and get asked together constantly.
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.
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
RuntimeExceptionunless 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
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:
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
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:
catch (IOException e) {
log.error("Failed to read config from {}", path, e);
}
Losing the cause
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
// 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
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.
| Aspect | Anti-pattern | What to do instead |
|---|---|---|
| Empty catch | Failure disappears | Log it, or comment why ignoring is correct |
| e.printStackTrace() | Outside your logging pipeline | log.error(message, args, e) |
| Dropping the cause | Root cause unrecoverable | Pass the original as the cause |
| catch (Exception) | Hides your own bugs | Catch the specific types you handle |
| Exceptions in a loop | Stack capture per iteration | Use the normal condition |
| throws Exception | Tells callers nothing | Declare the specific types |
Empty catch
Anti-patternFailure disappearsWhat to do insteadLog it, or comment why ignoring is correcte.printStackTrace()
Anti-patternOutside your logging pipelineWhat to do insteadlog.error(message, args, e)Dropping the cause
Anti-patternRoot cause unrecoverableWhat to do insteadPass the original as the causecatch (Exception)
Anti-patternHides your own bugsWhat to do insteadCatch the specific types you handleExceptions in a loop
Anti-patternStack capture per iterationWhat to do insteadUse the normal conditionthrows Exception
Anti-patternTells callers nothingWhat 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
// 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
}
}
// 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.
errorfor something needing attention,warnfor a recovered problem,infofor normal events.
InterruptedException is special
Swallowing this one breaks a mechanism the whole platform depends on.
// Wrong: the interruption request is discarded
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// ignored
}
// 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.
| Aspect | Keyword | What it does |
|---|---|---|
| final | A modifier | Variable cannot be reassigned, method cannot be overridden, class cannot be extended |
| finally | A block | Runs after try/catch regardless of outcome |
| finalize() | A method on Object | Was called before collection; deprecated since Java 9, removed in Java 18 |
final
KeywordA modifierWhat it doesVariable cannot be reassigned, method cannot be overridden, class cannot be extendedfinally
KeywordA blockWhat it doesRuns after try/catch regardless of outcomefinalize()
KeywordA method on ObjectWhat 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
Exceptionto be proper." ExtendRuntimeExceptionunless 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
InterruptedExceptionis harmless." It discards a cancellation request and can hang a shutdown.
Quick recall
Everything you need if you only revisit this box.
- Extend
RuntimeExceptionby default; offer a cause-accepting constructor; carry structured fields, not just a message. - Reuse
IllegalArgumentException,IllegalStateExceptionandUnsupportedOperationExceptionwhere 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
InterruptedExceptionclears the flag — rethrow or callThread.currentThread().interrupt(). finalis a modifier,finallyis a block,finalize()is a removed method. Use try-with-resources orCleanerinstead.
Test yourself
Answer these before moving on — recall is what makes it stick.