Custom Exception Classes

Write exception classes that carry domain meaning and data, so handlers can map them to the right response without parsing messages.

Syntaxclass 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 cause when 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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 device

A 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.

Example · java
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 over

Machine-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.

Example · java
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

  • Be the first to comment on this lesson.