Spring Boot
Validation in REST APIs
Spring Boot integrates Jakarta Bean Validation (Hibernate Validator) out of the box via spring-boot-starter-validation. Annotations on DTO fields declare constraints; @Valid or @Validated on controller parameters trigger validation; @RestControllerAdvice catches MethodArgumentNotValidException and shapes a consistent error response. This entry covers constraint annotations, cross-field validation, custom validators, nested object validation, and error response formatting.
Setup and Core Annotations
Add spring-boot-starter-validation to bring in Hibernate Validator. Annotate DTO fields with Jakarta constraint annotations and add @Valid to the @RequestBody parameter. Spring validates the object before the controller method runs and throws MethodArgumentNotValidException on failure.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
// ── Request DTO with constraints ──────────────────────────────────────
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 = "Email must be a valid address")
String email,
@NotNull(message = "Age is required")
@Min(value = 18, message = "Age must be at least 18")
@Max(value = 120, message = "Age must not exceed 120")
Integer age,
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(
regexp = "^(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).+$",
message = "Password must contain an uppercase letter, a digit, and a special character"
)
String password,
@NotNull(message = "Role is required")
UserRole role
) {}
// ── Controller — @Valid triggers validation ───────────────────────────
@PostMapping
public ResponseEntity<UserResponse> create(
@RequestBody @Valid CreateUserRequest request,
UriComponentsBuilder uriBuilder) {
UserResponse created = userService.create(request);
URI location = uriBuilder
.path("/api/v1/users/{id}")
.buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}Validation Error Response
MethodArgumentNotValidException is thrown when @Valid fails. Handle it in @RestControllerAdvice to return a structured error body that lists every field violation. Return 400 Bad Request with a map of field name to error message so clients can display inline errors.
Java
// ── Standardised error body ───────────────────────────────────────────
public record ValidationErrorResponse(
int status,
String error,
String message,
Instant timestamp,
Map<String, String> fieldErrors
) {}
// ── Global handler ────────────────────────────────────────────────────
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ── Request body validation (@Valid on @RequestBody) ──────────────
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null
? fe.getDefaultMessage() : "Invalid value",
(first, second) -> first // keep first message per field
));
return ResponseEntity.badRequest().body(new ValidationErrorResponse(
400, "Validation Failed",
"One or more fields failed validation",
Instant.now(), fieldErrors));
}
// ── Path variable / query param validation (@Validated on class) ──
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ValidationErrorResponse> handleConstraintViolation(
ConstraintViolationException ex) {
Map<String, String> fieldErrors = ex.getConstraintViolations()
.stream()
.collect(Collectors.toMap(
cv -> cv.getPropertyPath().toString(),
ConstraintViolation::getMessage,
(first, second) -> first
));
return ResponseEntity.badRequest().body(new ValidationErrorResponse(
400, "Validation Failed",
"Parameter validation failed",
Instant.now(), fieldErrors));
}
}
// ── Example error response ────────────────────────────────────────────
// {
// "status": 400,
// "error": "Validation Failed",
// "message": "One or more fields failed validation",
// "timestamp": "2024-03-15T10:30:00Z",
// "fieldErrors": {
// "email": "Email must be a valid address",
// "password": "Password must be at least 8 characters"
// }
// }Validating Path Variables and Query Parameters
@Valid only validates @RequestBody objects. To validate @PathVariable and @RequestParam values, add @Validated to the controller class. This activates method-level validation via a Spring AOP proxy and throws ConstraintViolationException on failure.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated // ← required for @PathVariable and @RequestParam validation
public class UserController {
private final UserService userService;
// ── Validate path variable ─────────────────────────────────────────
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Positive(message = "ID must be a positive number") Long id) {
return ResponseEntity.ok(userService.findById(id));
}
// ── Validate query parameters ──────────────────────────────────────
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
@RequestParam(required = false)
@Size(max = 100, message = "Search term must not exceed 100 characters")
String search,
@RequestParam(required = false)
@Min(value = 0, message = "Min age must not be negative") Integer minAge,
@RequestParam(required = false)
@Max(value = 120, message = "Max age must not exceed 120") Integer maxAge,
@PageableDefault(size = 20) Pageable pageable) {
return ResponseEntity.ok(
PageResponse.from(userService.findAll(search, minAge, maxAge, pageable)));
}
// ── Validate both body and path variable ──────────────────────────
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable @Positive Long id,
@RequestBody @Valid UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
}Cross-Field and Class-Level Validation
Some constraints span multiple fields — for example, confirming that two password fields match, or that a date range is valid. Implement these with a custom class-level constraint annotation and a ConstraintValidator that receives the whole object.
Java
// ── Constraint annotation ─────────────────────────────────────────────
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// ── Validator ─────────────────────────────────────────────────────────
public class PasswordMatchValidator
implements ConstraintValidator<PasswordMatch, ChangePasswordRequest> {
@Override
public boolean isValid(ChangePasswordRequest request,
ConstraintValidatorContext context) {
if (request.password() == null || request.confirmPassword() == null) {
return true; // let @NotBlank handle null checks
}
boolean match = request.password().equals(request.confirmPassword());
if (!match) {
// Attach the violation to the confirmPassword field
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Passwords do not match")
.addPropertyNode("confirmPassword")
.addConstraintViolation();
}
return match;
}
}
// ── DTO with class-level constraint ───────────────────────────────────
@PasswordMatch
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank @Size(min = 8) String password,
@NotBlank String confirmPassword
) {}
// ── Date range example ────────────────────────────────────────────────
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "Start date must be before end date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, DateRangeRequest> {
@Override
public boolean isValid(DateRangeRequest req, ConstraintValidatorContext ctx) {
if (req.startDate() == null || req.endDate() == null) return true;
return !req.startDate().isAfter(req.endDate());
}
}
@ValidDateRange
public record DateRangeRequest(
@NotNull LocalDate startDate,
@NotNull LocalDate endDate
) {}Custom Field-Level Validators
Create a custom field-level constraint when built-in annotations are insufficient — for example, validating a phone number format, checking that a value belongs to a known enum, or verifying uniqueness against the database.
Java
// ── @PhoneNumber constraint ───────────────────────────────────────────
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface PhoneNumber {
String message() default "Invalid phone number format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PhoneNumberValidator
implements ConstraintValidator<PhoneNumber, String> {
private static final Pattern E164 =
Pattern.compile("^\+[1-9]\d{7,14}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // @NotBlank handles null
return E164.matcher(value).matches();
}
}
// ── @UniqueEmail — database-backed constraint ─────────────────────────
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email address is already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component // Spring bean — can inject repositories
public class UniqueEmailValidator
implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private UserRepository userRepo;
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
if (email == null) return true;
return !userRepo.existsByEmailIgnoreCase(email);
}
}
// ── Apply on DTO ──────────────────────────────────────────────────────
public record CreateUserRequest(
@NotBlank @Size(min = 2, max = 100) String name,
@NotBlank @Email @UniqueEmail
String email,
@NotBlank @PhoneNumber
String phone
) {}Nested Object and Collection Validation
Validation cascades into nested objects and collection elements only when @Valid is present on the field. Without it, the nested object's constraints are silently skipped. Apply @Valid on every nested DTO field and on collection fields to validate each element.
Java
// ── Nested DTO ────────────────────────────────────────────────────────
public record AddressRequest(
@NotBlank(message = "Street is required") String street,
@NotBlank(message = "City is required") String city,
@NotBlank(message = "Country is required") String country,
@Pattern(regexp = "^[A-Z]{2}$",
message = "Country code must be a 2-letter ISO code")
String countryCode,
@NotBlank @Size(min = 3, max = 10)
String postalCode
) {}
// ── Parent DTO with nested object and collection ───────────────────────
public record CreateOrderRequest(
@NotNull @Positive
Long customerId,
@Valid // ← cascades into AddressRequest
@NotNull(message = "Shipping address is required")
AddressRequest shippingAddress,
@Valid // ← validates each OrderItemRequest
@NotEmpty(message = "Order must contain at least one item")
@Size(max = 50, message = "Order cannot exceed 50 items")
List<OrderItemRequest> items,
@NotNull @FutureOrPresent
LocalDate deliveryDate
) {}
public record OrderItemRequest(
@NotNull @Positive Long productId,
@NotNull @Positive @Max(999) Integer quantity,
@NotNull @DecimalMin("0.01") BigDecimal unitPrice
) {}
// ── Controller ────────────────────────────────────────────────────────
@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(
@RequestBody @Valid CreateOrderRequest request,
UriComponentsBuilder uriBuilder) {
OrderResponse created = orderService.create(request);
URI location = uriBuilder
.path("/api/v1/orders/{id}")
.buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
// ── Validation error includes nested paths ────────────────────────────
// "fieldErrors": {
// "shippingAddress.postalCode": "size must be between 3 and 10",
// "items[0].quantity": "must be less than or equal to 999",
// "items[1].unitPrice": "must be greater than or equal to 0.01"
// }Validation Groups
Validation groups apply different constraint subsets depending on the operation. A field might be @NotNull on create but optional on patch. Define marker interfaces as group names, assign them to constraints, and pass the group to @Validated on the controller parameter.
Java
// ── Group marker interfaces ───────────────────────────────────────────
public interface ValidationGroups {
interface OnCreate {}
interface OnUpdate {}
interface OnPatch {}
}
// ── DTO with group-specific constraints ───────────────────────────────
public record UserRequest(
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
@Size(min = 2, max = 100,
groups = {OnCreate.class, OnUpdate.class})
String name,
@NotBlank(groups = OnCreate.class) // required on create only
@Email(groups = {OnCreate.class, OnUpdate.class})
String email,
@NotBlank(groups = OnCreate.class) // required on create only
@Size(min = 8, groups = OnCreate.class)
String password,
// Optional on patch — no NotNull, just format validation if present
@PhoneNumber(groups = OnPatch.class)
String phone
) {}
// ── Controller using groups ───────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> create(
@RequestBody @Validated(OnCreate.class) UserRequest request,
UriComponentsBuilder uriBuilder) {
UserResponse created = userService.create(request);
URI location = uriBuilder
.path("/api/v1/users/{id}")
.buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@RequestBody @Validated(OnUpdate.class) UserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@PatchMapping("/{id}")
public ResponseEntity<UserResponse> patch(
@PathVariable Long id,
@RequestBody @Validated(OnPatch.class) UserRequest request) {
return ResponseEntity.ok(userService.patch(id, request));
}
}