designing with exceptions
custom exceptions, chaining, and translating across layers.
- chapter
- chapter 04.07
- reading time
- 8 min read
a good exception says what failed, with the values that matter, and why, by carrying the exception that caused it. each layer of a program catches the low-level failures it understands and rethrows them in its own words (exception translation), passing the original along as the cause.
and exceptions are for exceptional things: free when nothing's thrown, but every new walks the whole stack.
the picture
a good error report is a chain of "because": checkout failed for order 42, because finding order 42 failed, because the database refused the connection. each link is written by someone who knows their level of the story. the web layer knows about checkouts, the repository knows about queries, the driver knows about sockets.
in java each link is an exception object, and "because" is a reference: getCause().
- OrderControllercatch (CheckoutException e) → 503 + log(e)
- CheckoutServicethrow new CheckoutException(id, e);
- OrderRepositorythrow new DataAccessException("find order " + id, e);
- JDBC driverthrows SQLException: Connection refused
each layer speaks its own language (the controller only knows about checkouts), and the log still reaches all the way down to the real problem: connection refused.
translate at the boundary
the rule: catch an exception where it stops making sense, and rethrow one that does make sense one level up. the repository is the last place where "SQL" is a meaningful word, so that's where SQLException turns into a DataAccessException. the service turns that into a CheckoutException. nobody above the repository imports java.sql.
and always pass the original as the cause. every standard exception has a (String message, Throwable cause) constructor for exactly this, and printStackTrace prints the chain as Caused by: sections (you saw one in exceptions).
writing your own
most of the time a standard exception is right (IllegalArgumentException, IllegalStateException, UnsupportedOperationException...). write your own when callers need to catch it specifically, or when it has to carry data a caller will use.
import java.sql.SQLException;
class CheckoutException extends RuntimeException {
private final long orderId; // context, as a real field
CheckoutException(long orderId, Throwable cause) {
super("checkout failed for order " + orderId, cause); // message AND cause
this.orderId = orderId;
}
long orderId() {
return orderId;
}
}
void checkout(long id) {
try {
throw new SQLException("Connection refused"); // pretend this came from JDBC
} catch (SQLException e) {
throw new CheckoutException(id, e); // translate, keep the cause
}
}
void main() {
try {
checkout(42);
} catch (CheckoutException e) {
IO.println(e.getMessage() + ", id field = " + e.orderId());
Throwable root = e;
while (root.getCause() != null) root = root.getCause();
IO.println("root cause: " + root);
}
}checkout failed for order 42, id field = 42 root cause: java.sql.SQLException: Connection refused
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
the checklist:
- pick the parent on purpose.
RuntimeExceptionfor broken contracts and most application errors,Exceptiononly if callers can genuinely recover (the three questions in exceptions). - put the values in the message. "checkout failed for order 42" beats "checkout failed". include the inputs that caused it (but never passwords or tokens: messages end up in logs).
- keep the cause. offer a
(…, Throwable cause)constructor and use it. - expose context as fields, so callers don't have to parse your message.
- name it for the problem, ending in
Exception:OrderNotFoundException, notOrderException2. javac -Xlintwill mention a missingserialVersionUID(ThrowableisSerializable). add one if your exceptions ever cross a JVM boundary, ignore it otherwise.
exceptions are for exceptional things
exceptions are cheap when nothing's thrown: a try block costs nothing until something goes wrong (it's just a row in the exception table). the expensive part is creating one, because the constructor calls fillInStackTrace(), which walks every frame on the stack. turning that into StackTraceElement objects (when you call getStackTrace() or print it) costs even more.
writableStackTrace = false costs about as much as any small object.the honest takeaway: 235 ns is nothing for an error path, and a lot for a normal path. a parser that throws on every bad line of a big file, or a loop that ends by catching ArrayIndexOutOfBoundsException, is 100× slower than it needs to be, and hides real bugs behind a catch.
boolean isNumber(String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false; // the "normal" answer costs a stack walk
}
}boolean isNumber(String s) {
return !s.isEmpty() && s.chars().allMatch(Character::isDigit);
}(not identical: this one rejects "-5" and accepts digits too big for an int. pick the check that matches what you actually mean.)
when the JVM throws away your stack trace+
HotSpot has an optimization called OmitStackTraceInFastThrow, on by default. when compiled code keeps throwing the same implicit exception (an NPE from a null dereference, ArithmeticException, ArrayIndexOutOfBoundsException, ClassCastException...) from the same spot, C2 switches to throwing one preallocated exception with no stack trace and no message.
measured on jdk 25: a hot method dereferencing null in a loop gave full traces for the first ~5,180 NPEs, then bare ones (getStackTrace().length == 0, getMessage() == null) for most of the remaining 200,000. in production this shows up as a log full of java.lang.NullPointerException with nothing after it. the real trace is in the logs, just much earlier, from when the code was still interpreted. or restart with -XX:-OmitStackTraceInFastThrow.
leave things the way you found them
a method that throws should, ideally, leave its object exactly as it was before the call. that's called failure atomicity. the easy ways to get it: validate arguments before changing anything, compute the new state in locals and assign it at the end, or work on a copy. ArrayList.add checks capacity before it writes; a withdraw that subtracts first and checks the balance second leaves a negative balance behind when it throws.
- message = what failed, with the values. cause = why.
- translate exceptions at layer boundaries, and always pass the original as the cause.
- custom exceptions only when callers catch them specifically or need their data.
- a try costs nothing. creating an exception costs a stack walk. never use them for normal flow.
- caught
InterruptedException? rethrow it or restore the flag.