Designing Effective Custom Exceptions in Java
-
Jason Yang - 29 Nov, 2025
- Updated 04 Jan, 2026
- Views —
In the previous post, we explored how Java exceptions work and when to choose checked vs. unchecked exceptions.
In this follow-up, we focus on a practical topic that real-world developers deal with every day:
How to design custom exceptions that are expressive, maintainable, and easy to handle—especially in Spring Boot.
1. Naming Conventions: Express Intent Clearly
Good exception names should immediately tell you what went wrong.
✔ Good examples
InvalidOrderStateExceptionDuplicateUserExceptionPaymentProcessingException
✘ Avoid
AppExceptionMyExceptionGeneralErrorException
Clear names make stack traces readable and help other developers quickly understand failure scenarios.
2. Distinguish Business Exceptions vs. System Exceptions
All exceptions are not the same. Splitting them properly improves both design and error handling.
✔ Business Exceptions (Domain-Level)
- Used for business rule violations
- Almost always unchecked (
extends RuntimeException) - Example:
InsufficientBalanceExceptionOrderLimitExceededException
These usually map to 400 Bad Request in an API.
✔ System Exceptions (Environmental / External Failures)
- Caused by infrastructure or external system issues
- Often wrap checked exceptions inside
- Example:
FileStorageExceptionEmailSendException
These typically map to 500, 502, or 503 depending on the failure.
3. Exception Hierarchy Design
Creating a clean hierarchy brings structure and reusability.
PaymentException (base)
├─ PaymentDeclinedException
├─ PaymentTimeoutException
└─ InvalidPaymentMethodException
Benefits
- Centralized handler in
@ControllerAdvice - Cleaner error mapping logic
- Easier maintenance as the project grows
4. Mapping Exceptions to HTTP Status Codes (Spring Boot)
| Exception Type | Recommended Status |
|---|---|
| Business rule violation | 400 Bad Request |
| Authentication issue | 401 Unauthorized |
| Forbidden action | 403 Forbidden |
| Resource not found | 404 Not Found |
| External system failure | 502 / 503 |
| Unexpected server error | 500 Internal Server Error |
These mappings are usually implemented through your global exception handler:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
public ResponseEntity<ErrorResponse> handleBalanceException(InsufficientBalanceException ex) {
return ResponseEntity.badRequest().body(new ErrorResponse(ex.getMessage()));
}
}
5. Example: Defining a Custom Exception
public class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(String message) {
super(message);
}
}
Carry a machine-readable code, not just a message
A human-readable message is for logs; it’s a poor thing for a client to branch on. Give business exceptions a stable error code and the context that caused them, so your @ControllerAdvice can build a structured response and the frontend can react to the code rather than string-matching the message:
public class InsufficientBalanceException extends RuntimeException {
private final String code = "INSUFFICIENT_BALANCE";
private final long shortfall;
public InsufficientBalanceException(long shortfall) {
super("Balance short by " + shortfall);
this.shortfall = shortfall;
}
public String getCode() { return code; }
public long getShortfall() { return shortfall; }
}
With those accessors in place, your @ControllerAdvice can read ex.getCode() and ex.getShortfall() to build the structured response.
The message can change for readability without breaking any consumer; the code is the contract.
Conclusion
Designing custom exceptions is not just about throwing errors—it’s about expressing intent clearly, separating business errors from system failures, and creating a maintainable structure that integrates cleanly with your API layer. For when to reach for these unchecked custom exceptions in the first place — the wrap-at-the-boundary strategy real Spring Boot services use — see how real-world Java developers handle exceptions.