Exception Handling

Custom Exception Classes

15 min Lesson 7 of 14

Custom Exception Classes

The exceptions Java ships with — IllegalArgumentException, IOException, NullPointerException — are generic. When you read a stack trace that says IllegalArgumentException: invalid value you still have to guess which value was invalid and why. Custom exception classes solve this: they give a precise name to the problem, they live in your domain, and they can carry extra context that generic exceptions cannot.

Why Create Your Own Exceptions?

  • Meaningful names. InsufficientFundsException communicates the problem instantly; RuntimeException: balance too low does not.
  • Catch selectively. Callers can catch only the exact failure they know how to handle, without accidentally silencing unrelated problems.
  • Carry domain data. A custom class can store the account number, the requested amount, and the current balance — not just a string message.
  • Cleaner APIs. A method signature like throws InsufficientFundsException documents intent far better than throws Exception.

Extending Exception vs. RuntimeException

This is the most important design choice. The rule from Lesson 4 still applies:

  • Extend Exception (checked) when the caller should handle the failure — file not found, network timeout, business-rule violations the caller can recover from.
  • Extend RuntimeException (unchecked) when the failure is a programming mistake — invalid argument, wrong state, broken contract — that callers should not normally catch.
Domain logic errors are usually checked. If your bank transfer method can legitimately fail because a user has insufficient funds, that is a recoverable situation the UI must handle — so InsufficientFundsException extends Exception is the right choice. If an internal utility receives a null it was never supposed to receive, extends RuntimeException is cleaner.

The Minimal Pattern

Every custom exception needs at minimum a constructor that accepts a message and forwards it to the parent:

public class InsufficientFundsException extends Exception { public InsufficientFundsException(String message) { super(message); } }

That is all you need to throw it and catch it by name:

public void withdraw(double amount) throws InsufficientFundsException { if (amount > balance) { throw new InsufficientFundsException( "Cannot withdraw " + amount + "; balance is " + balance ); } balance -= amount; }

Adding a Cause Constructor

Always provide a constructor that accepts a Throwable cause. This lets you wrap lower-level exceptions without losing the original stack trace — a practice covered in Lesson 6 (rethrowing).

public class DataAccessException extends RuntimeException { public DataAccessException(String message) { super(message); } public DataAccessException(String message, Throwable cause) { super(message, cause); } }

Storing Domain-Specific Data

A custom class can hold any fields that help callers react intelligently to the failure:

public class InsufficientFundsException extends Exception { private final double requested; private final double available; public InsufficientFundsException(double requested, double available) { super("Requested " + requested + " but only " + available + " available"); this.requested = requested; this.available = available; } public double getRequested() { return requested; } public double getAvailable() { return available; } public double getShortfall() { return requested - available; } }

Now a caller can present a helpful message to the user without parsing a string:

try { account.withdraw(500.00); } catch (InsufficientFundsException e) { System.out.println("Transfer failed. You need " + e.getShortfall() + " more to complete this transaction."); }

Building a Small Exception Hierarchy

For a larger application, group related exceptions under a common base so callers can catch broadly or narrowly:

// Base class for all payment-related failures public class PaymentException extends Exception { public PaymentException(String message) { super(message); } public PaymentException(String message, Throwable cause) { super(message, cause); } } // Specific subtypes public class InsufficientFundsException extends PaymentException { public InsufficientFundsException(double shortfall) { super("Short by " + shortfall); } } public class CardDeclinedException extends PaymentException { public CardDeclinedException(String reason) { super("Card declined: " + reason); } }

A payment gateway handler can catch all payment problems with catch (PaymentException e), while a refund module that only cares about declined cards can catch CardDeclinedException specifically.

Keep the hierarchy shallow. One level of specialisation (base + concrete subtypes) is almost always enough. Deep hierarchies are hard to navigate and rarely add value.

Naming Convention

Every custom exception class name should end with Exception — ValidationException, OrderNotFoundException, RateLimitExceededException. Java developers expect this; breaking the convention confuses readers.

Do not extend Error or Throwable directly. Error is reserved for JVM-level failures (out of memory, stack overflow). Extending Throwable bypasses the checked/unchecked distinction entirely and breaks assumptions throughout the language. Always extend Exception or RuntimeException.

Summary

Custom exception classes make your code self-documenting and easier to maintain. Extend Exception for recoverable domain failures, extend RuntimeException for programming errors. Always provide a message constructor and a cause constructor. Add fields when callers need structured data beyond a message string. Keep hierarchies flat — a well-named base and a handful of concrete subtypes is the right shape for most applications.