On This Page
You've written this catch block. I've written it, more than once, and I'd bet money there's one in the service you own right now:
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
It feels tidy. It's the line that makes the 2 a.m. page unreadable. This post is about writing a custom exception in Java (or choosing not to) so the trace someone pastes into Slack answers its own questions. If you need the vocabulary first, the hub is How to Read a Java Stack Trace, Line by Line, Fast.
The trace that told me nothing
A currency converter, two layers, one missing rate. This is what the console printed on OpenJDK 21.0.12.1 (Ubuntu build), Linux, plain java -cp out fx.Lab bad:
Exception in thread "main" java.lang.RuntimeException: conversion failed
at fx.Lab.badController(Lab.java:41)
at fx.Lab.main(Lab.java:108)
Two frames. No pair, no amount, no "why". If this lands in your log aggregator, that's the whole evidence you get.
What the trace is actually saying
It says a catch block on line 41 gave up. That's all. My first instinct was to go look for a second trace somewhere in the logs, because surely something logged the real error. Nothing did. The real error, an IllegalStateException thrown from the repository on line 23, was caught by badService, reduced to its message string, thrown again as a fresh RuntimeException, caught again in badController, and reduced again to a message that doesn't even contain the first one. Each layer deleted a layer of evidence and called it error handling.
Open frame one and you land on a throw new RuntimeException("conversion failed"). Which tells you precisely nothing about GBP/USD.
Run all six shapes yourself
JDK 21 (I ran 21.0.12.1; nothing else, see the section near the end). One file, package fx. Keep the blank lines, the traces below quote its line numbers.
package fx;
import java.math.BigDecimal;
import java.util.Map;
public class Lab {
static final Map<String, BigDecimal> RATES = Map.of("EUR/USD", new BigDecimal("1.08"));
// ---- the exception type we'll end up with ----
static class RateUnavailableException extends RuntimeException {
final String pair;
RateUnavailableException(String pair, String why, Throwable cause) {
super("no rate for " + pair + ": " + why, cause);
this.pair = pair;
}
}
// ---- repository layer ----
static BigDecimal lookup(String pair) {
BigDecimal r = RATES.get(pair);
if (r == null) {
throw new IllegalStateException("missing");
}
return r;
}
// ---- scenario 1: the bad version ----
static BigDecimal badService(String pair, BigDecimal amount) {
try {
return lookup(pair).multiply(amount);
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
}
static BigDecimal badController(String pair, BigDecimal amount) {
try {
return badService(pair, amount);
} catch (Exception e) {
throw new RuntimeException("conversion failed");
}
}
// ---- scenario 2: the better version ----
static BigDecimal goodService(String pair, BigDecimal amount) {
try {
return lookup(pair).multiply(amount);
} catch (IllegalStateException e) {
throw new RateUnavailableException(pair, "repository said " + e.getMessage(), e);
}
}
// ---- scenario 3: factory helper vs. creating it where you detect it ----
static RateUnavailableException unavailable(String pair) {
return new RateUnavailableException(pair, "factory", null);
}
static BigDecimal viaFactory(String pair) {
BigDecimal r = RATES.get(pair);
if (r == null) throw unavailable(pair);
return r;
}
// ---- scenario 4: the four rethrow shapes ----
static void rethrow(String how) {
try {
lookup("GBP/USD");
} catch (IllegalStateException e) {
switch (how) {
case "same" -> throw e;
case "wrap" -> throw new RuntimeException(e);
case "msg" -> throw new RuntimeException(e.getMessage());
case "refill" -> {
e.fillInStackTrace();
throw e;
}
default -> throw new IllegalArgumentException(how);
}
}
}
// ---- scenario 5: a cached exception ----
static final IllegalStateException CACHED = new IllegalStateException("rate feed down");
static void useCached() {
throw CACHED;
}
// ---- scenario 6: getMessage and cause-only constructors ----
static class Ctx extends RuntimeException {
final String id;
Ctx(String id, Throwable cause) { super(cause); this.id = id; }
@Override public String getMessage() { return "[order " + id + "] " + super.getMessage(); }
}
static void ctx() {
try {
lookup("JPY/USD");
} catch (IllegalStateException e) {
throw new Ctx("o-7731", e);
}
}
public static void main(String[] args) {
String mode = args.length > 0 ? args[0] : "bad";
switch (mode) {
case "bad" -> badController("GBP/USD", BigDecimal.TEN);
case "good" -> goodService("GBP/USD", BigDecimal.TEN);
case "factory" -> viaFactory("GBP/USD");
case "same", "wrap", "msg", "refill" -> rethrow(mode);
case "cached" -> { try { useCached(); } catch (IllegalStateException e) { e.printStackTrace(); } useCached(); }
case "ctx" -> ctx();
default -> throw new IllegalArgumentException(mode);
}
}
}
mkdir -p fx out
# save the file above as fx/Lab.java
javac -d out fx/Lab.java
java -cp out fx.Lab bad
java -cp out fx.Lab good
java -cp out fx.Lab factory
java -cp out fx.Lab same # also: wrap, msg, refill
java -cp out fx.Lab cached
java -cp out fx.Lab ctx
If your line numbers are off by one or two, you lost a blank line. If your bottom frame isn't Lab.main, you ran it from an IDE runner that adds frames; use the terminal. Everything here goes through main, so the traces end there. In a pool thread they won't (see the async post).
Custom exception in Java, trace by trace
Start with the version I'd actually ship. java -cp out fx.Lab good:
Exception in thread "main" fx.Lab$RateUnavailableException: no rate for GBP/USD: repository said missing (1)
at fx.Lab.goodService(Lab.java:50) (2)
at fx.Lab.main(Lab.java:109)
Caused by: java.lang.IllegalStateException: missing (3)
at fx.Lab.lookup(Lab.java:23) (4)
at fx.Lab.goodService(Lab.java:48)
... 1 more (5)
- The class name tells you the layer's vocabulary (
RateUnavailableException, notRuntimeException), and the message carries the value that failed. Someone can grepGBP/USDand find every occurrence. - The top frame is where the exception was constructed, line 50, inside the catch block. That's the translation point, not the origin.
- The original exception is still here, under
Caused by. - The origin of the problem,
lookupline 23, survived. This is the frame I open first. ... 1 moreisLab.main, shared with the enclosing trace. Not lost, not hidden, just not repeated. The counting rule is in the Caused by post.
Now the two less obvious ones. A helper that builds the exception (factory):
Exception in thread "main" fx.Lab$RateUnavailableException: no rate for GBP/USD: factory
at fx.Lab.unavailable(Lab.java:56)
at fx.Lab.viaFactory(Lab.java:61)
at fx.Lab.main(Lab.java:110)
Frame one is the factory method. Everyone opens it. It's a one-liner that builds an exception and returns it, and it'll be frame one for every failure from every caller. The place that detected the problem, viaFactory line 61, is frame two.
And one with a cause-only constructor plus an overridden getMessage (ctx):
Exception in thread "main" fx.Lab$Ctx: [order o-7731] java.lang.IllegalStateException: missing
at fx.Lab.ctx(Lab.java:101)
at fx.Lab.main(Lab.java:113)
Caused by: java.lang.IllegalStateException: missing
at fx.Lab.lookup(Lab.java:23)
at fx.Lab.ctx(Lab.java:99)
... 1 more
super(cause) filled the detail message with cause.toString(), which is why java.lang.IllegalStateException: missing appears in the first line. The [order o-7731] prefix comes from my getMessage override. It works because printStackTrace calls toString(), which calls getLocalizedMessage(), which calls getMessage(). Handy; also a trap if your override throws or does I/O, since it runs while you're printing an error.
Why a rethrow can erase the evidence
The mechanism is one fact: the stack trace is captured when the Throwable is constructed, not when it's thrown. The constructor calls fillInStackTrace(). Every new RuntimeException(...) records the stack of the line that wrote new. So a wrapper never inherits the original trace. The only way the old trace survives is as a cause you pass in, and the only way a reader sees it is the Caused by block.
That's why the bad trace has two frames. Each new RuntimeException(String) recorded a fresh stack and carried no cause. Nothing links back to line 23. It isn't hidden. The reference is gone.
I spent a day and some evenings on six rethrow shapes to be sure I had the rules right, so you can skip that. I read the Javadoc for Throwable, not the source; the behaviours below I observed and the constructor rule is documented. Here's what four rethrow shapes print for the same underlying failure on line 23:
| code in the catch block | top line | Caused by? |
frame for line 23 (lookup)? |
|---|---|---|---|
throw e; |
IllegalStateException: missing |
no | yes, first frame |
throw new RuntimeException(e); |
RuntimeException: java.lang.IllegalStateException: missing |
yes | yes, under Caused by |
throw new RuntimeException(e.getMessage()); |
RuntimeException: missing |
no | gone |
e.fillInStackTrace(); throw e; |
IllegalStateException: missing |
no | gone |
The last row surprised me. Same object, same class, same message, and the frame that actually threw it vanished (msg and refill modes). The trace afterwards starts at Lab.rethrow(Lab.java:75), the line of the fillInStackTrace call. I've seen people add that line "to refresh the trace". It's a deletion.
Three things I believed about rethrows
"Wrapping with new X(e) doubles the message, so it's noisy." It looks like it: RuntimeException: java.lang.IllegalStateException: missing. That duplication is the price of having no message to write. It costs you nothing in evidence. The thing that actually hurts is the msg row, which looks cleaner and is strictly worse.
"A shared exception constant is a cheap way to avoid creating exceptions." I tried the cached mode and printed the same constant twice:
java.lang.IllegalStateException: rate feed down
at fx.Lab.<clinit>(Lab.java:84)
Exception in thread "main" java.lang.IllegalStateException: rate feed down
at fx.Lab.<clinit>(Lab.java:84)
Both say the failure happened in the static initialiser, line 84, because that's where the object was built. The second throw came from useCached. The trace lies, confidently, forever. (If you do this on purpose for speed, that's the cost-versus-trace trade covered in the performance series; measure before you do it.)
"The top frame is where it went wrong." In factory the top frame is a method whose whole job is return new .... The rule from the hub still applies: the top frame is where the object was created. Create it where you detect the problem and the two coincide.
Writing it so it explains itself
What I'd change, in order of value:
1. Put the value in the message. no rate for GBP/USD beats rate lookup failed every time. Ids, names, sizes, the state you expected and the state you found. Cost: building a string. On a path that throws a million times a second that might matter; on almost everything else it doesn't.
2. Never drop the cause. Use the two-argument constructor. If your custom exception only has a (String) constructor, add the (String, Throwable) one now. Half the dropped causes I've found were because the class didn't offer a way.
3. Create the exception at the point of detection, not in a helper. A factory is fine for building the message. Have it return a string, or make the call site throw new ...(...). If you must use a factory, accept that every trace has the factory as frame one and your log fingerprinting will group unrelated failures together. That last bit is inference on my part; I haven't run it through an aggregator.
4. Translate at layer boundaries and only there. The repository throws something about SQL or HTTP. The service throws something in the domain's vocabulary (RateUnavailableException) and passes the cause. The controller doesn't wrap again unless it's adding information. In the bad version both layers caught and re-threw; the good version has one catch.
5. Make a new type only when someone catches by type, or reads a field. RateUnavailableException has a pair field so a handler can retry or report it. If nothing ever does catch (RateUnavailableException e), a plain IllegalStateException with a good message is the same evidence with less code. Types are for control flow; messages are for people.
6. Keep secrets out. Messages end up in logs, in error trackers and in tickets. A token, a password, a full card number or an auth header in an exception message is a leak waiting for a log shipper. Put the id, never the credential.
7. Don't override fillInStackTrace to be clever. Suppressing the trace saves time and then costs you the first on-call night. If a profiler tells you exception creation is hot, measure first; the cost side is in the performance series, and the missing-trace side is in this post.
One more: if you wrote getMessage() overrides like my Ctx, keep them dumb. String concatenation of fields only.
What reaches your logs, and what I couldn't run
All of this is about what the exception carries. Whether it arrives depends on how you log it. The rule that matters: log the throwable itself, once, at the boundary that decides what happens to it. Logging e.getMessage() does to your logs what new RuntimeException(e.getMessage()) did to the trace. The longer version is coming in the logging post.
Rethrow-and-log at every layer prints the same chain several times. Pick one layer.
JDK versions. I ran every trace above on OpenJDK 21.0.12.1 only. I did not run JDK 8, 17 or 25, and I did not read Throwable.java this time. The "constructor fills the stack" and "cause-only constructor uses cause.toString()" rules are in the Throwable Javadoc, and the printed shapes (Caused by, ... N more) are the same ones I used in the earlier posts. I didn't run Logback or Log4j2. I didn't test a suppress/writableStackTrace=false constructor here (see the missing-trace post for that). The one forward link on this page, to the logging post, points at an article that isn't published yet.
Keep these
- A trace shows where the exception was constructed. Construct it where you detect the problem, not in a helper.
new X(e.getMessage())throws away the cause and every frame under it.new X(msg, e)keeps them.e.fillInStackTrace(); throw e;deletes the original frames. So does astatic finalexception, in a different way: it points at class init forever.- Put the failing value in the message, never a secret.
- A new exception type earns its place when something catches it or reads its fields.
Questions that come up before someone writes the class
Should I write a custom exception or use a standard one? Write a custom one when a caller needs to catch it specifically or read data from it. Otherwise IllegalStateException or IllegalArgumentException with a specific message gives the same trace. For which exceptions are checked, see Java errors and exceptions.
What goes in an exception message? The identifier and the value that failed, the expectation, and the operation. Not credentials, not whole payloads. If a message would be wrong to print in a support ticket, it's wrong to put there.
Does throw new RuntimeException(e) keep the original stack trace? Yes, under Caused by, as the table above shows (JDK 21). Passing e.getMessage() instead drops it.
Where should I catch and rethrow? At a layer boundary where you translate to the layer above's vocabulary or add context the layer below didn't have. Anywhere else, let it propagate.