Java Custom Exceptions — Creating Domain-Specific Errors

4 minute read

Java’s built-in exceptions cover generic cases, but real applications need domain-specific errors. Custom exceptions make your code more expressive, debuggable, and maintainable.


When to Create Custom Exceptions

  • The built-in exceptions don’t describe the error well enough.
  • You need to carry extra context (error code, field name, entity ID).
  • You want a consistent exception hierarchy for your application.
  • You need to distinguish between different failure modes in the same catch block.

Basic Custom Exception

Checked (Extends Exception)

public class OrderNotFoundException extends Exception {

    public OrderNotFoundException(String message) {
        super(message);
    }

    public OrderNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

Unchecked (Extends RuntimeException)

public class InvalidInputException extends RuntimeException {

    public InvalidInputException(String message) {
        super(message);
    }

    public InvalidInputException(String message, Throwable cause) {
        super(message, cause);
    }
}

The Four Standard Constructors

Follow the pattern from Java’s own exceptions:

public class PaymentFailedException extends RuntimeException {

    public PaymentFailedException() {
        super();
    }

    public PaymentFailedException(String message) {
        super(message);
    }

    public PaymentFailedException(String message, Throwable cause) {
        super(message, cause);
    }

    public PaymentFailedException(Throwable cause) {
        super(cause);
    }
}

At minimum, provide the message and message + cause constructors.


Adding Fields — Error Codes and Context

public class ApiException extends RuntimeException {

    private final int statusCode;
    private final String errorCode;

    public ApiException(int statusCode, String errorCode, String message) {
        super(message);
        this.statusCode = statusCode;
        this.errorCode = errorCode;
    }

    public ApiException(int statusCode, String errorCode,
                        String message, Throwable cause) {
        super(message, cause);
        this.statusCode = statusCode;
        this.errorCode = errorCode;
    }

    public int getStatusCode() { return statusCode; }
    public String getErrorCode() { return errorCode; }
}

Usage:

throw new ApiException(404, "USER_NOT_FOUND",
    "User with ID 42 not found");

Catching:

catch (ApiException e) {
    response.setStatus(e.getStatusCode());
    response.setBody(Map.of(
        "error", e.getErrorCode(),
        "message", e.getMessage()
    ));
}

Exception Hierarchy for Your Application

Design a hierarchy that mirrors your domain:

AppException (abstract base)
├── ValidationException
│   ├── InvalidEmailException
│   ├── InvalidAmountException
│   └── MissingFieldException
├── NotFoundException
│   ├── UserNotFoundException
│   ├── OrderNotFoundException
│   └── ProductNotFoundException
├── AuthenticationException
│   ├── InvalidCredentialsException
│   └── TokenExpiredException
└── ExternalServiceException
    ├── PaymentGatewayException
    └── EmailServiceException
public abstract class AppException extends RuntimeException {
    private final String errorCode;

    protected AppException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    protected AppException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() { return errorCode; }
}

public class NotFoundException extends AppException {
    public NotFoundException(String entity, Object id) {
        super("NOT_FOUND", entity + " with ID " + id + " not found");
    }
}

public class UserNotFoundException extends NotFoundException {
    public UserNotFoundException(long userId) {
        super("User", userId);
    }
}
throw new UserNotFoundException(42);
// Message: "User with ID 42 not found"
// Error code: "NOT_FOUND"

Naming Conventions

Pattern Example
<Entity>NotFoundException UserNotFoundException
Invalid<Thing>Exception InvalidEmailException
<Action>FailedException PaymentFailedException
<Thing>AlreadyExistsException AccountAlreadyExistsException
<Thing>ExpiredException TokenExpiredException

Rules:

  • Always end with Exception.
  • Name should describe what went wrong, not the technical mechanism.
  • Use nouns and adjectives, not verbs: InvalidOrderException, not OrderFailToValidateException.

serialVersionUID

If your exception might be serialised (RMI, distributed systems), add a serial version UID:

public class OrderException extends Exception {
    private static final long serialVersionUID = 1L;

    public OrderException(String message) {
        super(message);
    }
}

For most modern applications (REST APIs, microservices), this is optional but considered good practice.


Checked vs Unchecked — Which to Extend?

Extend Exception (Checked) Extend RuntimeException (Unchecked)
Caller must handle it Caller may handle it
I/O, network, external systems Validation, business logic
throws required in signature Cleaner APIs
Forces awareness More flexible

Modern best practice: Default to unchecked (RuntimeException). Use checked only when the caller must handle the error (e.g., recoverable I/O operations).


Exception Messages — Writing Good Ones

// ❌ BAD — no context
throw new RuntimeException("Error");
throw new RuntimeException("Not found");

// ✅ GOOD — includes what, why, and context
throw new UserNotFoundException(
    "User with email alice@example.com not found in database 'users_prod'");

throw new InvalidInputException(
    "Order amount must be positive, got: " + amount);

throw new PaymentFailedException(
    "Payment of $%.2f for order %s failed: gateway returned HTTP %d"
        .formatted(amount, orderId, httpStatus));

A good message answers: What happened? Where? With what values?


Complete Real-World Example

// Exception classes
public class InsufficientFundsException extends RuntimeException {
    private final String accountId;
    private final double requested;
    private final double available;

    public InsufficientFundsException(String accountId,
                                       double requested, double available) {
        super("Insufficient funds in account %s: requested $%.2f, available $%.2f"
                .formatted(accountId, requested, available));
        this.accountId = accountId;
        this.requested = requested;
        this.available = available;
    }

    public String getAccountId() { return accountId; }
    public double getRequested()  { return requested; }
    public double getAvailable()  { return available; }
}

// Service
public class BankService {
    public void withdraw(String accountId, double amount) {
        Account account = findAccount(accountId);
        if (amount > account.getBalance()) {
            throw new InsufficientFundsException(
                accountId, amount, account.getBalance());
        }
        account.debit(amount);
    }
}

// Controller
try {
    bankService.withdraw("ACC-001", 5000);
} catch (InsufficientFundsException e) {
    System.out.printf("Cannot withdraw from %s: need $%.2f but only $%.2f available%n",
        e.getAccountId(), e.getRequested(), e.getAvailable());
}

Common Mistakes

Mistake Problem Fix
No message constructor No context in logs Always accept a message
No cause constructor Loses original stack trace Include Throwable cause parameter
Mutable fields Thread-safety issues Make fields final
Generic names MyException, AppError Use descriptive domain names
Too many custom exceptions Over-engineering Create only when built-ins don’t suffice

Key Takeaways

  1. Create custom exceptions when built-in ones don’t describe the error well enough.
  2. Provide at minimum message and message + cause constructors.
  3. Add fields for context (error code, entity ID, amounts).
  4. Design an exception hierarchy that mirrors your domain.
  5. Default to unchecked (RuntimeException) for modern applications.
  6. Write descriptive messages that include what, where, and relevant values.

What’s Next?

Next: Multiple catch Blocks — catching specific exceptions, multi-catch syntax, and why ordering matters.

Updated: