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
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.
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
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());
}
}
}
}
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());
}
}
}
The exception is constructed with the message "Item out of stock".
getMessage() returns exactly the string passed to the constructor.
When is it correct to extend RuntimeException instead of Exception for a custom exception?
RuntimeException is for unchecked exceptions — bugs, precondition violations, and unrecoverable states. The compiler does not enforce handling. Choose Exception when the caller can reasonably be expected to recover.
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);
}
}
}
The GatewayException constructor only takes a String, not a Throwable cause.
Because the original IOException is not passed as the cause, getCause() returns null.
ge.getCause() == null evaluates to true, which is printed.
Which constructor best preserves debugging information when wrapping a lower-level failure?
The two-argument constructor stores the original exception as the cause, preserving its full stack trace. A message-only constructor silently drops that context.
You need an exception that callers must either catch or declare, and you want to include an integer error code. Which design is correct?
Extending Exception makes the exception checked, forcing callers to handle it. Adding a field and a matching constructor lets you carry structured domain data alongside the message. Extending Throwable is discouraged for application code.
Recap
- A custom exception is a class that extends
Exception(checked) orRuntimeException(unchecked). - Name it with an
Exceptionsuffix so readers instantly know it is throwable. - Provide constructors that forward
messageandcausetosuperso 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.