Tag: exception hierarchy

  • ✅ Recommended Strategy

    ✅ Recommended Strategy

    Use a Hierarchy of Custom Exceptions with Specific Messages

    1. Define a base exception class

    Create a generic custom exception that extends RuntimeException (or Exception if you need checked exceptions).

    public class ApplicationException extends RuntimeException {
        private final String errorCode;
    
        public ApplicationException(String message, String errorCode) {
            super(message);
            this.errorCode = errorCode;
        }
    
        public String getErrorCode() {
            return errorCode;
        }
    }
    

    2. Create a few meaningful subclasses for common domains

    Use subclasses only for distinct categories, not every input field.

    public class ValidationException extends ApplicationException {
        public ValidationException(String message) {
            super(message, "VALIDATION_ERROR");
        }
    }
    
    public class ResourceNotFoundException extends ApplicationException {
        public ResourceNotFoundException(String message) {
            super(message, "RESOURCE_NOT_FOUND");
        }
    }
    

    3. Throw exceptions with detailed messages

    Use informative messages, but reuse exception classes.

    if (email == null || !email.contains("@")) {
        throw new ValidationException("Invalid email address: " + email);
    }
    
    if (user == null) {
        throw new ResourceNotFoundException("User with ID " + userId + " not found.");
    }
    

    4. Centralize exception handling

    Use @ControllerAdvice in Spring Boot to map exceptions to HTTP responses.

    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(ValidationException.class)
        public ResponseEntity<ApiError> handleValidationException(ValidationException ex) {
            return ResponseEntity.badRequest().body(new ApiError(ex.getErrorCode(), ex.getMessage()));
        }
    
        @ExceptionHandler(ResourceNotFoundException.class)
        public ResponseEntity<ApiError> handleNotFoundException(ResourceNotFoundException ex) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                                 .body(new ApiError(ex.getErrorCode(), ex.getMessage()));
        }
    }
    

    🔄 Trade-offs

    StrategyProsCons
    One Exception per InputMax granularityToo many classes, high maintenance
    Generic Exception OnlySimpleHard to trace errors, poor UX/API feedback
    Domain-Based Custom Exceptions (✅)Balanced, readable, scalableSlightly more boilerplate

    📌 Summary

    • Don’t create one exception class per field or input.
    • Don’t rely solely on generic exceptions with vague messages.
    • Do create a few well-named custom exceptions per domain concern (validation, business rule, resource not found).
    • Do include specific messages and codes for clarity and front-end/API consumption.

    If you’re building APIs, error codes + structured JSON responses improve DX (developer experience) and UX.

    Let me know if you want a boilerplate for API exception handling in Spring Boot.


    Other considerations

    There are several important considerations when designing and handling exceptions in Java applications, especially for APIs or enterprise-level systems. Here’s a breakdown of strategic, practical, and technical points beyond the basic structure:


    🔍 1. Checked vs Unchecked Exceptions

    • Checked Exceptions (extends Exception): Force callers to handle or declare them.
      • Good for recoverable errors (e.g. file not found, invalid config).
      • Can pollute method signatures and reduce readability.
    • Unchecked Exceptions (extends RuntimeException): Don’t require handling.
      • Better for programming errors, validation failures, or business rules.
      • Preferred in modern Java and frameworks like Spring.

    👉 Recommendation: Use unchecked exceptions for business logic and REST APIs unless you’re dealing with truly recoverable scenarios (e.g., IO or external service failures).


    🧩 2. Message Structure: Human + Machine Friendly

    • Provide:
      • A clear human-readable message
      • An error code (short, unique identifier, e.g. USER_NOT_FOUND)
      • Optionally: a field name, if applicable
    {
      "error": {
        "code": "EMAIL_INVALID",
        "message": "The email format is invalid",
        "field": "email"
      }
    }
    

    This format helps:

    • Frontend display and localization
    • Developers debugging issues
    • Automated systems parsing errors

    🗂 3. Use of ErrorCode Enum or Registry

    Instead of string literals scattered throughout your code, centralize error codes:

    public enum ErrorCode {
        INVALID_EMAIL("EMAIL_INVALID"),
        USER_NOT_FOUND("USER_NOT_FOUND");
    
        private final String code;
    
        ErrorCode(String code) {
            this.code = code;
        }
    
        public String getCode() {
            return code;
        }
    }
    

    This:

    • Prevents duplication
    • Enables translation
    • Supports analytics/logs

    📦 4. Payload-rich Exceptions

    Sometimes, it’s useful for exceptions to carry more context, like field names, rejected values, etc.

    public class FieldValidationException extends ValidationException {
        private final String field;
        private final Object rejectedValue;
    
        public FieldValidationException(String field, Object rejectedValue, String message) {
            super(message);
            this.field = field;
            this.rejectedValue = rejectedValue;
        }
    
        // getters
    }
    

    You can serialize this in your @ControllerAdvice.


    🧪 5. Testability and Observability

    • Design exceptions so that unit and integration tests can assert them cleanly.
    • Log exceptions with structured messages or MDC context (e.g. requestId).
    • Use tools like:
      • Sentry, Datadog, or Elastic Stack for centralized logging
      • AOP or HandlerInterceptor to inject context (user ID, trace ID)

    💡 6. Avoid Swallowing Exceptions

    Don’t silently ignore exceptions unless you’re 100% sure it’s safe:

    try {
        doSomething();
    } catch (Exception e) {
        // ❌ Don't just do nothing or log without action
        logger.error("Something went wrong", e); // ✅
        throw new ApplicationException("Internal processing error", "INTERNAL_ERROR");
    }
    

    🔒 7. Security and Internal Data

    Be cautious not to expose internal details like stack traces, SQL errors, or implementation-specific info in API responses.

    ✅ Do this:

    {
      "code": "INTERNAL_ERROR",
      "message": "Something went wrong. Please contact support."
    }
    

    ❌ Not this:

    {
      "message": "java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'foo@example.com'..."
    }
    

    🧘 Final Thoughts

    Designing a good exception strategy is about clarity, maintainability, and robustness. A few key principles:

    • Be consistent in your error structure.
    • Surface enough context for clients to recover or correct input.
    • Don’t overdo exception classes, but don’t go fully generic either.
    • Integrate with observability tools to monitor exception patterns.