Custom and Chained exceptions in Java
-
Last Updated: October 26, 2024
-
By: javahandson
-
Series
Learn Java in a easy way
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.
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.
We build one exception class from scratch, then learn to link it to the error beneath it. Here is the plan:
Caused by section of a stack traceYou need throw and throws from part 3 for this article. Everything about custom classes starts from zero here.
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.
Give the failure its own class and everything downstream improves. The type itself starts carrying meaning.
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) { ... }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.
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.
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.
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.0Read 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.
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 thrownNothing here is special about custom classes. The catch-or-declare rule follows from the parent class you picked, and nothing else.
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.
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.
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.
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.
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 continueThat 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.
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.
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.
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.
Both routes produce identical results. Write the constructor into your own classes and you will rarely touch initCause again.
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 moreFollow 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.
That output has two halves, and the second half is the interesting one.
Caused by: introduces the exception underneath it.... 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.
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.
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 : IOExceptionThree facts about getCause are worth memorising:
Throwable, since a cause can be any throwable type.null when nothing set a cause, so guard before dereferencing.That null case bites people. Call e.getCause().getMessage() on an unwrapped exception and you get a NullPointerException while handling an exception.
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 missingThe 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.
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.Caused by: and Suppressed: respectively.Remember the split with one sentence. A cause sits below your exception, while a suppressed exception sits beside it.
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.
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.
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.
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.
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.
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.
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)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.
java.io.readOrderFile.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.