Java Custom Exceptions
Define your own exception classes in Java by extending Exception or RuntimeException for domain-specific errors.
The built-in exceptions cover most general failures, but they don't know anything about your domain. When something fails in a way that's specific to your code — "user not found", "invalid coupon", "config out of sync" — the right move is usually to define your own exception type. Custom exceptions cost little to write, make stack traces self-explanatory, and let callers handle precisely the failure they care about.
The minimum shape
A custom exception is a class that extends Exception (or one of its subclasses). The shortest useful version:
public class UserNotFoundException extends Exception {
public UserNotFoundException(String message) {
super(message);
}
}That's a complete, checked, custom exception. You can throw new UserNotFoundException("id=42") from anywhere, and callers can catch (UserNotFoundException e).
Checked or unchecked?
The single most important decision when defining an exception class: what do you extend?
extends Exception→ checked. The compiler forces callers to handle or declare it.extends RuntimeException→ unchecked. Callers may handle it but don't have to.
The same logic from checked vs. unchecked exceptions applies: extend Exception when callers can realistically recover and you want to force them to think about it; extend RuntimeException when the failure represents a bug or a condition no caller can sensibly recover from.
For domain exceptions in modern Java code, RuntimeException is the more common choice — partly because checked exceptions don't compose well with streams and lambdas, and partly because most domain failures bubble up to a single top-level handler anyway. Start with RuntimeException unless you have a specific reason to force handling.
The four constructors
By convention, an exception class provides the same four constructors as the built-ins:
public class ConfigLoadException extends RuntimeException {
public ConfigLoadException() {
super();
}
public ConfigLoadException(String message) {
super(message);
}
public ConfigLoadException(String message, Throwable cause) {
super(message, cause);
}
public ConfigLoadException(Throwable cause) {
super(cause);
}
}Why all four:
- No-arg — for tools and frameworks that reflect on the class.
- Message-only — the common case in your own code.
- Message + cause — for wrapping a lower-level exception. The most important one to include.
- Cause-only — for when the cause's message is already descriptive.
You don't need to type all four every time — IDEs generate them with one keystroke — but skipping the cause-bearing forms is a real loss. Without them you can't preserve the underlying exception when you wrap.
Carrying useful state
Strings are fine, but custom fields are better. If the caller might want to know which user wasn't found, expose it:
public class UserNotFoundException extends RuntimeException {
private final String userId;
public UserNotFoundException(String userId) {
super("user not found: " + userId);
this.userId = userId;
}
public String getUserId() { return userId; }
}Now a catch block can do something with the failure rather than just parsing the message:
catch (UserNotFoundException e) {
metrics.recordMissingUser(e.getUserId());
return Response.notFound();
}Keep the fields immutable (final) and the constructor minimal. Exceptions are constructed on the failure path — they should be fast and never throw themselves.
Wrapping with a cause
The single most useful technique with custom exceptions is translating a low-level exception into a domain one, preserving the original:
public Config load(Path p) {
try {
return parser.parse(Files.readString(p));
} catch (IOException e) {
throw new ConfigLoadException("could not read " + p, e);
} catch (ParseException e) {
throw new ConfigLoadException("invalid config in " + p, e);
}
}The caller sees a single exception type that matches their layer's vocabulary. The original failure isn't lost — it's hanging off getCause() and shows up in printStackTrace() under a Caused by: line.
This is how you keep layers separate. The Config API doesn't leak IOException or ParseException; both translate into something that means "loading the config failed."
A small hierarchy
When you have a family of related failures, give them a shared parent:
public class PaymentException extends RuntimeException {
public PaymentException(String message) { super(message); }
public PaymentException(String message, Throwable c) { super(message, c); }
}
public class CardDeclinedException extends PaymentException {
public CardDeclinedException(String message) { super(message); }
}
public class InsufficientFundsException extends PaymentException {
public InsufficientFundsException(String message) { super(message); }
}
public class FraudCheckFailedException extends PaymentException {
public FraudCheckFailedException(String message) { super(message); }
}Callers can be specific (catch (CardDeclinedException)) or broad (catch (PaymentException)) as needed. A shared parent also gives you a single import to bring into a throws clause when the method might throw any of them.
What to avoid
- Don't extend
ThrowableorErrordirectly. Always go throughExceptionorRuntimeException. - Don't override
getMessage()to compute strings on every call. Build the message in the constructor and let the parent class store it. - Don't put logic in the exception. It exists to carry information. Recovery belongs in the catch.
- Don't proliferate. Every new exception type is a small contract callers may now want to handle. If two failures truly want identical handling, they probably want to be the same type.
A worked example
A tiny order-processing module with its own exception family. The base class wraps lower-level failures; the subclasses carry domain detail; the driver catches them at different levels of specificity to show how the hierarchy lets you choose.
The driver catches EmptyOrderException first (the specific case it wants to handle differently), then OrderException as a catch-all for the family. When validation fails, the cause chain links back to the original IllegalStateException, so you don't lose information when you translate up to the domain type.
What's next
You now have all the mechanics. The closing chapter is the judgment side — when to throw, when to catch, what to log, and the patterns that mark mature exception code from defensive noise. Continue to Java exception handling best practices.