AgentSkillsCN

spring-validation

在 Spring Boot 中使用 Bean Validation(Jakarta Validation)。支持自定义验证器、验证组、跨字段验证,以及国际化错误信息。

SKILL.md
--- frontmatter
name: spring-validation
description: Bean Validation (Jakarta Validation) with Spring Boot. Custom validators, validation groups, cross-field validation, and internationalized error messages.
version: "1.0.0"
author: GazApps
tags: [validation, bean-validation, jakarta, constraints, spring-boot, forms]
dependencies: [java-fundamentals, spring-boot-core]
compatibility: [antigravity, claude-code, gemini-cli]

Spring Validation

Bean Validation (Jakarta Validation) patterns for Spring Boot applications.

Use this skill when

  • Adding input validation to DTOs / request bodies
  • Creating custom constraint annotations
  • Implementing cross-field validation (e.g., password confirmation, date ranges)
  • Setting up validation groups (different rules for create vs update)
  • Customizing validation error messages or adding i18n
  • Implementing method-level validation on service classes
  • Building structured API error responses for validation failures
  • User mentions "validation", "constraints", "@Valid", "@NotNull", "@NotBlank", "BindingResult"

Do not use this skill when

  • User needs authorization rules (use spring-security-oauth2)
  • User wants business rule validation that depends on database state (use service-layer logic)
  • User needs client-side / JavaScript validation only
  • User asks about OpenAPI schema validation (use springdoc annotations)

Dependencies

Add to pom.xml:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

This transitively brings in:

  • jakarta.validation:jakarta.validation-api (constraint annotations)
  • org.hibernate.validator:hibernate-validator (reference implementation)

Note: spring-boot-starter-web does NOT include validation automatically since Spring Boot 2.3+. You must add spring-boot-starter-validation explicitly.

Built-in Constraints Quick Reference

AnnotationApplies toDescription
@NotNullAny typeMust not be null (empty string "" passes)
@NotBlankCharSequenceMust not be null, must contain at least one non-whitespace character
@NotEmptyCharSequence, Collection, Map, ArrayMust not be null or empty (blank string " " passes)
@Size(min, max)CharSequence, Collection, Map, ArrayLength/size must be within bounds. null is valid.
@Min(value)Numeric typesMust be >= value
@Max(value)Numeric typesMust be <= value
@PositiveNumeric typesMust be > 0
@PositiveOrZeroNumeric typesMust be >= 0
@NegativeNumeric typesMust be < 0
@NegativeOrZeroNumeric typesMust be <= 0
@DecimalMin(value)BigDecimal, BigInteger, CharSequence, numericMust be >= value (string comparison)
@DecimalMax(value)BigDecimal, BigInteger, CharSequence, numericMust be <= value (string comparison)
@EmailCharSequenceMust be a valid email format. null is valid.
@Pattern(regexp)CharSequenceMust match the regex. null is valid.
@PastDate/time typesMust be a date in the past
@PastOrPresentDate/time typesMust be a date in the past or present
@FutureDate/time typesMust be a date in the future
@FutureOrPresentDate/time typesMust be a date in the future or present
@Digits(integer, fraction)Numeric typesMust have at most N integer digits and F fraction digits
@AssertTruebooleanMust be true
@AssertFalsebooleanMust be false

Critical: Most constraints pass on null. Combine with @NotNull if the field is required. For example, @Email alone allows null; use @NotNull @Email to make it mandatory.

See references/builtin-constraints.md for the complete reference with parameters, examples, and common gotchas.

@Valid vs @Validated

Feature@Valid (Jakarta)@Validated (Spring)
Packagejakarta.validationorg.springframework.validation.annotation
Validation groupsNoYes
Method-level validationNoYes (on class)
Nested object cascadeYesYes
Use on parameterYesYes
Use on classNoYes

Rule of thumb:

  • Use @Valid on @RequestBody parameters and nested fields when you do not need groups.
  • Use @Validated on classes (controllers, services) to enable method-level validation and on parameters when you need validation groups.

Validation in Controllers

Basic validation with @Valid

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

    @PostMapping
    public ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest request) {
        // If validation fails, MethodArgumentNotValidException is thrown automatically
        UserResponse response = userService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

Using BindingResult for manual error handling

java
@PostMapping
public ResponseEntity<?> create(
        @Valid @RequestBody CreateUserRequest request,
        BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        Map<String, String> errors = bindingResult.getFieldErrors().stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
                (a, b) -> a  // keep first error per field
            ));
        return ResponseEntity.badRequest().body(errors);
    }

    return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
}

Warning: When you declare BindingResult, Spring will NOT throw MethodArgumentNotValidException. You must check errors manually. If you forget, invalid data silently passes through.

Validation with groups

java
@RestController
@RequestMapping("/api/users")
@Validated  // Required for group-based validation on controller methods
public class UserController {

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @Validated(OnCreate.class) @RequestBody CreateUserRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable UUID id,
            @Validated(OnUpdate.class) @RequestBody UpdateUserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }
}

Validation Groups

Define marker interfaces to apply different validation rules for create vs update:

java
// Marker interfaces
public interface OnCreate {}
public interface OnUpdate {}
java
public record CreateUserRequest(
    @NotBlank(groups = OnCreate.class)
    @Size(min = 2, max = 100, groups = {OnCreate.class, OnUpdate.class})
    String name,

    @NotBlank(groups = OnCreate.class)
    @Email(groups = {OnCreate.class, OnUpdate.class})
    String email,

    @NotBlank(groups = OnCreate.class)
    @Size(min = 8, max = 72, groups = OnCreate.class)
    String password
) {}
  • On create: all fields required, all constraints enforced.
  • On update: @NotBlank is skipped (field can be null meaning "don't change"), but format constraints still apply if a value is provided.

Custom Constraint Annotations

Step 1: Define the annotation

java
@Documented
@Constraint(validatedBy = StrongPasswordValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface StrongPassword {
    String message() default "{validation.strongPassword}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    int minLength() default 8;
}

Step 2: Implement the validator

java
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {

    private int minLength;

    @Override
    public void initialize(StrongPassword annotation) {
        this.minLength = annotation.minLength();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;  // Let @NotNull handle null checks

        if (value.length() < minLength) return false;
        if (!value.matches(".*[A-Z].*")) return false;   // uppercase
        if (!value.matches(".*[a-z].*")) return false;   // lowercase
        if (!value.matches(".*\\d.*")) return false;     // digit
        if (!value.matches(".*[!@#$%^&*].*")) return false; // special char

        return true;
    }
}

Cross-field validation (class-level constraint)

java
@Documented
@Constraint(validatedBy = ValidDateRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDateRange {
    String message() default "End date must be after start date";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String startField() default "startDate";
    String endField() default "endDate";
}
java
public class ValidDateRangeValidator implements ConstraintValidator<ValidDateRange, Object> {

    private String startField;
    private String endField;

    @Override
    public void initialize(ValidDateRange annotation) {
        this.startField = annotation.startField();
        this.endField = annotation.endField();
    }

    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        try {
            BeanWrapper wrapper = new BeanWrapperImpl(obj);
            LocalDate start = (LocalDate) wrapper.getPropertyValue(startField);
            LocalDate end = (LocalDate) wrapper.getPropertyValue(endField);

            if (start == null || end == null) return true;

            boolean valid = end.isAfter(start);
            if (!valid) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                    .addPropertyNode(endField)
                    .addConstraintViolation();
            }
            return valid;
        } catch (Exception e) {
            return false;
        }
    }
}

Nested Object Validation

Use @Valid on nested fields to cascade validation:

java
public record CreateOrderRequest(
    @NotNull
    UUID customerId,

    @NotEmpty(message = "Order must have at least one item")
    @Valid  // Cascade validation into each OrderItemRequest
    List<OrderItemRequest> items,

    @Valid  // Cascade validation into the address object
    @NotNull
    AddressRequest shippingAddress
) {}

public record OrderItemRequest(
    @NotNull UUID productId,
    @Positive int quantity
) {}

public record AddressRequest(
    @NotBlank String street,
    @NotBlank String city,
    @NotBlank @Size(min = 2, max = 2) String state,
    @NotBlank @Pattern(regexp = "\\d{5}(-\\d{4})?") String zipCode
) {}

Critical: Without @Valid on the nested field, constraints on OrderItemRequest and AddressRequest will NOT be checked.

Collection Validation

Validate elements inside collections using @Valid and container element constraints:

java
public record BulkCreateRequest(
    @NotEmpty
    @Size(max = 100, message = "Cannot create more than 100 items at once")
    List<@Valid CreateUserRequest> users
) {}

Method-Level Validation

Apply validation to service method parameters and return values:

java
@Service
@Validated  // Enables method-level validation for this bean
public class UserService {

    public UserResponse createUser(@Valid CreateUserRequest request) {
        // @Valid triggers validation on the request parameter
        // ConstraintViolationException thrown if invalid
        // ...
    }

    @NotNull
    public UserResponse findById(@NotNull UUID id) {
        // Both parameter and return value validated
        // ...
    }

    public void updateEmails(@NotEmpty List<@Email String> emails) {
        // Validates list is not empty and each element is a valid email
        // ...
    }
}

Note: Method-level validation throws ConstraintViolationException (not MethodArgumentNotValidException). Your exception handler must catch both.

Error Message Customization

Inline messages

java
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between {min} and {max} characters")
private String name;

Using messages.properties

java
@NotBlank(message = "{user.name.required}")
private String name;
properties
# src/main/resources/messages.properties
user.name.required=Name is required
user.email.invalid=Please provide a valid email address

Message interpolation variables

properties
# {min}, {max}, {value} are automatically available
user.name.size=Name must be between {min} and {max} characters
order.quantity.min=Quantity must be at least {value}

See references/error-message-patterns.md for complete i18n setup and custom message resolvers.

Programmatic Validation

Use Validator directly when validation must happen outside the annotation flow:

java
@Service
@RequiredArgsConstructor
public class ImportService {

    private final Validator validator;

    public ImportResult importUsers(List<CreateUserRequest> requests) {
        List<String> errors = new ArrayList<>();

        for (int i = 0; i < requests.size(); i++) {
            Set<ConstraintViolation<CreateUserRequest>> violations =
                validator.validate(requests.get(i), OnCreate.class);

            if (!violations.isEmpty()) {
                for (ConstraintViolation<CreateUserRequest> v : violations) {
                    errors.add("Row %d: %s %s".formatted(
                        i + 1, v.getPropertyPath(), v.getMessage()));
                }
            }
        }

        if (!errors.isEmpty()) {
            return ImportResult.failure(errors);
        }

        // Proceed with import...
        return ImportResult.success(requests.size());
    }
}

Exception Handling for Validation Errors

java
@RestControllerAdvice
public class ValidationExceptionHandler {

    // Handles @Valid @RequestBody failures
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Validation Failed");
        problem.setDetail("One or more fields have validation errors");

        Map<String, List<String>> fieldErrors = ex.getBindingResult()
            .getFieldErrors().stream()
            .collect(Collectors.groupingBy(
                FieldError::getField,
                Collectors.mapping(fe ->
                    fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
                    Collectors.toList())
            ));

        problem.setProperty("fieldErrors", fieldErrors);
        return ResponseEntity.badRequest().body(problem);
    }

    // Handles @Validated method-level failures
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ProblemDetail> handleConstraintViolation(ConstraintViolationException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Constraint Violation");

        Map<String, String> errors = ex.getConstraintViolations().stream()
            .collect(Collectors.toMap(
                v -> extractFieldName(v.getPropertyPath()),
                ConstraintViolation::getMessage,
                (a, b) -> a
            ));

        problem.setProperty("errors", errors);
        return ResponseEntity.badRequest().body(problem);
    }

    private String extractFieldName(Path propertyPath) {
        String path = propertyPath.toString();
        return path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path;
    }
}

Code Quality Checklist

  • spring-boot-starter-validation is in pom.xml
  • DTOs use @NotNull combined with format constraints (@Email, @Size, etc.) for required fields
  • Nested objects annotated with @Valid to cascade validation
  • Collections use List<@Valid ItemRequest> for element validation
  • Validation groups defined for create vs update scenarios
  • Custom constraints return true for null values (let @NotNull handle nulls)
  • Class-level constraints used for cross-field validation
  • @RestControllerAdvice handles both MethodArgumentNotValidException and ConstraintViolationException
  • Error responses use ProblemDetail (RFC 7807) format
  • Validation messages externalized to messages.properties
  • i18n messages provided for supported locales
  • Method-level validation uses @Validated on the class, not @Valid
  • BindingResult is only used when manual error handling is intentional

References

  • See references/builtin-constraints.md for the complete constraint reference
  • See references/error-message-patterns.md for message customization and i18n
  • See examples/ for complete code examples