JAVA / EXCEPTIONS
Custom exception types and useful failure context
Design exception classes that name one failure category and carry the identifiers, values and cause a handler needs, instead of only a message string.
What you will learn
- Add an exception class only when a caller would react differently to it
- Carry failure data in final fields with accessors, not buried in message text
- Forward the original throwable with super(message, cause) so Caused by survives
- Give callers one base type to catch and put decisions like retryable() on it
Understanding Custom exception types and useful failure context
A custom exception class earns its place when a caller can plausibly react to it differently from everything else that can go wrong. That is the test: if every handler will only log and rethrow, IllegalArgumentException or IOException already carries enough meaning and a new class just adds a name to import. When the reaction really does differ, retry the payment, ask for another email address, fall back to a cached value, then the type is the cheapest dispatch mechanism available, because catch already selects on type.
Treat the class as the category of failure and its fields as the facts about one occurrence. getMessage() is written for a person reading a log at 3am, so it should name the identifier and the offending value; the fields exist so that handling code never has to substring that sentence back apart. One Java rule shapes the constructor: super(...) must be the first statement, so the message has to be built from the constructor parameters inside that call, or by a private static helper, not from fields you have not assigned yet. Make those fields final and it becomes impossible to construct the exception without its context.
The other half of context is the cause. Translating a low-level failure into your own vocabulary deliberately throws away the original type, so hand the original object to super(message, cause) and the printed trace gains a Caused by section pointing at the line that actually broke. Writing new MyException(e.getMessage()) keeps a sentence and discards the trace, which is why wrapped failures so often appear to originate at your own throw statement and nowhere else. For that reason a custom exception should offer at least a (String) and a (String, Throwable) constructor, mirroring the pair Throwable itself provides.
public class Main {
static class InsufficientFundsException extends RuntimeException {
private final String accountId;
private final long balanceCents;
private final long requestedCents;
InsufficientFundsException(String accountId, long balanceCents, long requestedCents) {
super("account " + accountId + " holds " + balanceCents
+ " cents but " + requestedCents + " cents were requested");
this.accountId = accountId;
this.balanceCents = balanceCents;
this.requestedCents = requestedCents;
}
String accountId() {
return accountId;
}
long shortfallCents() {
return requestedCents - balanceCents;
}
}
static void withdraw(String accountId, long balanceCents, long amountCents) {
if (amountCents > balanceCents) {
throw new InsufficientFundsException(accountId, balanceCents, amountCents);
}
System.out.println("withdrew " + amountCents + " cents from " + accountId);
}
public static void main(String[] args) {
try {
withdraw("ACC-4471", 2500, 4000);
} catch (InsufficientFundsException e) {
System.out.println("log: " + e.getMessage());
System.out.println("account " + e.accountId() + " is short by "
+ e.shortfallCents() + " cents");
if (e.shortfallCents() < 2000) {
System.out.println("offering a small overdraft");
}
}
}
}A custom exception is a named failure category whose final fields and preserved cause carry everything a handler and a log reader need, so no one has to parse a message string.
Worked examples
Wrapping a cause without losing it
Translates a JDK NumberFormatException into a domain exception while keeping both the original object and the setting name that failed.
public class Main {
static class ConfigException extends Exception {
private final String key;
ConfigException(String key, String message, Throwable cause) {
super(message, cause);
this.key = key;
}
String key() {
return key;
}
}
static int readPort(String raw) throws ConfigException {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
throw new ConfigException("server.port",
"server.port must be an integer, got '" + raw + "'", e);
}
}
public static void main(String[] args) {
try {
System.out.println("port = " + readPort("8080"));
System.out.println("port = " + readPort("80a0"));
} catch (ConfigException e) {
System.out.println(e.getMessage());
System.out.println("failing key: " + e.key());
Throwable cause = e.getCause();
System.out.println("caused by " + cause.getClass().getSimpleName()
+ ": " + cause.getMessage());
}
}
}Example explained
Line 1super(message, cause) stores both, so getMessage() is your sentence while getCause() still returns the NumberFormatException with its own trace.
Line 2The key field records which setting broke, so a handler can report it without slicing the message apart.
Line 3cause.getMessage() prints the JDK text For input string: "80a0", which is where the exact rejected characters survive.
Line 4readPort declares throws ConfigException, so the parse failure never escapes as an unrelated unchecked NumberFormatException.
One base type, several failure kinds
Shows an abstract base exception that carries the shared identifier and a retryable() decision, letting one catch block handle every subtype.
public class Main {
static abstract class OrderException extends RuntimeException {
private final String orderId;
OrderException(String orderId, String message) {
super(message);
this.orderId = orderId;
}
String orderId() {
return orderId;
}
abstract boolean retryable();
}
static class OutOfStockException extends OrderException {
OutOfStockException(String orderId, String sku) {
super(orderId, "sku " + sku + " is out of stock");
}
@Override
boolean retryable() {
return false;
}
}
static class PaymentTimeoutException extends OrderException {
PaymentTimeoutException(String orderId, int millis) {
super(orderId, "payment gateway did not answer in " + millis + " ms");
}
@Override
boolean retryable() {
return true;
}
}
static void placeOrder(String orderId, String sku) {
if (sku.equals("KB-88")) {
throw new OutOfStockException(orderId, sku);
}
if (orderId.equals("ORD-2")) {
throw new PaymentTimeoutException(orderId, 3000);
}
System.out.println("placed " + orderId + " for " + sku);
}
public static void main(String[] args) {
String[][] orders = {{"ORD-1", "KB-88"}, {"ORD-2", "MS-12"}, {"ORD-3", "MS-12"}};
for (String[] order : orders) {
try {
placeOrder(order[0], order[1]);
} catch (OrderException e) {
System.out.println(e.getClass().getSimpleName() + " on " + e.orderId()
+ " (retryable=" + e.retryable() + "): " + e.getMessage());
}
}
}
}Example explained
Line 1OrderException is abstract, so it works as a catch target and a contract but can never be thrown on its own as a vague failure.
Line 2orderId lives on the base class because every order failure has one, so subclasses only add what is specific to them.
Line 3retryable() moves the retry decision onto the exception type instead of forcing the caller to test class names.
Line 4ORD-3 completes normally, showing the single catch (OrderException e) fires only for the two failing orders.
Important notes
Exception messages travel into log files and sometimes into API responses, so put identifiers such as an account id in them and keep passwords, tokens and whole records out.
Throwable is Serializable, so a field holding something like an open Connection or a lazy entity can make the exception itself fail to serialize; keep fields to plain values and declare a serialVersionUID to satisfy -Xlint:serial.
Common mistakes
Wrapping with new MyException(e.getMessage()): the original object is dropped, so the trace ends at your throw statement and the line that actually failed is gone for good.
Passing only the cause, as in new MyException(e): the single-Throwable constructor sets the message to the cause's toString(), so the log describes the driver's complaint and never says what your code was attempting.
Storing details in fields but calling super() with no message: getMessage() returns null and the logged line is just the class name, so the context is only reachable by code that already knows the accessors exist.
Try it yourself
Change, predict, then run
Write RateLimitException extends RuntimeException taking clientId and retryAfterSeconds, building its message from both and exposing retryAfterSeconds(). Add static void check(String clientId, int callsUsed) that throws it when callsUsed exceeds 100, then catch it in main and print only the retry delay.
Open the Java workspaceCheck your understanding
A data access method catches SQLException while loading a user and wants to rethrow it as your own DataAccessException. Which rethrow leaves the most usable failure record?
- throw new DataAccessException(e.getMessage());
- throw new DataAccessException("could not load user " + id, e);
- e.printStackTrace(); throw new DataAccessException("could not load user " + id);
- throw new DataAccessException(e);
Show answer
Only the second option keeps your own description plus the id you were loading and links the original, so the printed trace still contains a Caused by: java.sql.SQLException section with the JDBC frames. new DataAccessException(e) is tempting because it does preserve the cause, but the single-Throwable constructor makes getMessage() the cause's toString(), so the log shows raw driver text and never mentions which user was being loaded; printing the trace separately just splits one failure across two unrelated log entries.