Java Custom Exceptions

Su Suriya Ravichandran Updated 14 Nov 2026
11 min read ·Lesson 131 of 133

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:

  1. Precise catching. A caller can write catch (InsufficientFundsException e) and react to exactly that situation — offer an overdraft, show the shortfall — without accidentally catching unrelated IllegalArgumentExceptions from elsewhere.
  2. 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.
  3. Readable code. throw new OrderAlreadyShippedException(orderId) documents the business rule at the throw site, and throws InsufficientFundsException documents it in the method signature.
  4. 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:

ExtendResultUse when
ExceptionChecked — callers must catch or declare itThe immediate caller is expected to handle the situation and recover (declined payment, insufficient funds)
RuntimeExceptionUnchecked — no compiler enforcementThe 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 categoryYour 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 Exception makes it a checked exception, so discountPercent must declare throws 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 the Exception constructor, which stores it. That's why getMessage() and toString() work without any extra code. If you forgot to call super(message), the message would be null.
  • System.out.println(e) uses Throwable.toString(): the class name, a colon and the message. Because the class has no package here, the name is just InvalidCouponException; in a real project it would be fully qualified, such as com.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:

  1. 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.
  2. 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.
  3. Fields are final. An exception describes something that already happened; it shouldn't change after it's created.
  4. 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. BigDecimal is 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);
    }
}
ConstructorUse 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:

  1. ShopException is abstract. Code can't throw a vague "shop error"; it must choose a specific subclass. The constructors are protected because only subclasses should call them.
  2. The base class owns the shared data. Every subclass gets an error code and getCode() without repeating the field.
  3. Subclass constructors take domain values (sku, available, reason) and build the message, just like InsufficientFundsException.
  4. InvalidAddressException wraps the cause. The PIN "60OO42" contains letter O's instead of zeros, so Integer.parseInt throws NumberFormatException. The catch turns that technical failure into a domain exception but passes the original as the cause, so logs still show exactly what went wrong. (The same catch also converts the explicit range check, since NumberFormatException is a subclass of IllegalArgumentException.)
  5. 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.
  6. It extends RuntimeException, following the common framework convention: business code throws without throws clauses, and a boundary handler maps error codes to HTTP responses (for example OUT_OF_STOCK → 409, ADDRESS_INVALID → 400).

Naming and Design Conventions

  • End the name with Exception: InsufficientFundsException, not InsufficientFunds or FundsError. Readers and tools recognise the suffix instantly.
  • Name the problem, not the location: OrderNotFoundException is clear; OrderServiceException says only where it came from.
  • Keep exceptions immutable: final fields, 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.

0 Comments

Reviewed before they appear

No comments yet.

Java
Java Introduction Java Features Java Syntax Java Comments Java Statements Java Output Java Variables Java Data Types Java Type Casting Java Operators Java Strings Java Numbers Java Math Java Booleans Java User Input Java Conditions Java If Else Java Switch Java While Loop Java For Loop Java Break Java Continue Java Arrays Java Methods Java Parameters Java Return Values Java Scope Java Recursion Install Java Install JDK Java IDEs Java JDK Java JRE Java JVM Java Compiler Java File Compile Java Run Java JShell Basics Java Packages Java OOP Java Classes Java Objects Class Methods Constructors Constructor Overload this Keyword static Keyword final Keyword Encapsulation Inheritance Polymorphism Abstraction Access Modifiers super Keyword Method Overload Method Override Instanceof Operator Nested Classes Inner Classes Anonymous Classes Enums Records Array Basics Array Length Loop Arrays Multidimensional Arrays Array Copy Array Sorting Array Search ArrayList LinkedList HashSet TreeSet HashMap TreeMap Queue Interface Deque Interface Stack Class Iterator List Interface Set Interface Map Interface Collections Class Comparable Comparator Generics Basics Generic Classes Generic Methods Wildcards String Basics String Length String Methods String Concatenation String Compare String Search String Replace String Split String Format StringBuilder StringBuffer Text Blocks Regular Expressions Number Types Integer Class Float Class Double Class BigInteger BigDecimal Math Class Random Numbers Number Format Date Basics LocalDate LocalTime LocalDateTime ZonedDateTime Date Formatting Date Parsing Period Class Duration Class Java Instant Class Java Exception Basics Java try...catch Java Multiple Catch Blocks Java finally Block Java throw Keyword Java throws Keyword Java Checked Exceptions Java Unchecked Exceptions Java Custom Exceptions Java try-with-resources Java Exception Chaining
Ask about this post
AI Ask about this post

Ask questions about Java Custom Exceptions and get answers drawn from it.

Signed-in readers only.