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 ExceptionValidationException, 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.