Spring: Bean Validation with @Valid and @Validated

Bean Validation (Jakarta Validation) lets you declare constraints on fields and method parameters using annotations, then automatically validate them at controller or service boundaries. Spring integrates Bean Validation so that invalid input is rejected before your business logic runs.

Contents

Adding Validation Dependency

Spring Boot's spring-boot-starter-web includes Hibernate Validator (the reference Bean Validation implementation). For non-web modules, add explicitly:

XML
<!-- Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Common Constraints

Annotate request fields with constraints; the message of each failed constraint is returned to the client.

Java
import jakarta.validation.constraints.*;

public class UserRequest {

    @NotNull(message = "Name is required")
    @Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
    private String name;

    @NotBlank(message = "Email must not be blank")
    @Email(message = "Email must be a valid email address")
    private String email;

    @NotNull
    @Min(value = 18, message = "Age must be at least 18")
    @Max(value = 120, message = "Age must be at most 120")
    private Integer age;

    @Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "Phone number is invalid")
    private String phone;

    @Positive(message = "Price must be positive")
    private BigDecimal price;

    @PositiveOrZero
    private Integer quantity;

    @NotEmpty(message = "Tags must not be empty")
    private List<@NotBlank String> tags; // validate each list element

    @Valid // cascade validation to nested object
    @NotNull
    private Address address;
}
AnnotationDescription
@NotNullValue must not be null
@NotBlankString must not be null and must contain non-whitespace characters
@NotEmptyCollection/String must not be null or empty
@Size(min, max)String length or collection size within range
@Min / @MaxNumber value lower/upper bound
@EmailString must be a valid email format
@PatternString must match regex
@Positive / @PositiveOrZeroNumber must be positive (or ≥ 0)
@Past / @FutureDate must be in the past or future

@Valid on Method Parameters

Add @Valid before a @RequestBody or @ModelAttribute parameter. Spring validates the object and throws MethodArgumentNotValidException if any constraint fails.

Java
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
        // Only reached if validation passes
        User user = userService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }

    // Validate @PathVariable / @RequestParam using @Validated on the class
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable @Positive Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }
}
To validate @PathVariable and @RequestParam, annotate the controller class with @Validated (not just the parameter). Spring will then apply method-level validation via AOP.

@Validated for Groups

Validation groups let you apply different constraints for different scenarios (e.g., different rules on create vs update).

Java
// Define group interfaces
public interface OnCreate {}
public interface OnUpdate {}

// Apply groups to constraints
public class ProductRequest {

    @Null(groups = OnCreate.class, message = "ID must be null on create")
    @NotNull(groups = OnUpdate.class, message = "ID required on update")
    private Long id;

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    @Size(max = 100)
    private String name;

    @NotNull(groups = OnCreate.class)
    @Positive
    private BigDecimal price;
}

// Controller — use @Validated with the group
@RestController
@RequestMapping("/products")
@Validated
public class ProductController {

    @PostMapping
    public ResponseEntity<Product> create(
            @Validated(OnCreate.class) @RequestBody ProductRequest request) {
        return ResponseEntity.status(201).body(productService.create(request));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(
            @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
        return ResponseEntity.ok(productService.update(request));
    }
}

Custom Constraint Annotation

Create a custom annotation when built-in constraints do not cover your validation logic.

Java
// Step 1 — define the annotation
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {
    String message() default "Email address is already registered";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
Java
// Step 2 — implement the ConstraintValidator
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.stereotype.Component;

@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    private final UserRepository userRepository;

    public UniqueEmailValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null) return true; // let @NotNull handle null
        return !userRepository.existsByEmail(email);
    }
}
Java
// Step 3 — use the annotation
public class RegistrationRequest {

    @NotBlank
    @Email
    @UniqueEmail
    private String email;
}

Handling MethodArgumentNotValidException

Use @ControllerAdvice to catch MethodArgumentNotValidException and return a structured error response.

Java
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new LinkedHashMap<>();

        ex.getBindingResult().getAllErrors().forEach(error -> {
            String field = ((FieldError) error).getField();
            String message = error.getDefaultMessage();
            fieldErrors.put(field, message);
        });

        return Map.of(
            "status", 400,
            "error", "Validation Failed",
            "errors", fieldErrors,
            "timestamp", Instant.now().toString()
        );
    }

    // For @Validated on @PathVariable / @RequestParam
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getConstraintViolations().forEach(violation -> {
            String field = violation.getPropertyPath().toString();
            errors.put(field, violation.getMessage());
        });
        return Map.of("status", 400, "error", "Constraint Violation", "errors", errors);
    }
}