Introduction
When a Spring Boot REST API receives a request body that fails Bean Validation (JSR 380) constraints, the default response is a generic 400 Bad Request with limited detail. To return a custom error response POJO with structured field-level errors, create a @ControllerAdvice class that handles MethodArgumentNotValidException, extracts the validation errors, and returns your custom response object. This gives API consumers consistent, machine-readable error messages instead of Spring Boot's default format.
Setup
Add the validation dependency to your pom.xml:
1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-validation</artifactId>
4</dependency>
Define the Request DTO with Validation Annotations
1import jakarta.validation.constraints.*;
2
3public class CreateUserRequest {
4
5 @NotBlank(message = "Name is required")
6 @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
7 private String name;
8
9 @NotBlank(message = "Email is required")
10 @Email(message = "Email must be valid")
11 private String email;
12
13 @NotNull(message = "Age is required")
14 @Min(value = 18, message = "Age must be at least 18")
15 @Max(value = 150, message = "Age must be at most 150")
16 private Integer age;
17
18 // Getters and setters
19 public String getName() { return name; }
20 public void setName(String name) { this.name = name; }
21 public String getEmail() { return email; }
22 public void setEmail(String email) { this.email = email; }
23 public Integer getAge() { return age; }
24 public void setAge(Integer age) { this.age = age; }
25}
Define the Custom Error Response POJO
1import java.time.LocalDateTime;
2import java.util.List;
3
4public class ApiErrorResponse {
5
6 private int status;
7 private String message;
8 private LocalDateTime timestamp;
9 private List<FieldError> errors;
10
11 public ApiErrorResponse(int status, String message, List<FieldError> errors) {
12 this.status = status;
13 this.message = message;
14 this.timestamp = LocalDateTime.now();
15 this.errors = errors;
16 }
17
18 // Getters
19 public int getStatus() { return status; }
20 public String getMessage() { return message; }
21 public LocalDateTime getTimestamp() { return timestamp; }
22 public List<FieldError> getErrors() { return errors; }
23
24 public static class FieldError {
25 private String field;
26 private String message;
27 private Object rejectedValue;
28
29 public FieldError(String field, String message, Object rejectedValue) {
30 this.field = field;
31 this.message = message;
32 this.rejectedValue = rejectedValue;
33 }
34
35 public String getField() { return field; }
36 public String getMessage() { return message; }
37 public Object getRejectedValue() { return rejectedValue; }
38 }
39}
Create the Controller with @Valid
1import jakarta.validation.Valid;
2import org.springframework.http.ResponseEntity;
3import org.springframework.web.bind.annotation.*;
4
5@RestController
6@RequestMapping("/api/users")
7public class UserController {
8
9 @PostMapping
10 public ResponseEntity<String> createUser(@Valid @RequestBody CreateUserRequest request) {
11 // If validation passes, this method executes
12 return ResponseEntity.ok("User created: " + request.getName());
13 }
14}
The @Valid annotation triggers Bean Validation on the request body. If validation fails, Spring throws MethodArgumentNotValidException before the method body executes.
Handle Validation Errors with @ControllerAdvice
1import org.springframework.http.HttpStatus;
2import org.springframework.http.ResponseEntity;
3import org.springframework.web.bind.MethodArgumentNotValidException;
4import org.springframework.web.bind.annotation.ControllerAdvice;
5import org.springframework.web.bind.annotation.ExceptionHandler;
6
7import java.util.List;
8import java.util.stream.Collectors;
9
10@ControllerAdvice
11public class GlobalExceptionHandler {
12
13 @ExceptionHandler(MethodArgumentNotValidException.class)
14 public ResponseEntity<ApiErrorResponse> handleValidationErrors(
15 MethodArgumentNotValidException ex) {
16
17 List<ApiErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
18 .getFieldErrors()
19 .stream()
20 .map(error -> new ApiErrorResponse.FieldError(
21 error.getField(),
22 error.getDefaultMessage(),
23 error.getRejectedValue()))
24 .collect(Collectors.toList());
25
26 ApiErrorResponse response = new ApiErrorResponse(
27 HttpStatus.BAD_REQUEST.value(),
28 "Validation failed",
29 fieldErrors);
30
31 return ResponseEntity.badRequest().body(response);
32 }
33}
Example Response
Sending an invalid request:
1{
2 "name": "",
3 "email": "not-an-email",
4 "age": 10
5}
Returns:
1{
2 "status": 400,
3 "message": "Validation failed",
4 "timestamp": "2025-01-15T10:30:00",
5 "errors": [
6 {
7 "field": "name",
8 "message": "Name is required",
9 "rejectedValue": ""
10 },
11 {
12 "field": "email",
13 "message": "Email must be valid",
14 "rejectedValue": "not-an-email"
15 },
16 {
17 "field": "age",
18 "message": "Age must be at least 18",
19 "rejectedValue": 10
20 }
21 ]
22}
Handling @RequestParam and @PathVariable Validation
For @RequestParam and @PathVariable validation, Spring throws ConstraintViolationException instead:
1@RestController
2@Validated // Required for parameter validation
3@RequestMapping("/api/users")
4public class UserController {
5
6 @GetMapping("/{id}")
7 public String getUser(@PathVariable @Min(1) Long id) {
8 return "User " + id;
9 }
10}
1import jakarta.validation.ConstraintViolationException;
2
3@ControllerAdvice
4public class GlobalExceptionHandler {
5
6 // ... handleValidationErrors from above ...
7
8 @ExceptionHandler(ConstraintViolationException.class)
9 public ResponseEntity<ApiErrorResponse> handleConstraintViolation(
10 ConstraintViolationException ex) {
11
12 List<ApiErrorResponse.FieldError> errors = ex.getConstraintViolations()
13 .stream()
14 .map(v -> new ApiErrorResponse.FieldError(
15 v.getPropertyPath().toString(),
16 v.getMessage(),
17 v.getInvalidValue()))
18 .collect(Collectors.toList());
19
20 return ResponseEntity.badRequest().body(
21 new ApiErrorResponse(400, "Validation failed", errors));
22 }
23}
Custom Validators
Create a custom constraint for complex validation:
1@Target({ElementType.FIELD})
2@Retention(RetentionPolicy.RUNTIME)
3@Constraint(validatedBy = PhoneNumberValidator.class)
4public @interface ValidPhone {
5 String message() default "Invalid phone number";
6 Class<?>[] groups() default {};
7 Class<? extends Payload>[] payload() default {};
8}
9
10public class PhoneNumberValidator implements ConstraintValidator<ValidPhone, String> {
11 @Override
12 public boolean isValid(String value, ConstraintValidatorContext context) {
13 if (value == null) return true; // Use @NotNull for null checks
14 return value.matches("\\+?[0-9]{10,15}");
15 }
16}
17
18// Usage in DTO
19public class CreateUserRequest {
20 @ValidPhone
21 private String phone;
22}
Common Pitfalls
Forgetting @Valid on the @RequestBody parameter: Without @Valid, Spring does not trigger Bean Validation and accepts any input. The @Valid annotation is required to activate constraint checking.
Catching Exception broadly in @ControllerAdvice: A catch-all @ExceptionHandler(Exception.class) can swallow MethodArgumentNotValidException before your specific handler runs. Define specific handlers first and use a catch-all only as a fallback.
Not handling ConstraintViolationException for path/query params: @RequestParam and @PathVariable validation throws ConstraintViolationException, not MethodArgumentNotValidException. Handle both in your @ControllerAdvice.
Using @Validated and @Valid inconsistently: @Valid triggers cascading validation on nested objects. @Validated is a Spring annotation needed for method-level validation on controller classes. Use @Valid on @RequestBody and @Validated on the controller class for path/query validation.
Returning the rejected value in production error responses: Including rejectedValue in the response can expose sensitive data (passwords, tokens). Consider omitting it or masking sensitive fields.
Summary
Annotate request DTO fields with Bean Validation constraints (@NotBlank, @Email, @Min)
Add @Valid before @RequestBody in the controller method to trigger validation
Create a @ControllerAdvice handler for MethodArgumentNotValidException to return a custom error POJO
Extract field names, messages, and rejected values from BindingResult.getFieldErrors()
Handle ConstraintViolationException separately for @RequestParam and @PathVariable validation