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
<!-- 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.
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;
}
| Annotation | Description |
|---|---|
@NotNull | Value must not be null |
@NotBlank | String must not be null and must contain non-whitespace characters |
@NotEmpty | Collection/String must not be null or empty |
@Size(min, max) | String length or collection size within range |
@Min / @Max | Number value lower/upper bound |
@Email | String must be a valid email format |
@Pattern | String must match regex |
@Positive / @PositiveOrZero | Number must be positive (or ≥ 0) |
@Past / @Future | Date must be in the past or future |
@Valid on Method Parameters
Add @Valid@RequestBody@ModelAttributeMethodArgumentNotValidException
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));
}
}
@PathVariable@RequestParam@Validated@Validated for Groups
Validation groups let you apply different constraints for different scenarios (e.g., different rules on create vs update).
// 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.
// 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 {};
}
// 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);
}
}
// Step 3 — use the annotation
public class RegistrationRequest {
@NotBlank
@Email
@UniqueEmail
private String email;
}
Handling MethodArgumentNotValidException
Use @ControllerAdviceMethodArgumentNotValidException
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);
}
}