Software Engineer's Blog

Designing Effective Custom Exceptions in Java

Designing Effective Custom Exceptions in Java

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

  • InvalidOrderStateException
  • DuplicateUserException
  • PaymentProcessingException

✘ Avoid

  • AppException
  • MyException
  • GeneralErrorException

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:
    • InsufficientBalanceException
    • OrderLimitExceededException

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:
    • FileStorageException
    • EmailSendException

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 TypeRecommended Status
Business rule violation400 Bad Request
Authentication issue401 Unauthorized
Forbidden action403 Forbidden
Resource not found404 Not Found
External system failure502 / 503
Unexpected server error500 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.