Spring BootValidation in REST APIs
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));
    }
}