Awesome-omni-skill spring-validation
Bean Validation (Jakarta Validation) with Spring Boot. Custom validators, validation groups, cross-field validation, and internationalized error messages.
install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/backend/spring-validation" ~/.claude/skills/diegosouzapw-awesome-omni-skill-spring-validation && rm -rf "$T"
manifest:
skills/backend/spring-validation/SKILL.mdsource content
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:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
This transitively brings in:
(constraint annotations)jakarta.validation:jakarta.validation-api
(reference implementation)org.hibernate.validator:hibernate-validator
Note:
does NOT include validation automatically since Spring Boot 2.3+. You must addspring-boot-starter-webexplicitly.spring-boot-starter-validation
Built-in Constraints Quick Reference
| Annotation | Applies to | Description |
|---|---|---|
| Any type | Must not be (empty string passes) |
| | Must not be , must contain at least one non-whitespace character |
| , , , | Must not be or empty (blank string passes) |
| , , , | Length/size must be within bounds. is valid. |
| Numeric types | Must be >= value |
| Numeric types | Must be <= value |
| Numeric types | Must be > 0 |
| Numeric types | Must be >= 0 |
| Numeric types | Must be < 0 |
| Numeric types | Must be <= 0 |
| , , , numeric | Must be >= value (string comparison) |
| , , , numeric | Must be <= value (string comparison) |
| | Must be a valid email format. is valid. |
| | Must match the regex. is valid. |
| Date/time types | Must be a date in the past |
| Date/time types | Must be a date in the past or present |
| Date/time types | Must be a date in the future |
| Date/time types | Must be a date in the future or present |
| Numeric types | Must have at most N integer digits and F fraction digits |
| | Must be |
| | Must be |
Critical: Most constraints pass on
. Combine withnullif the field is required. For example,@NotNullalone allows; usenullto make it mandatory.@NotNull @Email
See
references/builtin-constraints.md for the complete reference with parameters, examples, and common gotchas.
@Valid vs @Validated
| Feature | (Jakarta) | (Spring) |
|---|---|---|
| Package | | |
| Validation groups | No | Yes |
| Method-level validation | No | Yes (on class) |
| Nested object cascade | Yes | Yes |
| Use on parameter | Yes | Yes |
| Use on class | No | Yes |
Rule of thumb:
- Use
on@Valid
parameters and nested fields when you do not need groups.@RequestBody - Use
on classes (controllers, services) to enable method-level validation and on parameters when you need validation groups.@Validated
Validation in Controllers
Basic validation with @Valid
@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
@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
, Spring will NOT throwBindingResult. You must check errors manually. If you forget, invalid data silently passes through.MethodArgumentNotValidException
Validation with groups
@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:
// Marker interfaces public interface OnCreate {} public interface OnUpdate {}
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:
is skipped (field can be@NotBlank
meaning "don't change"), but format constraints still apply if a value is provided.null
Custom Constraint Annotations
Step 1: Define the annotation
@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
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)
@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"; }
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:
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
on the nested field, constraints on@ValidandOrderItemRequestwill NOT be checked.AddressRequest
Collection Validation
Validate elements inside collections using
@Valid and container element constraints:
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:
@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
(notConstraintViolationException). Your exception handler must catch both.MethodArgumentNotValidException
Error Message Customization
Inline messages
@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
@NotBlank(message = "{user.name.required}") private String name;
# src/main/resources/messages.properties user.name.required=Name is required user.email.invalid=Please provide a valid email address
Message interpolation variables
# {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:
@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
@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
-
is inspring-boot-starter-validationpom.xml - DTOs use
combined with format constraints (@NotNull
,@Email
, etc.) for required fields@Size - Nested objects annotated with
to cascade validation@Valid - Collections use
for element validationList<@Valid ItemRequest> - Validation groups defined for create vs update scenarios
- Custom constraints return
fortrue
values (letnull
handle nulls)@NotNull - Class-level constraints used for cross-field validation
-
handles both@RestControllerAdvice
andMethodArgumentNotValidExceptionConstraintViolationException - Error responses use
(RFC 7807) formatProblemDetail - Validation messages externalized to
messages.properties - i18n messages provided for supported locales
- Method-level validation uses
on the class, not@Validated@Valid -
is only used when manual error handling is intentionalBindingResult
References
- See
for the complete constraint referencereferences/builtin-constraints.md - See
for message customization and i18nreferences/error-message-patterns.md - See
for complete code examplesexamples/