Custom and Chained exceptions in Java

  • Last Updated: October 26, 2024
  • By: javahandson
  • Series
img

Custom and Chained exceptions in Java

Custom and chained exceptions in Java let you name your own failures and keep the original cause attached. Learn to write checked and unchecked exception classes, wrap a low-level error, and read the Caused by section of a stack trace.

1. Introduction

Java ships with hundreds of exception classes. Sooner or later, not one of them describes what actually went wrong in your application.

Think about a hotel booking system. A guest asks for a room on a date with nothing free. Which built-in type says that? IllegalArgumentException comes close, and it still misses the point entirely.

Custom and chained exceptions in Java solve two related problems. A custom exception gives your failure a proper name. Chaining keeps the original error attached underneath, so nobody loses the real story.

These two ideas travel together in practice. You wrap a low-level IOException inside a meaningful BookingFailedException, and both survive into the log.

This is part 4 of our five-part series. Part 1 covered what exceptions are, part 2 covered try, catch and finally, and part 3 covered throwing exceptions.

1.1 What This Article Covers

We build one exception class from scratch, then learn to link it to the error beneath it. Here is the plan:

  • Why the built-in exception types eventually run out
  • Writing a custom checked exception by extending Exception
  • Writing a custom unchecked exception by extending RuntimeException
  • The four constructors your class should offer, and why
  • Adding your own fields so the exception carries useful data
  • What chaining means, and how a cause travels with an exception
  • Reading the Caused by section of a stack trace
  • The getCause method, root-cause loops, and interview questions

You need throw and throws from part 3 for this article. Everything about custom classes starts from zero here.

2. Why Built-in Types Run Out

2.1 The Vocabulary Problem

Java’s exception classes describe technical failures. A file went missing, a number would not parse, a reference held null.

Your application fails for different reasons. A coupon expired, a seat sold out, a payment bounced. None of that lives in java.lang.

So developers reach for the closest match and stuff the meaning into the message. Now the type says nothing, and only a human reading English can tell two failures apart.

2.2 What a Custom Exception Buys You

Give the failure its own class and everything downstream improves. The type itself starts carrying meaning.

  • Callers can catch exactly one failure and ignore the rest.
  • Your class can hold extra fields, such as the order id that failed.
  • The name appears in every log line, so searching becomes trivial.
  • Reviewers understand the intent without reading the message text.

Compare the two catch blocks below. One of them tells you what the code is really guarding against.

// Vague: which illegal argument, out of the twenty possible ones?
catch (IllegalArgumentException e) { ... }

// Precise: this block handles exactly one business failure
catch (InsufficientBalanceException e) { ... }

2.3 When Not to Write One

Custom classes are cheap to write, which makes them easy to overuse. Ask one question first.

Will anybody catch this type on its own? If every caller treats it exactly like IllegalArgumentException, then just throw IllegalArgumentException.

Part 3 listed the built-in types worth reaching for first. Use those for programming mistakes, and save your own classes for domain failures.

3. Writing a Custom Checked Exception

3.1 Extend the Exception Class

A checked exception extends Exception but not RuntimeException. That single choice puts your class under the catch-or-declare rule.

public class InsufficientBalanceException extends Exception {

    public InsufficientBalanceException(String message) {
        super(message);
    }
}

That is a complete, working exception class. Six lines, no extra machinery.

Notice the naming. End the class with the word Exception, because every reader of your code expects that suffix.

3.2 The Four Constructors

Constructors do not inherit, so your class only offers what you write. Throwable gives you four shapes worth forwarding.

public class InsufficientBalanceException extends Exception {

    public InsufficientBalanceException() {
        super();
    }

    public InsufficientBalanceException(String message) {
        super(message);
    }

    public InsufficientBalanceException(String message, Throwable cause) {
        super(message, cause);       // the one that enables chaining
    }

    public InsufficientBalanceException(Throwable cause) {
        super(cause);
    }
}

Here is what each one gives you:

Constructor Sets the message Sets the cause Use it when
() No, stays null No The class name alone says everything
(String message) Yes No Your code spotted the problem itself
(String message, Throwable cause) Yes Yes Another exception triggered this one
(Throwable cause) Copies cause.toString() Yes A pure wrapper with nothing to add

The third one matters most for this article. That Throwable cause parameter is the whole mechanism behind chaining.

Skip the fourth if you like, since its message just repeats the cause. Two or three constructors cover almost every real class.

3.3 A Complete Example

Let us throw our new type from a withdraw method. The balance rule belongs to the account, so the account raises the alarm.

package com.javahandson;

class InsufficientBalanceException extends Exception {

    InsufficientBalanceException(String message) {
        super(message);
    }
}

public class CustomCheckedExample {

    private static double balance = 100;

    static void withdraw(double amount) throws InsufficientBalanceException {
        if (amount > balance) {
            throw new InsufficientBalanceException(
                    "Balance " + balance + " is below the requested " + amount);
        }
        balance -= amount;
        System.out.println("Withdrew " + amount + ", balance is " + balance);
    }

    public static void main(String[] args) {
        try {
            withdraw(40);
            withdraw(500);
        } catch (InsufficientBalanceException e) {
            System.out.println("Failed: " + e.getMessage());
        }
    }
}
// Output:
// Withdrew 40.0, balance is 60.0
// Failed: Balance 60.0 is below the requested 500.0

Read the flow once. The first withdrawal passes the guard, so the balance drops to 60.

The second one breaks the rule, so withdraw throws and stops immediately. Our catch block prints the message, and the program ends normally.

One detail deserves attention. Because the throw happens before balance -= amount, the account never lands in a broken state.

3.4 What the Compiler Now Demands

Your class extends Exception, so the compiler treats it exactly like IOException. Every caller must catch it or declare it.

public static void main(String[] args) {
    withdraw(500);        // no try-catch, no throws clause
}
// error: unreported exception InsufficientBalanceException;
//        must be caught or declared to be thrown

Nothing here is special about custom classes. The catch-or-declare rule follows from the parent class you picked, and nothing else.

4. Writing a Custom Unchecked Exception

4.1 Extend RuntimeException Instead

Change one word in the class declaration and the compiler goes quiet. Extend RuntimeException and your exception becomes unchecked.

public class InvalidRatingException extends RuntimeException {

    public InvalidRatingException(String message) {
        super(message);
    }

    public InvalidRatingException(String message, Throwable cause) {
        super(message, cause);
    }
}

The body looks identical to the checked version. Only the parent changed, and that parent decides everything.

No caller has to catch this type. No method has to declare it either.

4.2 An Unchecked Example

Star ratings run from 1 to 5. Anything outside that range is a programming mistake, which makes unchecked the right family.

package com.javahandson;

class InvalidRatingException extends RuntimeException {

    InvalidRatingException(String message) {
        super(message);
    }
}

public class CustomUncheckedExample {

    static void rate(String movie, int stars) {      // no throws clause needed
        if (stars < 1 || stars > 5) {
            throw new InvalidRatingException("Rating must be 1 to 5, got " + stars);
        }
        System.out.println(movie + " rated " + stars);
    }

    public static void main(String[] args) {
        rate("Interstellar", 5);
        rate("Tenet", 9);
    }
}
// Output:
// Interstellar rated 5
// Exception in thread "main" com.javahandson.InvalidRatingException: Rating must be 1 to 5, got 9
//     at com.javahandson.CustomUncheckedExample.rate(CustomUncheckedExample.java:14)
//     at com.javahandson.CustomUncheckedExample.main(CustomUncheckedExample.java:22)

Look at the rate signature. It throws an exception, yet it declares nothing, and the code still compiles.

The second call ends the program, because nobody caught the exception. A stack trace lands in the console with our class name at the front.

4.3 Checked or Unchecked for Your Own Class?

So which parent do you pick? One question settles most cases.

Can the caller do something sensible about this failure? If yes, checked forces them to think about it. If the failure means somebody wrote a bug, unchecked fits better.

Aspect extends Exception extends RuntimeException
Family Checked Unchecked
Compiler rule Catch or declare, always No rule at all
throws clause Required on every method in the path Optional, and usually skipped
Best for Recoverable business failures Programming and validation errors
Typical example InsufficientBalanceException InvalidRatingException
Cost Noisy signatures up the call chain Easy for a caller to forget

Modern Java leans towards unchecked. Spring, Hibernate and most other frameworks wrap checked exceptions into unchecked ones on purpose. Nobody enjoys a throws clause on ten methods in a row.

5. Carrying Extra Data

5.1 Adding a Field

Here is where custom classes really earn their place. An exception is an object, so it can hold whatever data the handler needs.

public class InsufficientBalanceException extends Exception {

    private final double shortfall;

    public InsufficientBalanceException(String message, double shortfall) {
        super(message);
        this.shortfall = shortfall;
    }

    public double getShortfall() {
        return shortfall;
    }
}

Two rules keep this clean. Make the field final, and always call super(message) first so the message still works.

5.2 Reading It in the catch Block

Now the handler gets a number instead of a sentence it has to parse.

try {
    account.withdraw(500);
} catch (InsufficientBalanceException e) {
    System.out.println(e.getMessage());
    System.out.println("Add " + e.getShortfall() + " to continue");
}
// Output:
// Balance 60.0 is below the requested 500.0
// Add 440.0 to continue

That second line would be painful without the field. You would end up pulling digits out of a string, which breaks the moment somebody edits the wording.

Keep the extras small though. An id, an amount, a status code, and that is usually enough.

6. What Chained Exceptions Are

6.1 The Lost Cause Problem

Picture a three-layer application. The database layer hits an IOException, and the service layer wants to report a friendlier failure.

Most beginners write this, and it destroys the evidence:

try {
    readFromDisk(id);
} catch (IOException e) {
    throw new DataAccessException("Could not load user " + id);   // e vanishes
}

The new exception travels upward, and the IOException disappears with everything it knew. Which file? Which line? Nobody can tell any more.

You end up with a tidy message and zero debugging information. That trade is never worth it.

6.2 Wrapping Instead of Swallowing

Chaining fixes this. Pass the caught exception as the second argument, and it rides along as the cause.

try {
    readFromDisk(id);
} catch (IOException e) {
    throw new DataAccessException("Could not load user " + id, e);   // e survives
}

One extra character does all the work. Callers still see your meaningful type, and the original error stays reachable underneath it.

Think of a courier delivering a broken parcel. The outer note explains that the delivery failed, and the parcel inside shows exactly what shattered.

  • Upper layers keep a clean, domain-friendly exception type.
  • Lower-level detail survives all the way to your logs.
  • The stack trace prints both, one under the other.
  • Debugging starts from the real cause instead of a guess.

6.3 Two Ways to Attach a Cause

Java gives you two routes, and the constructor route wins almost every time.

// 1. The constructor, used in nearly all real code
throw new DataAccessException("Could not load user", e);

// 2. initCause, for classes that lack a cause constructor
DataAccessException error = new DataAccessException("Could not load user");
error.initCause(e);
throw error;

The initCause method comes from Throwable and works on any exception. It carries one strict rule though.

  • You may call it only once on a given exception object.
  • A second call throws IllegalStateException.
  • Calling it after a cause constructor also throws IllegalStateException.
  • Prefer it only when you cannot change the exception class yourself.

Both routes produce identical results. Write the constructor into your own classes and you will rarely touch initCause again.

7. Chaining in Action

7.1 The Program

Let us wrap a genuine IOException inside a custom unchecked exception, exactly as a data layer would.

package com.javahandson;

import java.io.IOException;

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

public class ChainingExample {
    public static void main(String[] args) {
        loadUser(42);
    }

    static void loadUser(int id) {
        try {
            readFromDisk(id);
        } catch (IOException e) {
            throw new DataAccessException("Could not load user " + id, e);
        }
    }

    static void readFromDisk(int id) throws IOException {
        throw new IOException("users.dat is missing");
    }
}
// Output:
// Exception in thread "main" com.javahandson.DataAccessException: Could not load user 42
//     at com.javahandson.ChainingExample.loadUser(ChainingExample.java:20)
//     at com.javahandson.ChainingExample.main(ChainingExample.java:13)
// Caused by: java.io.IOException: users.dat is missing
//     at com.javahandson.ChainingExample.readFromDisk(ChainingExample.java:25)
//     at com.javahandson.ChainingExample.loadUser(ChainingExample.java:18)
//     ... 1 more

Follow the three methods. The lowest one throws an IOException, and loadUser catches it.

Instead of rethrowing that IOException, loadUser wraps it in a DataAccessException. Nobody catches the wrapper, so the JVM prints the whole thing.

7.2 Reading the Caused by Section

That output has two halves, and the second half is the interesting one.

  • The top block describes the exception that reached the JVM.
  • Caused by: introduces the exception underneath it.
  • Each block lists the frames belonging to that exception.
  • ... 1 more means one frame repeats from the block above.

So the JVM never prints the same frames twice. Both traces end inside main, and rather than repeating it, Java summarises the overlap.

Where do you start debugging? Almost always at the bottom block, since it names the failure that actually started everything.

Part 1 of this series described exactly this layout when it introduced stack traces. Now you know which code produces that second half.

7.3 Chains Longer Than Two

A cause can have its own cause. Real applications often produce three or four Caused by blocks stacked together.

// Exception in thread "main" com.javahandson.CheckoutException: Order 77 failed
//     ...
// Caused by: com.javahandson.DataAccessException: Could not load user 42
//     ...
// Caused by: java.io.IOException: users.dat is missing
//     ...

Read a trace like this from the bottom up. The last Caused by block holds the root cause, and everything above it is a layer of wrapping.

Two or three layers stay useful. Beyond that, ask whether every layer really adds information, because a ten-deep chain just wastes space in the log.

8. Accessing the Cause in Code

8.1 The getCause Method

Printing is not the only option. Throwable exposes getCause(), so your catch block can inspect the original exception directly.

package com.javahandson;

import java.io.IOException;

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

public class GetCauseExample {

    static void loadUser(int id) {
        try {
            throw new IOException("users.dat is missing");
        } catch (IOException e) {
            throw new DataAccessException("Could not load user " + id, e);
        }
    }

    public static void main(String[] args) {
        try {
            loadUser(42);
        } catch (DataAccessException e) {
            System.out.println("Wrapper : " + e.getMessage());
            System.out.println("Cause   : " + e.getCause());
            System.out.println("Type    : " + e.getCause().getClass().getSimpleName());
        }
    }
}
// Output:
// Wrapper : Could not load user 42
// Cause   : java.io.IOException: users.dat is missing
// Type    : IOException

Three facts about getCause are worth memorising:

  • It returns a Throwable, since a cause can be any throwable type.
  • It returns null when nothing set a cause, so guard before dereferencing.
  • Printing the returned object shows its class name and its message.

That null case bites people. Call e.getCause().getMessage() on an unwrapped exception and you get a NullPointerException while handling an exception.

8.2 Walking to the Root Cause

Since each cause may hold another cause, a small loop reaches the bottom of any chain.

static Throwable rootCause(Throwable error) {
    Throwable current = error;

    while (current.getCause() != null) {
        current = current.getCause();
    }
    return current;
}

// usage
catch (DataAccessException e) {
    System.out.println("Root cause: " + rootCause(e));
}
// Output: Root cause: java.io.IOException: users.dat is missing

The loop climbs down one link at a time and stops when getCause() returns null. Utility libraries such as Apache Commons Lang ship the same helper as ExceptionUtils.getRootCause.

This pattern shows up constantly in real logging code. Interviewers like it too, because it proves you understand that a chain is just a linked list.

8.3 getCause vs getSuppressed

Two methods sound similar and mean completely different things. Part 2 met the second one already.

  • getCause() answers why this exception happened, and returns one throwable.
  • getSuppressed() answers what else failed alongside it, and returns an array.
  • Chaining fills the cause, and try-with-resources fills the suppressed list.
  • A stack trace labels them Caused by: and Suppressed: respectively.

Remember the split with one sentence. A cause sits below your exception, while a suppressed exception sits beside it.

9. Common Mistakes and Pitfalls

9.1 Dropping the Cause

This is the single most common mistake in the whole topic, and it costs real hours.

// Poor: the original exception is gone forever
catch (SQLException e) {
    throw new DataAccessException("Query failed");
}

// Better: one extra argument keeps the whole story
catch (SQLException e) {
    throw new DataAccessException("Query failed", e);
}

Whenever you type throw new inside a catch block, pass the caught exception too. Make it a reflex.

9.2 A New Class for Every Failure

Some projects grow forty exception classes, one per error message. Nobody catches thirty-eight of them.

Group failures that callers handle the same way. One ValidationException with a field name beats twelve near-identical classes.

9.3 Forgetting the Cause Constructor

Write only a (String) constructor and you quietly block chaining for everyone using your class.

class OrderException extends RuntimeException {
    OrderException(String message) {          // the only constructor
        super(message);
    }
}

// later, in a different file
catch (IOException e) {
    throw new OrderException("Order failed", e);   // error: constructor not found
}

Add the (String, Throwable) constructor from the start. It costs three lines and saves a future edit.

9.4 Extending Throwable or Error

Both compile, and both are wrong for application code. Extend Exception or RuntimeException, and nothing else.

The Error family belongs to the JVM for problems like OutOfMemoryError. Borrowing it tells every reader something untrue about your failure.

9.5 Logging and Rethrowing

Catching an exception, logging the stack trace, and then wrapping it produces the same trace twice in your log file.

// Poor: this trace gets printed here and again higher up
catch (IOException e) {
    e.printStackTrace();
    throw new DataAccessException("Could not load user", e);
}

Pick one. Either handle the exception here, or wrap it and let the layer above do the logging.

10. A Practical Walkthrough

10.1 The Task

Let us bring every idea together in one small program. We will look up an order that lives in a file on disk.

Three requirements shape the design. The service layer must not leak file-handling details, the caller needs the order id, and the original failure must survive.

So we write OrderNotFoundException as a checked exception. It carries an id field, and it accepts a cause.

10.2 The Code

package com.javahandson;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

class OrderNotFoundException extends Exception {

    private final int orderId;

    OrderNotFoundException(String message, int orderId, Throwable cause) {
        super(message, cause);
        this.orderId = orderId;
    }

    int getOrderId() {
        return orderId;
    }
}

public class OrderApp {

    // the storage layer, which only knows about files
    static String readOrderFile(int orderId) throws IOException {
        return Files.readString(Path.of("order-" + orderId + ".txt"));
    }

    // the service layer, which only knows about orders
    static String findOrder(int orderId) throws OrderNotFoundException {
        try {
            return readOrderFile(orderId);
        } catch (IOException e) {
            throw new OrderNotFoundException("No record for order " + orderId, orderId, e);
        }
    }

    public static void main(String[] args) {
        try {
            System.out.println(findOrder(77));
        } catch (OrderNotFoundException e) {
            System.out.println("Message : " + e.getMessage());
            System.out.println("Order id: " + e.getOrderId());
            System.out.println("Cause   : " + e.getCause());
        }
    }
}
// Output:
// Message : No record for order 77
// Order id: 77
// Cause   : java.io.FileNotFoundException: order-77.txt (No such file or directory)

10.3 Reading the Output

Walk the layers from the bottom. readOrderFile asks for a file that does not exist, so Files.readString throws a FileNotFoundException.

That type extends IOException, so our catch block matches it. We then throw OrderNotFoundException with three pieces of information: a message, the id, and the cause.

Main catches one type and prints all three. Notice what the service layer achieved here.

  • Callers never import anything from java.io.
  • Swapping files for a database changes only readOrderFile.
  • The id arrives as an int, so no code has to parse the message.
  • The original file error stays one getCause() call away.

One small note on that last line. The wording inside the brackets comes from the operating system, so Windows prints a different sentence than Linux.

Remove the try-catch in main and the program prints a full trace instead. Our exception sits on top, with the FileNotFoundException under Caused by.

11. Interview Questions

Q: What is a custom exception in Java?

A: A custom exception is an exception class you write yourself, usually to describe a failure specific to your application. Extend Exception for a checked custom exception, or RuntimeException for an unchecked one. The class then behaves like a built-in type. You can throw it, catch it, and add your own fields.

Q: How do you create a custom checked exception?

A: Create a class that extends Exception and give it a constructor that passes a message to super. The class extends Exception and not RuntimeException, so the catch-or-declare rule applies. Every caller must catch it or add a throws clause.

Q: What is the difference between a custom checked and a custom unchecked exception?

A: Only the parent class differs. Extending Exception makes it checked, so the compiler forces callers to catch or declare it. Extending RuntimeException makes it unchecked, so the compiler stays silent. Use checked for failures a caller can recover from, and unchecked for programming mistakes such as invalid arguments.

Q: What is exception chaining in Java?

A: Exception chaining links a new exception to the one that triggered it. You pass the original exception as the cause, usually through a constructor such as new DataAccessException(“message”, e). The wrapper keeps a meaningful type for upper layers, while the cause preserves the low-level detail for debugging.

Q: Why do we chain exceptions instead of just rethrowing?

A: Rethrowing the low-level exception forces every upper layer to know about files, sockets or SQL. Throwing a fresh exception without the cause loses the real reason for the failure. Chaining gives you both, because the caller sees a clean domain type and the log still shows the original error.

Q: What does the getCause method return?

A: It returns the Throwable that caused this exception, or null when no cause was ever attached. Always check for null before calling a method on the result, otherwise you risk a NullPointerException inside your own catch block.

Q: What does the Caused by section of a stack trace mean?

A: It shows the chained cause of the exception printed above it. The JVM prints the outer exception first, then a Caused by block for each cause down the chain. The line … N more means N frames repeat from the block above, so Java skips printing them again. Read the trace bottom up to find the root cause.

Q: What is the difference between the initCause method and the cause constructor?

A: Both attach a cause, and the result is identical. The constructor does it at creation time and reads better, so prefer it. Use initCause only when the exception class offers no cause constructor. It works once per object, and a second call throws IllegalStateException.

Q: Can a custom exception have extra fields and methods?

A: Yes, because an exception is an ordinary object. Add final fields such as an order id or an error code, set them in the constructor, and expose getters. The handler then reads real values instead of parsing the message text.

Q: Should a custom exception class extend Throwable or Error?

A: No. Extend Exception or RuntimeException instead. Java reserves the Error family for JVM-level problems such as OutOfMemoryError. Extending Throwable directly gives you nothing, and it confuses every reader of your code.

Q: What is the difference between getCause and getSuppressed?

A: The getCause method returns the single exception that triggered this one, which chaining sets. Meanwhile getSuppressed returns an array of exceptions that failed alongside it, which try-with-resources fills when a close call fails. A cause sits below your exception, and a suppressed exception sits beside it.

12. Conclusion

Let us wrap up what we covered. A custom exception is just a class extending Exception or RuntimeException, and that parent decides whether the compiler polices it.

Give the class a good name ending in Exception, a message constructor, and a (String, Throwable) constructor. Add your own final fields when the handler needs real data.

Chaining links a new exception to the one beneath it. Pass the caught exception as the cause, and the stack trace grows a Caused by section that points straight at the root problem.

Read those traces from the bottom up, and use getCause() when your code needs the original exception rather than a printed line. A short loop walks any chain down to its root.

Above all, never throw a new exception from a catch block without passing the old one along. That single habit will save you more debugging time than everything else in this article.

Part 5 finishes the series with the rules Java applies to exceptions in overloaded and overridden methods.

Further Reading

Leave a Comment