Java
Bean Validation
Hibernate Validator
Custom Response
Error Handling

How to return a custom response pojo when request body fails validations that are defined using Bean Validation/Hibernate Validator?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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:

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

java
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

java
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

java
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

java
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:

json
1{
2    "name": "",
3    "email": "not-an-email",
4    "age": 10
5}

Returns:

json
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:

java
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}
java
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:

java
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

Course illustration
Course illustration

All Rights Reserved.