Java Custom Exceptions — Creating Domain-Specific Errors
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, notOrderFailToValidateException.
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
- Create custom exceptions when built-in ones don’t describe the error well enough.
- Provide at minimum message and message + cause constructors.
- Add fields for context (error code, entity ID, amounts).
- Design an exception hierarchy that mirrors your domain.
- Default to unchecked (
RuntimeException) for modern applications. - 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.