Java Custom Exceptions
What a Custom Exception Is
A custom exception is an exception class you define yourself by extending one of Java's exception classes. It represents a failure that is specific to your application's domain — an invalid coupon, insufficient funds, an out-of-stock product — rather than a general technical problem.
public class InvalidCouponException extends Exception {
public InvalidCouponException(String message) {
super(message);
}
}
That is a complete, working exception class. Everything that makes it an exception — the message, the stack trace, the ability to be thrown and caught — is inherited from Throwable.
Why Create Custom Exceptions
The JDK's built-in exceptions describe technical problems. IllegalArgumentException says "an argument was wrong", but in a banking application, "the balance is too low" and "the amount is negative" are very different situations that callers want to handle differently. Custom exceptions give you:
- Precise catching. A caller can write
catch (InsufficientFundsException e)and react to exactly that situation — offer an overdraft, show the shortfall — without accidentally catching unrelatedIllegalArgumentExceptions from elsewhere. - Structured data. A custom class can carry fields (
balance,requested,errorCode) instead of hiding information inside a message string that callers would have to parse. - Readable code.
throw new OrderAlreadyShippedException(orderId)documents the business rule at the throw site, andthrows InsufficientFundsExceptiondocuments it in the method signature. - Consistent error handling. A shared base class lets one handler deal with all application errors, while subclasses keep the details.
Don't create one when a standard exception already says exactly what you mean. A null argument is still best reported with NullPointerException (via Objects.requireNonNull), and a negative quantity with IllegalArgumentException. Custom exceptions are for conditions callers may want to treat differently from the standard ones.
Checked or Unchecked: Choosing the Parent Class
The class you extend decides how the compiler treats your exception:
| Extend | Result | Use when |
|---|---|---|
Exception | Checked — callers must catch or declare it | The immediate caller is expected to handle the situation and recover (declined payment, insufficient funds) |
RuntimeException | Unchecked — no compiler enforcement | The failure is a broken rule that a central handler deals with, or the exception must pass through lambdas/streams |
A specific standard exception (e.g. IllegalStateException, IOException) | Inherits that meaning and category | Your exception is a more specific case of an existing one |
Error or Throwable | — | Don't. Error is reserved for JVM problems, and catching Throwable subclasses that aren't exceptions confuses every handler |
A Simple Checked Custom Exception
import java.util.*;
class InvalidCouponException extends Exception {
public InvalidCouponException(String message) {
super(message);
}
}
public class Coupon {
static final Map<String, Integer> COUPONS = Map.of("WELCOME10", 10, "DIWALI25", 25);
static int discountPercent(String code) throws InvalidCouponException {
Integer percent = COUPONS.get(code.toUpperCase());
if (percent == null) {
throw new InvalidCouponException("coupon '" + code + "' does not exist");
}
return percent;
}
public static void main(String[] args) {
for (String code : List.of("diwali25", "FREE100")) {
try {
System.out.println(code + " -> " + discountPercent(code) + "% off");
} catch (InvalidCouponException e) {
System.out.println(e);
}
}
}
}
Compile and run (both classes are in Coupon.java):
javac Coupon.java
java Coupon
Output:
diwali25 -> 25% off
InvalidCouponException: coupon 'FREE100' does not exist
How it works:
extends Exceptionmakes it a checked exception, sodiscountPercentmust declarethrows InvalidCouponException, and every caller must handle it. That is intentional: a wrong coupon is a normal event the checkout page must respond to.super(message)passes the message to theExceptionconstructor, which stores it. That's whygetMessage()andtoString()work without any extra code. If you forgot to callsuper(message), the message would benull.System.out.println(e)usesThrowable.toString(): the class name, a colon and the message. Because the class has no package here, the name is justInvalidCouponException; in a real project it would be fully qualified, such ascom.gocourse.shop.InvalidCouponException.
Only one class in a source file can be public, and it must match the file name. The exception class here is package-private for that reason. In real projects, each exception goes in its own file, usually in an exception package.
Adding Fields: Exceptions That Carry Data
A message is meant for humans. Code that handles the exception often needs the underlying values. Store them in final fields and expose getters.
import java.math.BigDecimal;
class InsufficientFundsException extends Exception {
private final String accountId;
private final BigDecimal balance;
private final BigDecimal requested;
public InsufficientFundsException(String accountId, BigDecimal balance, BigDecimal requested) {
super("account " + accountId + ": balance " + balance + " is less than requested " + requested);
this.accountId = accountId;
this.balance = balance;
this.requested = requested;
}
public String getAccountId() { return accountId; }
public BigDecimal getBalance() { return balance; }
public BigDecimal getRequested() { return requested; }
public BigDecimal getShortfall() { return requested.subtract(balance); }
}
class Account {
private final String id;
private BigDecimal balance;
Account(String id, String opening) {
this.id = id;
this.balance = new BigDecimal(opening);
}
void withdraw(BigDecimal amount) throws InsufficientFundsException {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive: " + amount);
}
if (amount.compareTo(balance) > 0) {
throw new InsufficientFundsException(id, balance, amount);
}
balance = balance.subtract(amount);
}
BigDecimal balance() { return balance; }
}
public class Bank {
public static void main(String[] args) {
Account acc = new Account("SB-2041", "2500.00");
String[] requests = {"1000.00", "1800.50"};
for (String r : requests) {
try {
acc.withdraw(new BigDecimal(r));
System.out.println("withdrew " + r + ", balance " + acc.balance());
} catch (InsufficientFundsException e) {
System.out.println("declined: " + e.getMessage());
System.out.println(" add at least " + e.getShortfall() + " to account " + e.getAccountId());
}
}
}
}
Output:
withdrew 1000.00, balance 1500.00
declined: account SB-2041: balance 1500.00 is less than requested 1800.50
add at least 300.50 to account SB-2041
Points to notice:
- The constructor builds the message from the data. Callers throw it with just the values (
new InsufficientFundsException(id, balance, amount)), so every instance has a consistent, complete message. getShortfall()is behaviour on the exception. The handler asks the exception for the amount missing (1800.50 - 1500.00 = 300.50) instead of recomputing it or parsing the message. A UI could use the same value to pre-fill a "top up" form.- Fields are
final. An exception describes something that already happened; it shouldn't change after it's created. - Two different exceptions for two different problems. A non-positive amount is a programming or input-validation error, so it stays an unchecked
IllegalArgumentException. Insufficient funds is a legitimate business outcome the caller must handle, so it's a checked custom exception.BigDecimalis used because money must not suffer floating-point rounding errors.
The Standard Constructors
Exception and RuntimeException provide four public constructors. A reusable custom exception commonly offers the ones it needs:
public class ReportGenerationException extends Exception {
public ReportGenerationException() {
super();
}
public ReportGenerationException(String message) {
super(message);
}
public ReportGenerationException(String message, Throwable cause) {
super(message, cause);
}
public ReportGenerationException(Throwable cause) {
super(cause);
}
}
| Constructor | Use it when |
|---|---|
() | Rarely — an exception without a message is hard to diagnose |
(String message) | You detected the problem yourself |
(String message, Throwable cause) | You caught a lower-level exception and are converting it — the original is kept as the cause |
(Throwable cause) | Wrapping without extra explanation; the message becomes cause.toString() |
Constructors are not inherited in Java, so a subclass only has the constructors you write. The (message, cause) version is the most important one to include whenever your exception may wrap another failure: without it, the original exception and its stack trace are lost. The Exception Chaining lesson explains causes in detail.
Practical Example: An Exception Hierarchy for an Application
Larger applications usually define a base exception for the whole application and specific subclasses beneath it. Callers can catch the specific type when they care, or the base type to handle all application errors uniformly. An error code enum gives each failure a stable, machine-readable identifier — useful for API responses, translations and monitoring — that doesn't change when the human-readable message is reworded.
import java.util.*;
enum ErrorCode { OUT_OF_STOCK, PAYMENT_DECLINED, ADDRESS_INVALID }
abstract class ShopException extends RuntimeException {
private final ErrorCode code;
protected ShopException(ErrorCode code, String message) {
super(message);
this.code = code;
}
protected ShopException(ErrorCode code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public ErrorCode getCode() { return code; }
}
class OutOfStockException extends ShopException {
OutOfStockException(String sku, int available) {
super(ErrorCode.OUT_OF_STOCK, sku + " has only " + available + " left");
}
}
class PaymentDeclinedException extends ShopException {
PaymentDeclinedException(String reason) {
super(ErrorCode.PAYMENT_DECLINED, "payment declined: " + reason);
}
}
class InvalidAddressException extends ShopException {
InvalidAddressException(String pin, Throwable cause) {
super(ErrorCode.ADDRESS_INVALID, "invalid PIN code " + pin, cause);
}
}
public class Shop {
record Order(String sku, int qty, String pin, String card) { }
static void checkout(String sku, int qty, String pin, String card) {
Map<String, Integer> stock = Map.of("PEN-01", 40, "BAG-07", 1);
int available = stock.getOrDefault(sku, 0);
if (qty > available) throw new OutOfStockException(sku, available);
try {
if (Integer.parseInt(pin) < 110001) throw new IllegalArgumentException("below valid range");
} catch (IllegalArgumentException e) {
throw new InvalidAddressException(pin, e);
}
if (card.endsWith("0000")) throw new PaymentDeclinedException("card blocked");
System.out.println("order placed: " + qty + " x " + sku + " to " + pin);
}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("PEN-01", 2, "600042", "4111-1111"),
new Order("BAG-07", 3, "600042", "4111-1111"),
new Order("PEN-01", 1, "60OO42", "4111-1111"),
new Order("PEN-01", 1, "600042", "4111-0000"));
for (Order o : orders) {
try {
checkout(o.sku(), o.qty(), o.pin(), o.card());
} catch (OutOfStockException e) {
System.out.println("suggest restock alert -> " + e.getMessage());
} catch (ShopException e) {
String cause = e.getCause() == null ? "" : " [cause: " + e.getCause().getClass().getSimpleName() + "]";
System.out.println(e.getCode() + " -> " + e.getMessage() + cause);
}
}
}
}
Output:
order placed: 2 x PEN-01 to 600042
suggest restock alert -> BAG-07 has only 1 left
ADDRESS_INVALID -> invalid PIN code 60OO42 [cause: NumberFormatException]
PAYMENT_DECLINED -> payment declined: card blocked
Step by step:
ShopExceptionisabstract. Code can't throw a vague "shop error"; it must choose a specific subclass. The constructors areprotectedbecause only subclasses should call them.- The base class owns the shared data. Every subclass gets an error code and
getCode()without repeating the field. - Subclass constructors take domain values (
sku,available,reason) and build the message, just likeInsufficientFundsException. InvalidAddressExceptionwraps the cause. The PIN"60OO42"contains letter O's instead of zeros, soInteger.parseIntthrowsNumberFormatException. Thecatchturns that technical failure into a domain exception but passes the original as the cause, so logs still show exactly what went wrong. (The samecatchalso converts the explicit range check, sinceNumberFormatExceptionis a subclass ofIllegalArgumentException.)- Handlers choose their level of detail.
catch (OutOfStockException e)gives stock problems special treatment;catch (ShopException e)handles every other application error generically using the error code. It comes second because it's the parent type. - It extends
RuntimeException, following the common framework convention: business code throws withoutthrowsclauses, and a boundary handler maps error codes to HTTP responses (for exampleOUT_OF_STOCK → 409,ADDRESS_INVALID → 400).
Naming and Design Conventions
- End the name with
Exception:InsufficientFundsException, notInsufficientFundsorFundsError. Readers and tools recognise the suffix instantly. - Name the problem, not the location:
OrderNotFoundExceptionis clear;OrderServiceExceptionsays only where it came from. - Keep exceptions immutable:
finalfields, set in the constructor, no setters. - Write messages for the person debugging: include the identifiers and values involved (
"order ORD-1001 is already SHIPPED"), but never secrets such as passwords, full card numbers or tokens — exception messages end up in logs. - Don't build deep hierarchies without a reason. One base class plus subclasses that callers actually catch separately is usually enough. A subclass nobody catches specifically adds little.
- Prefer reusing standard exceptions for generic contract violations (
IllegalArgumentException,IllegalStateException,UnsupportedOperationException) and define custom ones for domain outcomes.
serialVersionUID
Throwable implements Serializable, so every exception class is serializable — exceptions can be sent between JVMs, for example in RMI or some messaging systems. Compiling with -Xlint:serial (or -Xlint:all) reports a warning for custom exceptions:
warning: [serial] serializable class ReportException has no definition of serialVersionUID
Declaring the field removes the warning and makes the serialized form's version explicit:
public class ReportException extends Exception {
private static final long serialVersionUID = 1L;
public ReportException(String message) {
super(message);
}
}
Fields in a custom exception should then also be serializable (as String, BigDecimal and enums are) or marked transient. Many codebases add serialVersionUID = 1L to every exception as a habit for this reason.
Exceptions for Expected, High-Frequency Outcomes
Creating an exception captures a stack trace, which costs time proportional to the call depth. For normal business outcomes this cost is negligible. If an exception type is thrown very frequently on a hot path and its stack trace is never useful, Throwable has a protected constructor that skips it:
public class RateLimitedException extends RuntimeException {
public RateLimitedException(String message) {
super(message, null, false, false); // no suppression, no stack trace
}
}
The last two arguments are enableSuppression and writableStackTrace. With writableStackTrace set to false, getStackTrace() returns an empty array, which also makes such exceptions much harder to debug. Use this only after measuring a real performance problem; in most code, a result type (such as an Optional or a status value) is a clearer choice for very frequent outcomes than an exception.