When developing REST APIs with Spring Boot, various exceptions occur, such as validation errors, business errors, and system errors. If you handle these exceptions individually in each Controller, the format of error responses won’t be unified, making error handling on the client side complex.

This article explains how to use @ControllerAdvice and @ExceptionHandler to return exceptions that occur in REST APIs in a unified JSON format. We’ll introduce design patterns for setting appropriate HTTP status codes for validation errors, business errors, and system errors, and returning error responses that are easy for clients to handle, along with concrete code examples.

Challenges in Exception Handling for REST APIs

Exception Type × HTTP Status × Handler Quick Reference

The main patterns covered in this article can be summarized as follows. Each section provides details.

Exception TypeRecommended HTTP StatusHandler MethodMain Purpose
MethodArgumentNotValidException400 Bad RequesthandleMethodArgumentNotValid (override)@Valid validation failure
IllegalArgumentException400 Bad Request@ExceptionHandler(IllegalArgumentException.class)Invalid argument
Custom BusinessException400 Bad Request@ExceptionHandler(BusinessException.class)Business rule violation
Custom ResourceNotFoundException404 Not Found@ExceptionHandler(ResourceNotFoundException.class)Resource not found
HttpRequestMethodNotSupportedException405 Method Not AllowedhandleHttpRequestMethodNotSupported (override)Unsupported HTTP method
HttpMediaTypeNotSupportedException415 Unsupported Media TypeResponseEntityExceptionHandler defaultUnsupported Content-Type
Other Exception500 Internal Server Error@ExceptionHandler(Exception.class)Unexpected system errors

Based on this table, the goal of this article is to implement all patterns uniformly using @RestControllerAdvice + inheritance of ResponseEntityExceptionHandler.

Without proper exception handling in REST APIs, the following problems occur.

Different Error Responses per Controller

When exception handling is implemented individually in each Controller, the format of error responses varies depending on the developer. Inconsistencies arise, such as one endpoint returning {"error": "message"} and another returning {"errorMessage": "message"}.

Increased Complexity on the Client Side

When the format of error responses is not unified, the client side needs to implement different error handling logic for each endpoint. This significantly reduces maintainability.

Code Duplication

Similar exception handling has to be implemented repeatedly in each Controller, which violates the DRY principle. When specifications change, all Controllers need to be modified, increasing the risk of missing updates.

Benefits of Unified Error Responses

Introducing a unified error response design provides the following benefits.

  • Consistent error handling on the client side
  • Improved code maintainability and elimination of duplication
  • Centralized management of error response specifications
  • Simplification of test code

Basics of @ControllerAdvice and @ExceptionHandler

In Spring Boot, you can implement unified exception handling across the entire application by using @ControllerAdvice and @ExceptionHandler.

The Role of @ControllerAdvice

@ControllerAdvice is an annotation used to define advice (common processing) that is applied across multiple Controllers. By defining exception handling within a class annotated with this, you can handle exceptions occurring in all Controllers in the application in one place.

Difference from @RestControllerAdvice

When developing REST APIs, @RestControllerAdvice is convenient. @RestControllerAdvice is an annotation that combines @ControllerAdvice and @ResponseBody, automatically serializing return values to JSON. When using @ControllerAdvice, you need to add @ResponseBody to each handler method individually.

Exception Handling with @ExceptionHandler

@ExceptionHandler is an annotation attached to methods that handle specific exception types. By specifying the type of exception, the method is automatically called when that type of exception occurs.

Response Control with ResponseEntity

By using ResponseEntity, you can flexibly control HTTP status codes, headers, and body. By returning ResponseEntity as the return value of an exception handler method, you can construct appropriate HTTP responses based on the type of exception.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

In this example, when an IllegalArgumentException occurs, an error response in a unified format is returned along with a 400 Bad Request status code.

Designing a Unified Error Response

It is recommended that error responses easy for clients to handle include the following information.

Basic Information Fields

  • timestamp: Date and time the error occurred
  • status: HTTP status code
  • error: Description of the HTTP status (Bad Request, Not Found, etc.)
  • message: Detailed error message
  • path: Request path where the error occurred

Extended Fields for Validation Errors

For validation errors, you need to return detailed information about which field caused which error.

  • errors: List of error details per field

Implementation of the Error Response Class

public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private List<FieldError> errors;

    public ErrorResponse(LocalDateTime timestamp, int status, String error, 
                        String message, String path) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    public ErrorResponse(LocalDateTime timestamp, int status, String error, 
                        String message, String path, List<FieldError> errors) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
        this.errors = errors;
    }

    // Getter implementation (required for JSON serialization)
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
    public List<FieldError> getErrors() { return errors; }

    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String message;

        public FieldError(String field, Object rejectedValue, String message) {
            this.field = field;
            this.rejectedValue = rejectedValue;
            this.message = message;
        }

        // Getter implementation
        public String getField() { return field; }
        public Object getRejectedValue() { return rejectedValue; }
        public String getMessage() { return message; }
    }
}

Note: In practice, using Lombok’s @Getter or @Data annotations allows automatic generation of getter methods for more concise code. When using Lombok, add the dependency to build.gradle or pom.xml.

By using this class, you can return error responses with unified JSON structure for all exceptions.

Handling Validation Errors

When you perform validation using the @Valid annotation in Spring Boot, a MethodArgumentNotValidException is thrown when validation fails.

Occurrence of MethodArgumentNotValidException

For example, consider a DTO and Controller as follows.

public class UserCreateRequest {
    @NotBlank(message = "Username is required")
    @Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters")
    private String username;

    @NotBlank(message = "Email address is required")
    @Email(message = "Email address format is invalid")
    private String email;

    @NotNull(message = "Age is required")
    @Min(value = 0, message = "Age must be 0 or greater")
    @Max(value = 150, message = "Age must be 150 or less")
    private Integer age;

    // Getter/Setter omitted
}

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody UserCreateRequest request) {
        // User creation process
        return ResponseEntity.ok("User created successfully");
    }
}

When validation fails, a MethodArgumentNotValidException is thrown.

Returning Validation Error Details

MethodArgumentNotValidException includes a BindingResult, from which you can retrieve error information per field.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            "Input validation failed",
            request.getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

This returns an error response such as the following.

{
  "timestamp": "2025-01-15T10:30:00",
  "status": 400,
  "error": "Bad Request",
  "message": "Input validation failed",
  "path": "/api/users",
  "errors": [
    {
      "field": "username",
      "rejectedValue": "ab",
      "message": "Username must be between 3 and 20 characters"
    },
    {
      "field": "email",
      "rejectedValue": "invalid-email",
      "message": "Email address format is invalid"
    }
  ]
}

For more details about validation, refer to How to Implement Validation with the @Valid Annotation in Spring Boot and How to Implement Grouping and Method-Level Validation with the @Validated Annotation in Spring Boot.

Handling Custom Business Exceptions

It is recommended to create custom exception classes to represent application-specific business errors.

Designing Custom Exception Classes

Business exceptions are created by extending RuntimeException. This is because making them checked exceptions would require try-catch blocks at every call site, making the code cumbersome.

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

Example of Using Custom Exceptions

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(
                "User with ID: " + id + " not found"));
        return ResponseEntity.ok(user);
    }

    @PostMapping("/{id}/activate")
    public ResponseEntity<String> activateUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(
                "User with ID: " + id + " not found"));
        
        if (user.isActive()) {
            throw new BusinessException("User is already active");
        }
        
        userService.activate(user);
        return ResponseEntity.ok("User activated successfully");
    }
}

Implementation of Custom Exception Handling

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

Choosing HTTP Status Codes

  • ResourceNotFoundException: Returns 404 Not Found (resource does not exist)
  • BusinessException: Returns 400 Bad Request (business rule violation)

Handling System Errors and Unexpected Exceptions

Appropriate error responses must also be returned for unexpected exceptions and system errors.

Comprehensive Exception Handling

By catching the Exception class, you can handle all exceptions that are not individually handled.

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        
        // Output details of system errors to the log
        logger.error("An unexpected error occurred: {}", ex.getMessage(), ex);
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "An internal server error occurred",
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

Security Considerations

In production environments, you should not return detailed exception information (stack traces or internal error messages) to clients. This is because attackers could gain information about internal implementation details.

In the example above, only a generic message is returned to the client, while details are output to the server log. If you want to return detailed information in development environments, consider implementing switching based on environment variables or profiles.

Utilizing ResponseEntityExceptionHandler

Spring MVC provides a base class called ResponseEntityExceptionHandler, which allows you to handle standard exceptions in a unified format by extending it.

The Role of ResponseEntityExceptionHandler

ResponseEntityExceptionHandler provides default handling for the following standard exceptions provided by Spring MVC.

  • HttpRequestMethodNotSupportedException: Unsupported HTTP method
  • HttpMediaTypeNotSupportedException: Unsupported Content-Type
  • MissingServletRequestParameterException: Missing required request parameter
  • Many other Spring MVC standard exceptions

Extension by Inheritance

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "Input validation failed",
            servletWebRequest.getRequest().getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "HTTP method " + ex.getMethod() + " is not supported",
            servletWebRequest.getRequest().getRequestURI()
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // Other custom exception handlers
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

By extending ResponseEntityExceptionHandler, Spring MVC standard exceptions can also be returned in a unified format, enabling more comprehensive error handling.

Guidelines for Choosing HTTP Status Codes

Leveraging Spring Boot 3.x Standard ProblemDetail (RFC 7807)

From Spring Framework 6 / Spring Boot 3.0 onwards, the ProblemDetail class is provided as a standard format for error responses, conforming to RFC 7807 Problem Details for HTTP APIs. Instead of defining your own ErrorResponse class, you can concisely implement industry-standard error response formats using ProblemDetail.

Basic Structure of ProblemDetail

ProblemDetail has the following fields.

  • type: URI identifying the error (e.g., https://example.com/probs/out-of-credit)
  • title: Short human-readable summary of the error
  • status: HTTP status code
  • detail: Detailed error message
  • instance: URI of the instance where the error occurred (such as the request path)
  • Optional extension properties (via the properties map)

Returning ProblemDetail with @ExceptionHandler

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setType(URI.create("https://springboot-123.example.com/errors/resource-not-found"));
        problem.setTitle("Resource Not Found");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

The response is returned with Content-Type: application/problem+json, with a structure such as the following.

{
  "type": "https://springboot-123.example.com/errors/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "User with ID: 1 not found",
  "instance": "/api/users/1",
  "timestamp": "2025-06-01T10:30:00Z"
}

Integration with ResponseEntityExceptionHandler

Spring Boot 3.x’s ResponseEntityExceptionHandler has been revamped to automatically return Spring MVC standard exceptions in ProblemDetail format. Simply setting the following in application.properties will cause built-in exceptions such as MethodArgumentNotValidException to be returned in RFC 7807-compliant format.

spring.mvc.problemdetails.enabled=true

Choosing Between Custom ErrorResponse and ProblemDetail

  • New projects / Spring Boot 3.x: Consider ProblemDetail as the first choice. As a standard format, it offers high interoperability with client-side libraries (e.g., problem-spring-web).
  • Existing projects / Client compatibility required: Maintain the ErrorResponse pattern from this article. Preserves existing schemas such as timestamp and the errors array without breaking them.
  • Mixed operation: You can also create a custom class extending ProblemDetail and add extension fields such as errors to the properties map.

Whichever you choose, the @RestControllerAdvice + @ExceptionHandler structure explained in this article can be reused as-is.

It is important to return appropriate HTTP status codes based on the type of exception.

400 Bad Request

Use in the following cases.

  • Validation errors (missing required fields, format errors, etc.)
  • Business rule violations (already processed, insufficient permissions, etc.)
  • Invalid request parameters

Used when there is a problem with the client’s request.

404 Not Found

Used when the specified resource does not exist.

  • Resources such as user IDs or product IDs are not found
  • Accessing endpoints that do not exist

500 Internal Server Error

Use in the following cases.

  • Database connection errors
  • Unexpected runtime errors
  • Failed external API calls

Used when processing cannot be completed due to issues on the server side.

Other Status Codes

The following status codes can also be used as needed.

  • 401 Unauthorized: Authentication required
  • 403 Forbidden: Authenticated but insufficient permissions
  • 409 Conflict: Resource conflict (optimistic locking errors, etc.)
  • 503 Service Unavailable: Service temporarily unavailable

Note: Detailed handling of authentication/authorization errors (401/403) is outside the scope of this article. Exception handling for authentication/authorization mechanisms using Spring Security requires separate dedicated configuration.

Implementation Example: A Complete Global Exception Handler

Here is an implementation example of a complete exception handler class that integrates the content covered so far.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // Using Lombok's @Slf4j to auto-generate the logger
    // Without Lombok: private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // Validation errors
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "Input validation failed",
            servletWebRequest.getRequest().getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // Invalid HTTP method
    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "HTTP method " + ex.getMethod() + " is not supported",
            servletWebRequest.getRequest().getRequestURI()
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // Resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    // Business exception
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // Invalid argument
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // All other exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        
        log.error("An unexpected error occurred: {}", ex.getMessage(), ex);
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "An internal server error occurred",
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

This implementation allows you to return unified error responses across the entire application.

How to Test Exception Handling

It is important to write test code to verify that exception handling works correctly.

Testing with MockMvc

@WebMvcTest(UserController.class)
class GlobalExceptionHandlerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private UserService userService;

    @Test
    void when_validation_error_occurs_returns_400_and_error_details() throws Exception {
        UserCreateRequest request = new UserCreateRequest();
        request.setUsername("ab"); // Error: less than 3 characters
        request.setEmail("invalid-email"); // Error: invalid email format
        request.setAge(200); // Error: exceeds upper limit

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.status").value(400))
            .andExpect(jsonPath("$.message").value("Input validation failed"))
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors[*].field", 
                containsInAnyOrder("username", "email", "age")))
            .andExpect(jsonPath("$.errors[?(@.field=='username')].message")
                .value("Username must be between 3 and 20 characters"));
    }

    @Test
    void when_accessing_nonexistent_resource_returns_404() throws Exception {
        when(userService.findById(99999L))
            .thenReturn(Optional.empty());

        mockMvc.perform(get("/api/users/99999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.message", 
                containsString("User") /* "not found" */));
    }

    @Test
    void when_business_error_occurs_returns_400_and_error_message() throws Exception {
        User activeUser = new User();
        activeUser.setId(1L);
        activeUser.setActive(true);
        when(userService.findById(1L))
            .thenReturn(Optional.of(activeUser));

        mockMvc.perform(post("/api/users/1/activate"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.status").value(400))
            .andExpect(jsonPath("$.message", 
                containsString("already active")));
    }

    @Test
    void when_unsupported_http_method_returns_405() throws Exception {
        mockMvc.perform(put("/api/users"))
            .andExpect(status().isMethodNotAllowed())
            .andExpect(jsonPath("$.status").value(405))
            .andExpect(jsonPath("$.message", 
                containsString("not supported")));
    }
}

Testing Points

  • Use @WebMvcTest to make only the Controller layer the test target
  • Mock the service layer with @MockBean and set up behavior according to test cases
  • Verify that HTTP status codes are correct
  • Verify that the JSON structure of error responses is as expected
  • For validation errors, verify that the errors array contains appropriate field errors
  • Verifying the error message content for specific fields in detail makes for more practical tests

Implementation Considerations and Best Practices

Priority of Exception Handlers

When multiple @ExceptionHandler are defined, more specific exception types take priority. For example, if there are handlers for both IllegalArgumentException and Exception, the former takes priority when an IllegalArgumentException occurs.

However, you need to be careful about the processing order of exceptions in inheritance relationships. If you want to explicitly control priority, you can use the @Order annotation.

Protecting Sensitive Information

Do not include sensitive information such as the following in error responses.

  • Database connection strings
  • Internal file paths
  • Stack traces (in production)
  • SQL statements or query details
  • Internal system configuration information

This information could be exploited by attackers. Detailed information should only be output to server logs, and generic messages should be returned to clients.

Use of Logging

  • Error response: Information for clients to understand the error and respond appropriately
  • Log output: Detailed information for developers to investigate and resolve issues

These two have different purposes and should be designed separately. Especially for system errors, output detailed stack traces to the log while returning only concise messages to the client.

Internationalization Support

If you want to support multilingual error messages, you can leverage Spring Boot’s MessageSource.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @Autowired
    private MessageSource messageSource;

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, 
            HttpServletRequest request,
            Locale locale) {
        
        String message = messageSource.getMessage(
            "error.resource.notfound", 
            new Object[]{ex.getMessage()}, 
            locale
        );
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            message,
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

This allows you to return error messages in a language based on the Accept-Language header. Spring MVC automatically resolves the Locale parameter from the request header and passes it as an argument.

Conclusion

This article explained how to return unified error responses in Spring Boot REST APIs.

  • By using @ControllerAdvice and @ExceptionHandler, unified exception handling is possible across the entire application
  • For REST APIs, using @RestControllerAdvice automatically returns responses in JSON format
  • Error responses should include basic information such as timestamp, status, error, message, and path
  • Catch MethodArgumentNotValidException for validation errors and return details per field
  • Create custom business exceptions and return them with appropriate HTTP status codes (400/404, etc.)
  • Return 500 for system errors and output details only to the log
  • By extending ResponseEntityExceptionHandler, Spring MVC standard exceptions can also be handled in a unified format
  • Use appropriate HTTP status codes (400/404/500, etc.) according to the type of exception
  • Implement tests for error handling using MockMvc and @MockBean

A unified error response design simplifies client-side implementation and improves the overall maintainability of the API. Customize based on the patterns introduced in this article to suit the requirements of your project.

Frequently Asked Questions

What is the difference between @ControllerAdvice and @RestControllerAdvice?

@RestControllerAdvice is an annotation that combines @ControllerAdvice and @ResponseBody. For REST APIs, using @RestControllerAdvice automatically serializes return values to JSON. When using @ControllerAdvice, you need to add @ResponseBody to each handler method.

How is priority determined when there are multiple @ControllerAdvice classes?

You can control priority with the @Order annotation. Smaller numbers indicate higher priority. For example, @Order(1) is evaluated before @Order(2). Without explicitly specifying the order, the order is not guaranteed.

Can @ExceptionHandler handle multiple exception types simultaneously?

Yes, it can. You can handle multiple exception types in a single method by specifying them as an array, like @ExceptionHandler({Exception1.class, Exception2.class}). However, if different processing is required for each, methods should be separated.

What happens if I don’t extend ResponseEntityExceptionHandler?

Spring MVC standard exceptions (e.g., HttpRequestMethodNotSupportedException) will have default handling applied and won’t be returned in a unified format. If you want to unify with your own error response format, you need to either extend ResponseEntityExceptionHandler or define @ExceptionHandler individually for each standard exception.

Can @ControllerAdvice also catch exceptions occurring in asynchronous processing or CompletableFuture?

For asynchronous processing, exceptions occur on a different thread, so they may not be caught by @ControllerAdvice. Exceptions within @Async methods need to be handled by implementing AsyncUncaughtExceptionHandler. For CompletableFuture, exception handling should be done explicitly with .exceptionally() or .handle().

How can I hide stack traces in production environments?

Use environment variables or Spring Profiles to control so that detailed information is not returned in production environments. For example, you can hide stack traces on the default error page by setting server.error.include-stacktrace=never in application-prod.properties. For custom error handlers as well, it is recommended to implement switching of message content based on the environment.

How can I internationalize (support multiple languages) error messages?

Use Spring Boot’s MessageSource. Create property files such as messages.properties (default), messages_ja.properties (Japanese), and messages_en.properties (English), and retrieve messages based on the locale via MessageSource. By receiving a Locale parameter in the exception handler method and resolving the message with messageSource.getMessage(), multilingual support is possible.