> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Custom Exception in Java: Write Ones That Explain Themselves
- URL: https://www.ggorantala.dev/java-custom-exceptions-stack-traces/
- Published: 2026-10-20T00:59:00.000Z
- Updated: 2026-11-01T02:02:51.000Z
- Description: A custom exception in Java should tell you which value, which layer and why. How to write messages, chain causes and rethrow without losing the trace.
- Author: Gopi Gorantala
- Tags: stack-traces, java-exceptions

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:

```java
} 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](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev).

## 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`:

```log
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.

```java
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);
        }
    }
}

```

```bash
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](https://ggorantala.dev/java-async-stack-traces/?ref=ggorantala.dev)).

## Custom exception in Java, trace by trace

Start with the version I'd actually ship. `java -cp out fx.Lab good`:

```log
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)

```

1. The class name tells you the layer's vocabulary (`RateUnavailableException`, not `RuntimeException`), and the message carries the value that failed. Someone can grep `GBP/USD` and find every occurrence.
2. The top frame is where the *exception was constructed*, line 50, inside the catch block. That's the translation point, not the origin.
3. The original exception is still here, under `Caused by`.
4. The origin of the problem, `lookup` line 23, survived. This is the frame I open first.
5. `... 1 more` is `Lab.main`, shared with the enclosing trace. Not lost, not hidden, just not repeated. The counting rule is in [the Caused by post](https://ggorantala.dev/java-caused-by-root-cause/?ref=ggorantala.dev).

Now the two less obvious ones. A helper that builds the exception (`factory`):

```log
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`):

```log
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:

```log
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](https://ggorantala.dev/java-missing-stack-trace/?ref=ggorantala.dev).

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](https://ggorantala.dev/logging-java-exceptions-correctly/?ref=ggorantala.dev).

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](https://ggorantala.dev/java-missing-stack-trace/?ref=ggorantala.dev) 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 a `static final` exception, 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](https://www.ggorantala.dev/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.