Custom Exception Classes
Write exception classes that carry domain meaning and data, so handlers can map them to the right response without parsing messages.
class NotFoundException extends RuntimeException {
NotFoundException(String message) { super(message); }
NotFoundException(String message, Throwable cause) { super(message, cause); }
}A custom exception is an ordinary class that extends RuntimeException (unchecked) or Exception (checked). Write one when a caller needs to tell this failure apart from others, or needs data from it, not just a message. In a web service the usual set is small: not found, conflict, validation failed, forbidden.
public class OrderNotFoundException extends RuntimeException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("order " + orderId + " not found");
this.orderId = orderId;
}
public long orderId() { return orderId; }
}What a good one contains
- A clear message built from the data, passed to
super(message). - Fields for that data, so code never has to parse the message string.
- A constructor that accepts a
Throwable causewhen the exception wraps a lower-level failure. Dropping the cause loses the stack trace that explains the real problem.
Checked or unchecked?
Most back-end teams make domain exceptions unchecked. They pass through lambdas and service layers without cluttering signatures, and frameworks such as Spring roll back transactions for them by default. Choose a checked exception only when every caller genuinely must handle the outcome, such as a payment that may be declined, and you want the compiler to enforce that.
A small hierarchy, not one class per method
A shared base class lets the error handler catch all your API failures in one place. Make that base abstract sealed (Java 17) and a pattern switch over it (Java 21) is checked for completeness, so adding a new failure type forces you to decide its status code. Resist creating UserServiceException, OrderServiceException and so on: exceptions named after where they happened, instead of what happened, give handlers nothing to act on.
Practical details
Throwable implements Serializable, so IDEs warn about a missing serialVersionUID; adding private static final long serialVersionUID = 1L; silences it. Keep exceptions immutable: final fields and no setters. If a message includes user input, remember it may be logged, so leave out secrets and personal data.
Do not reuse a JDK type with a special meaning just to save a class. Throwing IllegalStateException("404") and checking the message in a handler is fragile; one rename and every not-found becomes a 500.
Example
When to use it
- Throw an OrderNotFoundException carrying the order id so the handler can build a 404 body.
- Return every field error of a signup form in one 400 response from a ValidationException.
- Give payment failures stable error codes that a mobile client can switch on.
More examples
Keeping the cause when wrapping
The new message adds business context (which invoice) while the cause keeps the technical reason. A log of the StorageException shows both, with the cause listed under Caused by.
class StorageException extends RuntimeException {
StorageException(String message, Throwable cause) {
super(message, cause);
}
}
void writeInvoice(String id) {
try {
throw new IOException("No space left on device");
} catch (IOException e) {
throw new StorageException("could not store invoice " + id, e);
}
}
void main() {
try {
writeInvoice("INV-7");
} catch (StorageException e) {
IO.println(e.getMessage());
IO.println("cause: " + e.getCause().getClass().getSimpleName() + ": " + e.getCause().getMessage());
}
}
// Output:
// could not store invoice INV-7
// cause: IOException: No space left on deviceA validation exception that carries every field error
Collecting all errors before throwing lets a form show every problem at once instead of one per round trip. List.copyOf keeps the exception immutable even if the caller's list changes later.
class ValidationException extends RuntimeException {
private final List<String> errors;
ValidationException(List<String> errors) {
super(errors.size() + " validation error(s)");
this.errors = List.copyOf(errors);
}
List<String> errors() { return errors; }
}
record SignupRequest(String email, String password, int age) {}
void validate(SignupRequest r) {
List<String> errors = new ArrayList<>();
if (r.email() == null || !r.email().contains("@")) errors.add("email: must be an email address");
if (r.password() == null || r.password().length() < 12) errors.add("password: at least 12 characters");
if (r.age() < 16) errors.add("age: must be 16 or over");
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
}
void main() {
try {
validate(new SignupRequest("ada.example.com", "short", 15));
} catch (ValidationException e) {
IO.println("400 " + e.getMessage());
e.errors().forEach(err -> IO.println(" " + err));
}
}
// Output:
// 400 3 validation error(s)
// email: must be an email address
// password: at least 12 characters
// age: must be 16 or overMachine-readable error codes for clients
Clients branch on the stable code, not on the message text, so you can reword messages without breaking a mobile app. Keeping the status on the enum puts the HTTP mapping in one place.
enum ErrorCode {
INSUFFICIENT_FUNDS(422), ACCOUNT_FROZEN(403);
final int status;
ErrorCode(int status) {
this.status = status;
}
}
class PaymentException extends RuntimeException {
final ErrorCode code;
PaymentException(ErrorCode code, String message) {
super(message);
this.code = code;
}
}
void pay(String account, long cents) {
if (account.equals("frozen-1")) {
throw new PaymentException(ErrorCode.ACCOUNT_FROZEN, "account is frozen");
}
if (cents > 5_000) {
throw new PaymentException(ErrorCode.INSUFFICIENT_FUNDS, "balance too low for " + cents + " cents");
}
IO.println("paid " + cents + " from " + account);
}
void main() {
String[][] attempts = {{"acc-1", "1200"}, {"acc-1", "9900"}, {"frozen-1", "100"}};
for (String[] a : attempts) {
try {
pay(a[0], Long.parseLong(a[1]));
} catch (PaymentException e) {
IO.println(e.code.status + " {\"code\":\"" + e.code + "\",\"message\":\"" + e.getMessage() + "\"}");
}
}
}
// Output:
// paid 1200 from acc-1
// 422 {"code":"INSUFFICIENT_FUNDS","message":"balance too low for 9900 cents"}
// 403 {"code":"ACCOUNT_FROZEN","message":"account is frozen"}
Discussion