When developing REST APIs with Spring Boot, various exceptions can occur — validation errors, business logic errors, system errors, and more. If each Controller handles these exceptions individually, error response formats become inconsistent, making client-side error handling unnecessarily complex.
This article explains how to use @ControllerAdvice and @ExceptionHandler to return exceptions from a REST API in a unified JSON format. We’ll walk through a design pattern — with concrete code examples — for assigning appropriate HTTP status codes to validation errors, business errors, and system errors, and returning error responses that are easy for clients to consume.
The Challenge of Exception Handling in REST APIs
Without proper exception handling in a REST API, the following problems arise.
Inconsistent Error Responses Across Controllers
When each Controller implements its own exception handling, different developers end up returning error responses in different formats. One endpoint might return {"error": "message"} while another returns {"errorMessage": "message"}, creating inconsistency across the API.
Increased Complexity on the Client Side
When error response formats are not unified, clients must implement separate error handling logic for each endpoint. This significantly reduces maintainability.
Code Duplication
Similar exception handling ends up being repeated in every Controller, violating the DRY principle. Any specification change requires modifying all Controllers, increasing the risk of missed updates.
Benefits of a Unified Error Response Design
Introducing a unified error response design provides the following benefits:
- Clients can implement consistent error handling
- Improved code maintainability and elimination of duplication
- Centralized management of error response specifications
- Simpler test code
Basics of @ControllerAdvice and @ExceptionHandler
In Spring Boot, @ControllerAdvice and @ExceptionHandler allow you to implement unified exception handling across the entire application.
The Role of @ControllerAdvice
@ControllerAdvice is an annotation for defining advice (shared processing) that applies across multiple Controllers. By defining exception handling inside a class annotated with this, you can process exceptions thrown by any Controller in the application from a single place.
Difference from @RestControllerAdvice
When building REST APIs, @RestControllerAdvice is more convenient. @RestControllerAdvice combines @ControllerAdvice and @ResponseBody, so return values are automatically serialized to JSON. When using @ControllerAdvice, you need to add @ResponseBody individually to each handler method.
Exception Handling with @ExceptionHandler
@ExceptionHandler is an annotation applied to methods that handle specific exception types. By specifying an exception type, that method is automatically invoked when an exception of that type occurs.
Response Control with ResponseEntity
ResponseEntity gives you flexible control over the HTTP status code, headers, and body. By returning ResponseEntity from an exception handler method, you can construct an appropriate HTTP response 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, a 400 Bad Request status code is returned along with a uniformly formatted error response.
Designing a Unified Error Response
Error responses that are easy for clients to consume should include the following information.
Basic Fields
timestamp: Date and time the error occurredstatus: HTTP status codeerror: HTTP status description (Bad Request, Not Found, etc.)message: Detailed error messagepath: Request path where the error occurred
Extended Fields for Validation Errors
For validation errors, you need to return detailed information about which fields failed and why.
errors: A list of per-field error details
Implementing 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;
}
// Getters (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;
}
// Getters
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 auto-generates getter methods, keeping the code concise. If you use Lombok, add the dependency to your build.gradle or pom.xml.
Using this class allows every exception to return an error response with a consistent JSON structure.
Handling Validation Errors
When you use the @Valid annotation for validation in Spring Boot, a MethodArgumentNotValidException is thrown when validation fails.
When MethodArgumentNotValidException Is Thrown
Consider the following DTO and Controller:
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;
// Getters/Setters omitted
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<String> createUser(@Valid @RequestBody UserCreateRequest request) {
// User creation logic
return ResponseEntity.ok("User created successfully");
}
}
When a validation error occurs, a MethodArgumentNotValidException is thrown.
Returning Validation Error Details
MethodArgumentNotValidException contains a BindingResult, from which you can retrieve per-field error information.
@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(),
"Validation failed",
request.getRequestURI(),
fieldErrors
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
}
This returns an error response like the following:
{
"timestamp": "2025-01-15T10:30:00",
"status": 400,
"error": "Bad Request",
"message": "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 on validation, see 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. Using checked exceptions would require try-catch at every call site, making the code verbose.
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
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 + " was 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 + " was not found"));
if (user.isActive()) {
throw new BusinessException("User is already active");
}
userService.activate(user);
return ResponseEntity.ok("User activated successfully");
}
}
Implementing Handlers for Custom Exceptions
@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 the Right HTTP Status Code
ResourceNotFoundException: Return 404 Not Found (resource does not exist)BusinessException: Return 400 Bad Request (business rule violation)
Handling System Errors and Unexpected Exceptions
Unexpected exceptions and system errors also need to return appropriate error responses.
Catch-All Exception Handling
By catching the Exception class, you can handle all exceptions that were not handled by a more specific handler.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(
Exception ex, HttpServletRequest request) {
// Log the full details of system errors
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 never return detailed exception information (stack traces or internal error messages) to clients. Doing so could expose internal implementation details to attackers.
In the example above, only a generic message is returned to the client, while the details are written to server logs. If you need detailed information in development environments, consider toggling this behavior via environment variables or Spring profiles.
Leveraging ResponseEntityExceptionHandler
Spring MVC provides a base class called ResponseEntityExceptionHandler that, when extended, allows you to handle standard exceptions in a unified format.
The Role of ResponseEntityExceptionHandler
ResponseEntityExceptionHandler provides default handling for standard Spring MVC exceptions such as:
HttpRequestMethodNotSupportedException: Unsupported HTTP methodHttpMediaTypeNotSupportedException: Unsupported Content-TypeMissingServletRequestParameterException: Missing required request parameter- Many other standard Spring MVC exceptions
Extending the Base Class
@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(),
"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);
}
// Additional 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’s standard exceptions are also returned in a unified format, enabling more comprehensive error handling.
Guidelines for Choosing HTTP Status Codes
It is important to return the appropriate HTTP status code 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
Use when the problem lies in the client’s request.
404 Not Found
Use when the specified resource does not exist:
- A user ID, product ID, or other resource cannot be found
- Access to a non-existent endpoint
500 Internal Server Error
Use in the following cases:
- Database connection errors
- Unexpected runtime errors
- External API call failures
Use when a server-side problem prevents the request from completing.
Other Status Codes
The following status codes can also be used as needed:
401 Unauthorized: Authentication required403 Forbidden: Authenticated but insufficient permissions409 Conflict: Resource conflict (optimistic locking error, etc.)503 Service Unavailable: Service temporarily unavailable
Note: Detailed handling of authentication and authorization errors (401/403) is outside the scope of this article. Exception handling for authentication and authorization using Spring Security requires separate, dedicated configuration.
Complete Implementation: A Full Global Exception Handler
Here is a complete implementation of the exception handler class incorporating everything covered so far.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// Auto-generate logger with Lombok's @Slf4j
// 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(),
"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 exceptions
@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);
}
// Illegal 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 ensures that the entire application returns unified error responses.
Testing Exception Handling
Writing tests to verify that exception handling works correctly is essential.
Testing with MockMvc
@WebMvcTest(UserController.class)
class GlobalExceptionHandlerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UserService userService;
@Test
void whenValidationErrorOccurs_shouldReturn400WithErrorDetails() throws Exception {
UserCreateRequest request = new UserCreateRequest();
request.setUsername("ab"); // Under 3 characters — error
request.setEmail("invalid-email"); // Invalid email format — error
request.setAge(200); // Exceeds maximum — error
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("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 whenAccessingNonExistentResource_shouldReturn404() 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("was not found")));
}
@Test
void whenBusinessErrorOccurs_shouldReturn400WithErrorMessage() 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 whenUnsupportedHttpMethodUsed_shouldReturn405() throws Exception {
mockMvc.perform(put("/api/users"))
.andExpect(status().isMethodNotAllowed())
.andExpect(jsonPath("$.status").value(405))
.andExpect(jsonPath("$.message",
containsString("is not supported")));
}
}
Key Testing Points
- Use
@WebMvcTestto target only the Controller layer - Use
@MockBeanto mock the service layer and configure behavior for each test case - Verify that the HTTP status code is correct
- Verify that the JSON structure of the error response matches expectations
- For validation errors, verify that the
errorsarray contains the appropriate field errors - Verifying the content of specific field error messages makes for more thorough, practical tests
Implementation Notes and Best Practices
Handler Priority
When multiple @ExceptionHandler definitions exist, more specific exception types take precedence. For example, if handlers exist for both IllegalArgumentException and Exception, the former is invoked when an IllegalArgumentException occurs.
Be aware of the processing order when dealing with exceptions in an inheritance hierarchy. If you need explicit control over priority, use the @Order annotation.
Protecting Sensitive Information
Never include the following sensitive information in error responses:
- Database connection strings
- Internal file paths
- Stack traces (in production)
- SQL statements or query details
- Internal system configuration details
This information could be exploited by attackers. Detailed information should only be written to server logs; clients should receive only a generic message.
Separating Error Responses from Logging
- Error responses: Information to help clients understand the error and respond appropriately
- Log output: Detailed information to help developers investigate and resolve issues
These serve different purposes and should be designed separately. For system errors in particular, log the full stack trace while returning only a concise message to the client.
Internationalization
To support multiple languages for error messages, you can use 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 the language specified by the Accept-Language header. Spring MVC automatically resolves the Locale parameter from the request header and injects it into the method argument.
Summary
This article explained how to return unified error responses from a Spring Boot REST API.
- Using
@ControllerAdviceand@ExceptionHandlerenables unified exception handling across the entire application - For REST APIs,
@RestControllerAdviceautomatically returns responses in JSON format - Error responses should include basic fields such as
timestamp,status,error,message, andpath - Catch
MethodArgumentNotValidExceptionfor validation errors and return per-field details - Create custom business exceptions and return them with appropriate HTTP status codes (400/404, etc.)
- Return 500 for system errors and write the details to logs only
- Extending
ResponseEntityExceptionHandlerallows Spring MVC’s standard exceptions to also be handled in a unified format - Use the appropriate HTTP status code (400/404/500, etc.) based on the type of exception
- Use MockMvc and
@MockBeanto implement tests for error handling
A unified error response design simplifies client-side implementation and improves the overall maintainability of the API. Use the patterns introduced in this article as a foundation and customize them to fit your project’s requirements.
FAQ
What is the difference between @ControllerAdvice and @RestControllerAdvice?
@RestControllerAdvice is a combination of @ControllerAdvice and @ResponseBody. For REST APIs, using @RestControllerAdvice automatically serializes return values to JSON. When using @ControllerAdvice, you must add @ResponseBody to each individual handler method.
When multiple @ControllerAdvice classes exist, how is priority determined?
You can control priority with the @Order annotation. Lower numbers have higher priority — for example, @Order(1) is evaluated before @Order(2). If no explicit order is specified, the order is not guaranteed.
Can @ExceptionHandler handle multiple exception types at once?
Yes. By specifying an array like @ExceptionHandler({Exception1.class, Exception2.class}), a single method can handle multiple exception types. However, if each type requires different processing, it is better to use separate methods.
What happens if I don’t extend ResponseEntityExceptionHandler?
Spring MVC’s standard exceptions (e.g., HttpRequestMethodNotSupportedException) will use default handling and won’t be returned in your unified format. If you want to standardize error responses across all exceptions, you must either extend ResponseEntityExceptionHandler or define individual @ExceptionHandler methods for each standard exception.
Can exceptions thrown in async processing or CompletableFuture be caught by @ControllerAdvice?
In async processing, exceptions may occur on a different thread, so they cannot always be caught by @ControllerAdvice. Exceptions inside @Async methods must be handled by implementing AsyncUncaughtExceptionHandler. For CompletableFuture, use .exceptionally() or .handle() to explicitly handle exceptions.
How do I hide stack traces in production?
Use environment variables or Spring Profiles to control what information is returned in production. For example, setting server.error.include-stacktrace=never in application-prod.properties hides stack traces from the default error page. It is also recommended to implement logic in your custom error handler to switch message content based on the active environment.
How do I internationalize (localize) error messages?
Use Spring Boot’s MessageSource. Create property files such as messages.properties (default), messages_ja.properties (Japanese), and messages_en.properties (English), then retrieve locale-specific messages via MessageSource. By accepting a Locale parameter in your exception handler method and resolving the message with messageSource.getMessage(), you can support multiple languages.