When a custom exception earns its keep
A custom exception is not about decorating your error messages — it is about giving a
failure a name and a type that callers can act on. The moment a caller needs to
react differently to one failure than to every other failure, or needs structured
data out of it (the offending id, an error code), a dedicated type pays for itself.
throw new RuntimeException("order 42 not found") forces every caller to parse a string;
throw new OrderNotFoundException(42) lets them write a precise catch and pull the id
back out. This guide is about the design decisions around those classes — which parent to
extend, how to preserve diagnostics, and the conventions that keep an exception hierarchy
maintainable rather than noisy.
Checked or unchecked: pick the parent first
The single most consequential decision is the superclass, because it encodes whether
the error is expected or a defect. Extend Exception to make it checked —
the compiler forces callers to catch or declare it. Extend RuntimeException to make
it unchecked — no compiler obligation at all.
// checked: a recoverable, anticipated condition the caller should plan for
public class ConfigParseException extends Exception { }
// unchecked: a programming error or violated precondition — a bug to fix, not handle
public class InvalidOrderStateException extends RuntimeException { }
The litmus test is "can the caller do something useful about it?" A missing config
file or a failed network call is recoverable — lean checked. A null argument or an
illegal state is a defect the caller cannot meaningfully recover from — lean unchecked.
Choosing the parent first means every later decision (whether to declare throws, how
callers handle it) follows naturally.
The four standard constructors
Mirror the four constructors that Exception and RuntimeException expose, so callers
can supply a message, a cause, or both. The two cause-bearing forms are the ones people
forget — and they are the most important, because they are what makes chaining
possible.
public class PaymentException extends RuntimeException {
public PaymentException() { super(); }
public PaymentException(String message) { super(message); }
public PaymentException(String message, Throwable cause) {
super(message, cause); // message + root cause — the workhorse
}
public PaymentException(Throwable cause) { super(cause); }
}
If you provide nothing else, provide at minimum (String message) and
(String message, Throwable cause). The latter is what lets you wrap a lower-level
failure without discarding the line that actually broke.
Chaining: never throw away the cause
Exception chaining is wrapping a low-level exception inside a higher-level one while
keeping the original as the cause. It lets you raise a meaningful domain exception
without losing the root-cause diagnostics. The cause surfaces in the stack trace under a
Caused by: line, so you see both the abstract failure and the SQL error that
triggered it.
try {
return jdbc.query(sql);
} catch (SQLException e) {
// wrap to your abstraction, but pass e as the cause so the trace survives
throw new RepositoryException("failed to load user " + id, e);
}
The classic mistake is throw new RepositoryException("failed to load user " + id) with
no e. The new exception's trace now starts at the throw site, and the line that truly
failed is gone forever. When you rethrow, always carry the cause. If a class predates
the cause constructors, initCause(e) does the same job — but it may be called exactly
once, so the constructor form is strictly better when available.
Carrying structured data with custom fields
Put any data a handler might need into typed, final fields populated through the constructor and exposed via getters — not buried in the message string. This is half the reason custom exceptions exist.
public class OrderNotFoundException extends RuntimeException {
private final long orderId; // immutable: an exception is a one-shot value
public OrderNotFoundException(long orderId) {
super("order not found: " + orderId);
this.orderId = orderId;
}
public long getOrderId() { return orderId; }
}
Now a caller can write catch (OrderNotFoundException e) { retry(e.getOrderId()); } and
get the id without scraping the message. Keep the fields final and defensively copy any
mutable inputs (List.copyOf(...)) — an exception describes a moment in time and should
never be mutated after it is thrown.
Naming and serialVersionUID
Two conventions keep a hierarchy professional. First, name the class for the problem and
suffix it with Exception — InsufficientFundsException, not FundsProblem or
CheckBalanceException (name the condition, not the throwing method). It mirrors the JDK's
own IllegalArgumentException / IOException and makes exceptions instantly recognizable
in stack traces.
public class RemoteCallException extends RuntimeException {
private static final long serialVersionUID = 1L; // pin it so old instances deserialize
private final String endpoint; // String is serializable — fine
public RemoteCallException(String endpoint, Throwable cause) {
super("call failed: " + endpoint, cause);
this.endpoint = endpoint;
}
}
Second, because Throwable already implements Serializable, every exception is
serializable by inheritance — and exceptions genuinely cross JVM boundaries (RMI,
distributed systems, app servers). Declare an explicit serialVersionUID; without one
the compiler-generated id shifts on any edit, breaking deserialization of older instances.
Mark any non-serializable custom field transient.
Exception translation at layer boundaries
Exception translation is catching a low-level exception and throwing a higher-level one
that fits your API's abstraction — so callers depend on your types, not on SQLException
or IOException leaking up from the data layer. It is chaining applied deliberately at a
boundary.
public User findById(long id) {
try {
return jdbc.queryForObject(SQL, mapper, id);
} catch (SQLException e) {
throw new UserRepositoryException("findById " + id, e); // translate + chain
}
}
This decouples callers from the implementation: you could swap JDBC for an ORM and the
exceptions they catch would not change. Always chain the cause so the low-level detail
remains available for debugging. The same discipline separates business exceptions
(InsufficientFundsException — expected, mapped to a friendly 4xx) from technical
exceptions (ServiceUnavailableException — infrastructure broke, log/alert/5xx), and
lets you handle each kind differently.
Stay inside Exception and RuntimeException
Subclass Exception or RuntimeException and nothing higher. Never extend Throwable
directly — it produces a type that is neither a clean exception nor an error and confuses
catch blocks. Never extend Error — that is reserved for serious JVM-level failures
like OutOfMemoryError that applications are not meant to catch.
class DataImportException extends Error { } // WRONG — implies the JVM is broken
class DataImportException extends RuntimeException { } // right level of abstraction
Throwing an Error subtype is actively harmful: a catch (Exception e) handler will not
catch it, so it slips past code designed to handle failures, while signalling a
catastrophe that did not occur.
Modern style: default to unchecked
Much of the modern ecosystem — Spring chief among it — defaults to unchecked domain
exceptions. They keep method signatures clean, work inside lambdas and streams (whose
functional interfaces cannot declare checked exceptions), and avoid rippling throws
clauses up the entire call stack every time a method can fail. Handling moves from scattered
try/catch blocks to one centralized place.
// a single global handler translates domain exceptions into HTTP responses
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<?> handle(OrderNotFoundException e) {
return ResponseEntity.status(404).body(e.getMessage());
}
The trade-off is real: the compiler no longer reminds callers that an error can occur, so
teams rely on @throws Javadoc and convention instead of enforcement. Most accept that
for the reduced ceremony. Whichever default you pick, resist over-creating: when a standard
JDK type already says it exactly — IllegalArgumentException for a bad argument,
IllegalStateException for a bad state — reuse it. A custom exception only earns its place
when callers will catch it by type or need data from it.
Recap
A custom exception is a typed, named failure callers can target — create one only when
that targeting or its data is needed. Decide checked (extends Exception) vs unchecked
(extends RuntimeException) first, since it encodes "expected vs defect." Provide the
four standard constructors, especially (String, Throwable), and always chain the
cause so the stack trace and its Caused by: line survive. Carry context in final
fields, follow the ...Exception naming convention, declare a serialVersionUID,
and translate low-level exceptions to your abstraction at layer boundaries. Never reach
above Exception/RuntimeException into Throwable or Error. Modern code leans
unchecked by default with centralized handling and @throws docs — get the design
right and your failures stay debuggable instead of mysterious.