Module 8 · Exceptions, I/O & Resources ⏱ 19 min

Designing Custom Exceptions

By the end of this lesson you will be able to:
  • Decide when to extend Exception versus RuntimeException
  • Chain the original failure as the cause to preserve the stack trace
  • Add custom fields and constructors that carry domain-specific context

Built-in exceptions like IllegalArgumentException and IOException cover generic failure modes, but they do not tell the caller which business rule broke. Was the payment denied because the card expired, or because the amount exceeded the limit? Catching a vague exception forces the caller to parse error messages — a fragile habit that breaks whenever someone rewords the text.

A custom exception is a class you write that extends Exception (or RuntimeException) and carries meaning specific to your domain. It lets callers catch exactly the failure they care about, without guessing from a string. It also lets you attach extra fields — an error code, a transaction ID, or the invalid value itself — so the handler has structured data instead of a sentence to parse.

Custom exceptions are not decoration. They are how you turn a generic crash into a precise, actionable signal. When you catch PaymentDeclinedException instead of Exception, your handler knows immediately which recovery path to take — retry with a different card, prompt the user, or log a fraud alert. That precision is why production codebases contain dozens of custom exceptions, each one representing a specific domain rule that can be violated.

flowchart TD
  T["Throwable"] --> E["Exception"]
  E --> RE["RuntimeException"]
  E --> CE["PaymentException<br/>(checked, yours)"]
  RE --> RCE["PaymentRuntimeException<br/>(unchecked, yours)"]
  style CE fill:#c2410c,color:#fff
  style RCE fill:#c2410c,color:#fff
Your custom exceptions sit in the same hierarchy as the built-in ones. The choice of parent decides whether the compiler enforces handling.

Extending Exception

A custom exception is just a class. The minimum viable version has a name that ends in Exception and a constructor that forwards a message to super:

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

Because it extends Exception, it is a checked exception. The compiler forces every method that throws it to declare it in its throws clause, and every caller to handle it or redeclare it. If that feels heavy for a bug that probably represents a programming error, extend RuntimeException instead — then it is unchecked and the compiler stays out of the way.

The naming convention matters. Ending with Exception tells readers immediately that this type is throwable. InsufficientFundsError would confuse Java developers, who reserve Error for unrecoverable system problems like OutOfMemoryError.

Checked or unchecked?

The choice between checked and unchecked is one of the most debated topics in Java design. Checked exceptions are appropriate for recoverable conditions: a file is missing, a network timed out, or a user provided invalid input. Unchecked exceptions fit programming errors: a null reference was dereferenced, an array index was out of bounds, or an invariant was violated.

When in doubt, ask whether the caller can reasonably do something useful with the exception. If the answer is no, make it unchecked.

A checked custom exception with a message constructor. Real, compiling Java.
public class Main {
    static class PaymentException extends Exception {
        PaymentException(String message) {
            super(message);
        }
    }

    public static void main(String[] args) throws PaymentException {
        throw new PaymentException("Card declined");
    }
}

Chaining the cause

Real failures often stack. A PaymentException might be triggered by an IOException from the network. If you discard the original IOException and replace it with your own message, you erase the stack trace that tells you where the network broke.

The fix is the cause constructor. Every exception class inherits Throwable(String message, Throwable cause). Pass the original exception as the cause, and the JVM preserves both stack traces:

throw new PaymentException("Gateway timeout", originalIOException);

Now a caller can catch PaymentException, log your business message, and still drill down to the exact line that threw the IOException. Without the cause, debugging becomes archaeology.

This is especially important in layered architectures. The service layer should not leak database exceptions to the controller; it should wrap them in a domain exception that the controller can map to an HTTP status code. But that wrapper must still contain the original database exception so the operations team can diagnose the root cause from the logs.

flowchart LR
  IO["IOException thrown<br/>in chargeCard()"] --> CATCH["catch(IOException e)"]
  CATCH --> WRAP["new PaymentException<br/>(#quot;Payment failed#quot;, e)"]
  WRAP --> OUT["PaymentException carries<br/>its own stack + cause stack"]
  style WRAP fill:#c2410c,color:#fff
Wrapping the original exception as the cause keeps both stack traces intact.
Chaining the original IOException as the cause. Real, compiling Java.
public class Main {
    static class PaymentException extends Exception {
        PaymentException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static void chargeCard() throws java.io.IOException {
        throw new java.io.IOException("Connection reset");
    }

    public static void main(String[] args) {
        try {
            chargeCard();
        } catch (java.io.IOException e) {
            try {
                throw new PaymentException("Payment failed", e);
            } catch (PaymentException pe) {
                System.out.println(pe.getMessage());
                System.out.println(pe.getCause().getClass().getSimpleName());
            }
        }
    }
}
Exercise

What does this program print? Read it carefully and type the exact output (one line).

public class Main {
    static class InvalidOrderException extends Exception {
        InvalidOrderException(String message) {
            super(message);
        }
    }

    public static void main(String[] args) {
        try {
            throw new InvalidOrderException("Item out of stock");
        } catch (InvalidOrderException e) {
            System.out.println(e.getMessage());
        }
    }
}
Exercise

When is it correct to extend RuntimeException instead of Exception for a custom exception?

Exercise

Cause check. This code wraps an exception poorly — it forwards only the message. Predict the exact output (one line).

public class Main {
    static class GatewayException extends Exception {
        GatewayException(String message) {
            super(message);
        }
    }

    public static void main(String[] args) {
        try {
            try {
                throw new java.io.IOException("Reset");
            } catch (java.io.IOException e) {
                throw new GatewayException(e.getMessage());
            }
        } catch (GatewayException ge) {
            System.out.println(ge.getCause() == null);
        }
    }
}
Exercise

Which constructor best preserves debugging information when wrapping a lower-level failure?

Exercise

You need an exception that callers must either catch or declare, and you want to include an integer error code. Which design is correct?

Recap

  • A custom exception is a class that extends Exception (checked) or RuntimeException (unchecked).
  • Name it with an Exception suffix so readers instantly know it is throwable.
  • Provide constructors that forward message and cause to super so the stack trace survives.
  • Forwarding only e.getMessage() discards the cause — a silent debugging killer.
  • Add custom fields when the handler needs structured data, not just a sentence.
  • Choose checked when the caller can recover; choose unchecked for programming errors.

Next you will learn to manage files and streams with java.nio.file, and how to handle the IOException that almost every file operation can throw.

Checkpoint quiz

What does extending Exception give you that extending RuntimeException does not?

Why should custom exceptions provide a constructor that accepts a Throwable cause?

Go deeper — technical resources