Exception Chaining and Causes
Wrap low-level failures with context while keeping the original as the cause, and find the root cause when you need it.
throw new ServiceException("could not load order " + id, e);
Throwable cause = e.getCause(); // null at the bottom of the chainWhen a service catches a low-level exception and throws a higher-level one, it should pass the original along as the cause. The new exception says what the application was trying to do; the cause says what actually broke.
try {
return jdbc.queryOrder(id);
} catch (SQLException e) {
throw new RepositoryException("could not load order " + id, e);
}Printed stack traces show the chain as Caused by: sections, and every logging framework prints them. Without the cause, all you get is "could not load order 42" and no idea whether it was a deadlock, a timeout or a bad column name.
The constructors
(String message, Throwable cause): the normal choice. Add context in the message.(Throwable cause): sets the message tocause.toString(), sogetMessage()returns something like"java.io.IOException: disk full". Fine, but you lose the chance to add context.initCause(cause): for exception types with no cause constructor. It can be called once, and only if no cause was set by a constructor; otherwise it throwsIllegalStateException.
Reading a chain
getCause() returns the next exception down, or null at the bottom. Walking to the bottom gives the root cause, which is useful for classifying failures, for example retrying when the root is a SocketTimeoutException.
Concurrency utilities wrap on your behalf. Future.get() throws ExecutionException and CompletableFuture.join() throws CompletionException; the exception your task threw is the cause. Catching the wrapper and logging only its message is a common way to lose the real reason a parallel call failed.
Mistakes that break the chain
throw new ServiceException(e.getMessage())copies the text but drops the cause and its stack trace.- Wrapping at every layer adds noise. Wrap where the meaning changes, such as at the repository boundary, not in every method.
- Sending the root cause message to API clients.
"ERROR: relation orders does not exist"tells an attacker about your schema. Log the chain; return a generic message.
Example
When to use it
- Wrap a SQLException in a repository exception that names the table and id while keeping the driver's details as the cause.
- Unwrap ExecutionException from parallel downstream calls to log the real failure.
- Retry a sync job only when the root cause is a socket timeout.
More examples
Unwrapping ExecutionException from a Future
get() always wraps the task's failure in ExecutionException, so the useful information lives in getCause(). join() behaves the same way with the unchecked CompletionException.
void main() throws InterruptedException {
CompletableFuture<String> price = CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("pricing service returned 503");
});
try {
IO.println(price.get());
} catch (ExecutionException e) {
IO.println("wrapper: " + e.getClass().getSimpleName());
Throwable cause = e.getCause();
IO.println("cause: " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
// Output:
// wrapper: ExecutionException
// cause: IllegalStateException: pricing service returned 503Finding the root cause
Retry logic should look at what actually failed, not at the outermost wrapper. A timeout deep in the chain is worth retrying; bad data never is.
Throwable rootCause(Throwable t) {
Throwable current = t;
while (current.getCause() != null) {
current = current.getCause();
}
return current;
}
boolean isRetryable(Throwable t) {
return rootCause(t) instanceof SocketTimeoutException;
}
void main() {
var timeout = new RuntimeException("sync failed",
new UncheckedIOException("call to billing failed",
new SocketTimeoutException("Read timed out")));
var badData = new RuntimeException("sync failed",
new IllegalArgumentException("invoice total is negative"));
IO.println(rootCause(timeout).getMessage() + " -> retry " + isRetryable(timeout));
IO.println(rootCause(badData).getMessage() + " -> retry " + isRetryable(badData));
}
// Output:
// Read timed out -> retry true
// invoice total is negative -> retry falseMessage from the cause-only constructor, and initCause
The cause-only constructor reuses cause.toString() as the message, which adds no context. initCause fills the gap for older exception types but can be set only once.
void main() {
var io = new IOException("disk full");
var withContext = new RuntimeException("could not write report 2026-Q1", io);
var causeOnly = new RuntimeException(io);
IO.println(withContext.getMessage());
IO.println(causeOnly.getMessage());
// ArithmeticException has no cause constructor, so attach one afterwards.
var overflow = new ArithmeticException("total overflowed long");
overflow.initCause(io);
IO.println(overflow.getCause().getMessage());
try {
overflow.initCause(new IOException("second cause"));
} catch (IllegalStateException e) {
IO.println("initCause twice: " + e.getClass().getSimpleName());
}
}
// Output:
// could not write report 2026-Q1
// java.io.IOException: disk full
// disk full
// initCause twice: IllegalStateException
Discussion