You create a custom exception in Java when the failure has a name of its own that the caller can react to — "insufficient funds" or "invalid email" are decisions, not accidents. The mechanics are simple: extend Exception or RuntimeException, add a constructor that calls super, and decide whether the extra fields you carry are worth exposing.
When is a custom exception worth the extra class?
Write one when the failure is part of your domain vocabulary and the caller can act on it differently than on a generic failure. If your payment code can distinguish "the card was declined" from "the network is down" and wants to show different messages or take different actions, that distinction deserves a type of its own rather than a string comparison against a message.
Skip it when the only extra information is a message. Wrapping a failure in a custom class that carries nothing but a better sentence adds a file to maintain and a type to catch, and buys very little. A plain IllegalStateException with a good message is often the better trade.
The choice between the two base classes is the one decision you must make deliberately, and it follows from the rule you met in the hierarchy lesson. Extend Exception when the failure is an expected, recoverable outcome that callers must handle explicitly, such as a rejected payment. Extend RuntimeException when the failure means the caller did something wrong, such as passing a malformed identifier to a parser. A checked custom exception changes the signature of every method that can produce it, so use it when that is genuinely the contract you want.
How do you build a checked custom exception?
Extend Exception, add the data the caller needs, and pass a message up to the parent class so the standard getMessage() still works. Fields you add are yours to expose with whatever accessors suit the design.
public class CustomCheckedDemo {
static class InsufficientFundsException extends Exception {
private final int requested;
private final int available;
InsufficientFundsException(int requested, int available) {
super("need " + requested + " but only " + available + " available");
this.requested = requested;
this.available = available;
}
int shortfall() {
return requested - available;
}
}
public static void main(String[] args) {
try {
withdraw(900);
} catch (InsufficientFundsException e) {
System.out.println(e.getMessage());
System.out.println("short by " + e.shortfall());
}
}
static void withdraw(int amount) throws InsufficientFundsException {
if (amount > 500) {
throw new InsufficientFundsException(amount, 500);
}
System.out.println("withdrew " + amount);
}
}
need 900 but only 500 available
short by 400
Two details are deliberate. The message is built inside the constructor and handed to super, so every instance has a meaningful description without the throwing code having to remember to write one. And the extra data is stored in final fields with an accessor, so the caller can react to the numbers instead of parsing the message text — parsing a message is the practice that custom exceptions exist to replace.
How do you build an unchecked one?
The shape is identical, only the parent class changes. Because unchecked exceptions need no declaration, a validation helper can throw one without forcing every caller to add a catch clause, which is the right ergonomics for "you passed me something invalid".
public class CustomUncheckedDemo {
static class InvalidEmailException extends RuntimeException {
private final String email;
InvalidEmailException(String email) {
super("not a valid email: " + email);
this.email = email;
}
String email() {
return email;
}
}
static void register(String email) {
if (!email.contains("@") || email.endsWith("@")) {
throw new InvalidEmailException(email);
}
System.out.println("registered " + email);
}
public static void main(String[] args) {
register("[email protected]");
try {
register("not-an-email");
} catch (InvalidEmailException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
registered [email protected]
rejected: not a valid email: not-an-email
Notice that the validation lives in the method that owns the rule rather than in the caller. The caller can catch the specific type when it wants to react, or let it propagate when a bad address really is a programming error. Both choices work, and the type makes the difference explicit.
How do you keep the original failure?
When your code detects a problem that was caused by a lower-level failure, wrap the original instead of discarding it. The parent class has a constructor that takes a message and a cause, and it stores the cause where getCause() can find it.
public class WrappingDemo {
static class OrderProcessingException extends Exception {
OrderProcessingException(String message, Throwable cause) {
super(message, cause);
}
}
static void saveOrder(String id) throws OrderProcessingException {
try {
throw new java.io.IOException("disk is full");
} catch (java.io.IOException e) {
throw new OrderProcessingException("could not save order " + id, e);
}
}
public static void main(String[] args) {
try {
saveOrder("A-1042");
} catch (OrderProcessingException e) {
System.out.println("outer: " + e.getMessage());
Throwable cause = e.getCause();
System.out.println("inner: " + cause.getClass().getSimpleName()
+ " -> " + cause.getMessage());
}
}
}
outer: could not save order A-1042
inner: IOException -> disk is full
The outer exception says what failed in business terms; the inner one says why it failed technically. If the cause is discarded, the report stops at "could not save order" and the person on call has no way to learn that the disk filled up.
Which habits make custom exceptions worse?
Three come up repeatedly. First, an exception class that carries no information beyond its message — it costs a file and a type and gives nothing back. Second, throwing an exception for ordinary control flow, such as signalling "not found" in a loop where a return value would be clearer; exceptions are for exceptional situations, and using them as return values makes the hot path slow and the code noisy. Third, catching your own exception and doing nothing with it, which leaves the caller with no signal at all. If you catch it, log it, translate it, or add context and rethrow it.
Keep the constructors minimal. One that takes the detail your code needs and one that takes a message plus a cause cover almost every case, and a small constructor set is easier to use correctly than a long list of overloads that differ only in argument order.
A short checklist before you commit
- The failure has a name the domain actually uses, and a caller would treat it differently from other failures.
- You chose the base class deliberately:
Exceptionfor expected outcomes,RuntimeExceptionfor caller mistakes. - The message is built in the constructor and passed to
super. - Extra data is exposed as fields or accessors, not by parsing the message.
- The original cause is preserved whenever you are wrapping another failure.
Try it yourself: write a class SeatUnavailableException extending Exception that carries the row and the seat number, and a method reserve(int row, int seat) that throws it for seat numbers below 1 or above 20. Catch it in main, print the row and seat from the exception object rather than from the message, and confirm the message alone is enough to identify the problem. Then change the class to extend RuntimeException and remove the catch block to see how the call site changes.