Custom Exceptions

Building a clean exception hierarchy for enterprise applications — naming conventions, when to extend checked vs unchecked, and how Spring Boot maps them to HTTP responses.

Last reviewed: January 2025

·Java 8Java 11Java 17Java 21
✅
Quick Understanding: Custom exceptions let you express domain-specific error conditions clearly. Instead of throwing RuntimeException("User not found"), you throw UserNotFoundException("User 42 not found"). This makes error handling code readable, enables specific catch blocks, and in Spring Boot, maps cleanly to HTTP status codes.

🔍 What Is It?

Custom Exception Design:

RuntimeException (base — unchecked)
└── AppException (your base)
    ├── NotFoundException (404)
    │   ├── UserNotFoundException
    │   ├── OrderNotFoundException
    │   └── ProductNotFoundException
    ├── ValidationException (400)
    │   ├── InvalidEmailException
    │   └── InsufficientFundsException
    ├── ConflictException (409)
    │   └── DuplicateEmailException
    └── ServiceException (500)
        └── ExternalServiceException

Exception Fields:

  • →message — human-readable description
  • →cause — original exception (for chaining)
  • →errorCode — machine-readable code (for API responses)
  • →context — additional data (userId, orderId, etc.)

🎯 Why Do We Need It?

Custom exceptions provide:

  • →Clarity — UserNotFoundException is self-documenting; RuntimeException is not
  • →Specific handling — catch UserNotFoundException separately from InsufficientFundsException
  • →HTTP mapping — Spring @ControllerAdvice maps each exception type to a status code
  • →Error codes — API clients can programmatically handle specific error types
  • →Domain language — exceptions speak the language of your business domain

🏗️ What Happens in Real Projects?

Every production Spring Boot service has a custom exception hierarchy. The pattern:

  1. Define base AppException (unchecked)
  2. Define category exceptions (NotFoundException, ValidationException, etc.)
  3. Define specific exceptions (UserNotFoundException, etc.)
  4. Map to HTTP in @ControllerAdvice
  5. Throw specific exceptions in service layer
  6. Never let raw RuntimeException reach the controller

⚙️ How It Works

Minimal Custom Exception:

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long userId) {
        super("User not found with ID: " + userId);
    }
    public UserNotFoundException(String email) {
        super("User not found with email: " + email);
    }
}

Rich Exception with Context:

Include an error code and additional context for API responses and logging.

💻 Example

Enterprise Exception Hierarchy — A complete, production-ready exception design for a Spring Boot application.

java
// Base exception — all app exceptions extend this
public abstract class AppException extends RuntimeException {
    private final String errorCode;
    
    protected AppException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
    }
    
    protected AppException(String message, String errorCode, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }
    
    public String getErrorCode() { return errorCode; }
}

// Category exceptions — map to HTTP status codes
public class NotFoundException extends AppException {
    protected NotFoundException(String message, String errorCode) {
        super(message, errorCode);
    }
}

public class ValidationException extends AppException {
    private final Map<String, String> fieldErrors;
    
    public ValidationException(String message, Map<String, String> fieldErrors) {
        super(message, "VALIDATION_ERROR");
        this.fieldErrors = Map.copyOf(fieldErrors);
    }
    
    public Map<String, String> getFieldErrors() { return fieldErrors; }
}

public class ConflictException extends AppException {
    protected ConflictException(String message, String errorCode) {
        super(message, errorCode);
    }
}

// Domain-specific exceptions
public class UserNotFoundException extends NotFoundException {
    public UserNotFoundException(Long id) {
        super("User not found with ID: " + id, "USER_NOT_FOUND");
    }
    public UserNotFoundException(String email) {
        super("User not found with email: " + email, "USER_NOT_FOUND");
    }
}

public class InsufficientFundsException extends ValidationException {
    public InsufficientFundsException(BigDecimal available, BigDecimal requested) {
        super(String.format("Insufficient funds. Available: %s, Requested: %s",
                available, requested),
            Map.of("balance", "Insufficient funds for this transaction"));
    }
}

public class DuplicateEmailException extends ConflictException {
    public DuplicateEmailException(String email) {
        super("Email already registered: " + email, "DUPLICATE_EMAIL");
    }
}

🤔 Common Confusions

❓ Should custom exceptions extend Exception (checked) or RuntimeException (unchecked)?

In modern enterprise Java, extend RuntimeException (unchecked). This avoids forcing every caller to handle or declare the exception. The exception hierarchy handles all specific cases in @ControllerAdvice. Only use checked custom exceptions if you have a library API where callers genuinely need compiler-enforced handling.

❓ How many constructors should a custom exception have?

At minimum: (String message) and (String message, Throwable cause). The cause constructor is critical for exception chaining. Many teams also add convenience constructors for common cases, like UserNotFoundException(Long id) which formats the message automatically.

🚨 Common Production Issues

Too many exception types — having 50 specific exception classes becomes hard to maintain. Group by HTTP status category (NotFoundException, ValidationException) and use error codes for specifics.

Forgetting the cause constructor — custom exceptions without a (String, Throwable) constructor lose the original stack trace when wrapping.

✅ Best Practices

Build a hierarchy: AppException -> NotFoundException, ValidationException, ConflictException -> specific exceptions.

Always include constructors with Throwable cause for exception chaining.

Use error codes (Strings like "USER_NOT_FOUND") for machine-readable error identification.

Map exceptions to HTTP status codes in @ControllerAdvice, not in service layer.

Keep exception messages clear and actionable — include the relevant ID or value.

🏛️ Architect's Note

Senior Architect's Perspective

Design your exception hierarchy before writing services. The hierarchy drives your API error contract. Document each exception type, its HTTP status, and its error code in your API documentation. Client applications depend on these codes to handle errors programmatically. Changing error codes is a breaking API change.

💬 Frequently Asked Questions

What is the difference between @ExceptionHandler and @ControllerAdvice?

@ExceptionHandler is a method-level annotation that handles exceptions thrown by the same controller. @ControllerAdvice (or @RestControllerAdvice) is a class-level annotation that applies @ExceptionHandler methods globally across all controllers. Use @RestControllerAdvice for REST APIs — it combines @ControllerAdvice and @ResponseBody.

🎯 Key Takeaways

1Extend RuntimeException for application exceptions — avoid checked custom exceptions
2Build a hierarchy: base AppException -> category exceptions -> specific exceptions
3Always include a (String message, Throwable cause) constructor
4Use error codes for machine-readable error identification in API responses
5Map exceptions to HTTP status codes in @ControllerAdvice — keep services clean
6Include relevant context in exception messages (IDs, values)

🎯 Practice this topic in interviews

↗ Where this topic fits

Sources

Sources / References

Use these Core Java references to verify the concept in official docs and continue into the most relevant SplashRide pages.

Related SplashRide pages