Validation in Spring Boot
Validate request data in Spring Boot using Bean Validation annotations, custom validators, and global error handling for clean API error responses.
Validation prevents bad data from reaching your business logic. Without it, you end up with null checks and error handling scattered throughout your service layer. Spring Boot integrates Bean Validation (JSR-380) via Hibernate Validator — add the starter, annotate your request DTOs, add @Valid in your controllers, and Spring handles the rest.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Built-in Constraints
Annotate request DTO fields with the constraints they must satisfy. The message attribute customises the error text returned to the client — make it descriptive enough that the client knows exactly how to fix the value.
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
String password,
@Min(value = 0, message = "Age cannot be negative")
@Max(value = 150, message = "Age cannot exceed 150")
Integer age,
@Past(message = "Birth date must be in the past")
LocalDate birthDate,
@NotNull(message = "Role is required")
UserRole role,
@NotEmpty(message = "At least one tag is required")
List<@NotBlank String> tags // validates each element in the list individually
) {}
Common Annotations
| Annotation | Validates |
|---|---|
@NotNull | Not null |
@NotBlank | Not null, not empty, not whitespace-only |
@NotEmpty | Not null, not empty (String/Collection) |
@Size(min, max) | String/Collection length range |
@Min(value) | Number ≥ value |
@Max(value) | Number ≤ value |
@Positive | Number > 0 |
@PositiveOrZero | Number ≥ 0 |
@Email | Valid email format |
@Pattern(regexp) | Matches regex |
@Past / @Future | Date before/after now |
@PastOrPresent | Date ≤ now |
@Digits(integer, fraction) | Number format |
Triggering Validation in Controllers
@Valid on a @RequestBody parameter tells Spring to validate the object before calling your method. If any constraint fails, Spring throws MethodArgumentNotValidException and your method body is never reached. For path variables and query parameters, add @Validated to the controller class and put constraints directly on the parameters.
@RestController
@RequestMapping("/api/users")
@Validated // required to enable @PathVariable and @RequestParam validation
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest req) {
// Only reached if all constraints in CreateUserRequest pass
return ResponseEntity.status(201).body(userService.create(req));
}
@GetMapping("/{id}")
public UserResponse getById(@PathVariable @Min(1) Long id) {
// @Min(1) ensures we never query the database with id <= 0
return userService.findById(id);
}
@GetMapping
public Page<UserResponse> search(
@RequestParam @Min(0) int page,
@RequestParam @Min(1) @Max(100) int size) {
return userService.findAll(page, size);
}
}
Global Exception Handler
Without a handler, validation failures produce an ugly Spring default response or a 500. A @RestControllerAdvice intercepts the exceptions thrown by failed validation and transforms them into the clean JSON structure your API contract promises. Note that @RequestBody failures throw MethodArgumentNotValidException, while @PathVariable/@RequestParam failures throw ConstraintViolationException — you need to handle both.
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handles @RequestBody validation failures — field-level errors
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
return new ErrorResponse("Validation failed", fieldErrors);
}
// Handles @PathVariable and @RequestParam validation failures
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolations(ConstraintViolationException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getConstraintViolations().forEach(v -> {
String field = v.getPropertyPath().toString();
// Strip the method and parameter prefix — return just the parameter name
errors.put(field.substring(field.lastIndexOf('.') + 1), v.getMessage());
});
return new ErrorResponse("Validation failed", errors);
}
// Handles domain-level not found
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse(ex.getMessage(), null);
}
// Catch-all — prevents raw stack traces from leaking to clients
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex) {
return new ErrorResponse("An unexpected error occurred", null);
}
}
public record ErrorResponse(String message, Map<String, String> errors) {}
Validation error response:
{
"message": "Validation failed",
"errors": {
"email": "Must be a valid email address",
"password": "Password must be at least 8 characters",
"age": "Age cannot be negative"
}
}
Nested Object Validation
@Valid cascades validation into nested objects and collections. Without it, the constraints on the nested type are ignored. Add @Valid to any field that is itself annotated with constraints.
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<@Valid OrderItemRequest> items, // validates each item in the list
@Valid @NotNull ShippingAddressRequest shippingAddress // validates the nested object
) {}
public record OrderItemRequest(
@NotBlank String sku,
@Min(1) int quantity,
@Positive double unitPrice
) {}
public record ShippingAddressRequest(
@NotBlank String street,
@NotBlank String city,
@NotBlank @Size(min = 2, max = 2) String countryCode // exactly 2 characters
) {}
Custom Constraint
When the built-in annotations don’t cover your domain rule, create a custom constraint. It has three parts: an annotation that declares the constraint, a validator class that implements the check, and usage on a field or parameter.
// Step 1: declare the annotation — it's meta-annotated with @Constraint to link the validator
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface ValidPhone {
String message() default "Invalid phone number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Step 2: implement the validation logic
public class PhoneNumberValidator implements ConstraintValidator<ValidPhone, String> {
private static final Pattern PATTERN = Pattern.compile("^\\+?[1-9]\\d{7,14}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // let @NotNull handle null separately — single responsibility
return PATTERN.matcher(value).matches();
}
}
// Step 3: use it exactly like any built-in annotation
public record ContactRequest(
@NotBlank String name,
@ValidPhone String phone
) {}
Cross-Field Validation
Sometimes a constraint involves more than one field — for example, confirming that two password fields match. Class-level constraints receive the whole object and can check any combination of fields.
// Class-level annotation — targets the whole object, not a single field
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {
@Override
public boolean isValid(Object obj, ConstraintValidatorContext ctx) {
if (obj instanceof RegisterRequest req) {
return req.password().equals(req.confirmPassword());
}
return true;
}
}
@PasswordMatch // applied at class level — checked after all field-level constraints pass
public record RegisterRequest(
@NotBlank String username,
@NotBlank @Size(min = 8) String password,
@NotBlank String confirmPassword
) {}
Validation Groups
Groups let you apply different constraints in different scenarios — for example, requiring a password on create but not on update. This avoids having separate request classes for create and update when the only difference is which fields are required.
// Marker interfaces — no methods needed, just used as identifiers
public interface OnCreate {}
public interface OnUpdate {}
public class UserRequest {
@NotBlank(groups = OnCreate.class) // required only when creating; ignored on update
private String password;
@NotBlank(groups = {OnCreate.class, OnUpdate.class}) // required in both cases
private String name;
}
// @Validated(group) activates only constraints for that group
@PostMapping
public UserResponse create(@RequestBody @Validated(OnCreate.class) UserRequest req) { ... }
@PutMapping("/{id}")
public UserResponse update(@PathVariable Long id,
@RequestBody @Validated(OnUpdate.class) UserRequest req) { ... }