Bean Validation in Spring Boot
Validation is the cheapest bug prevention in a web application, and it is also the feature people most often wire up almost correctly. The annotations are there, the request still goes through, and nobody finds out until a row with an empty email address reaches production.
The dependency people forget
spring-boot-starter-web does not bring Bean Validation with it. Without this, every constraint annotation you write compiles fine and does absolutely nothing:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
If your constraints appear to be ignored, check this first. It is the cause more often than everything else combined.
Constraints on the request body
Put the annotations on the DTO, not the entity:
public record CreateUserRequest(
@NotBlank @Size(max = 80) String name,
@NotBlank @Email String email,
@Min(18) int age
) {}
Then ask for them to be checked with @Valid:
@PostMapping("/users")
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
return service.create(request);
}
Drop the @Valid and the constraints are inert. This is the second most common cause of silent validation, and it is invisible in review because the DTO looks thoroughly validated.
A failure here throws MethodArgumentNotValidException, which by default produces a response no client can act on. Give it a handler, as covered in exception handling in Spring Boot.
Nested objects need @Valid too
Constraints do not recurse on their own. An address inside a request is validated only if the field says so:
public record CreateUserRequest(
@NotBlank String name,
@Valid @NotNull Address address
) {}
Without that inner @Valid, Address is checked for null and nothing else, no matter what its own fields are annotated with. The same applies to collections: List<@Valid OrderLine> lines validates the elements, @Valid List<OrderLine> lines does not.
Path variables and request params
These live on the method signature rather than in an object, so they need the class marked as a validation target:
@RestController
@Validated
public class ReportController {
@GetMapping("/reports")
public List<Report> list(@RequestParam @Min(1) @Max(100) int size) {
return service.list(size);
}
}
@Validated on the class is what enables method level validation. The failure type is different here too: you get a ConstraintViolationException, not MethodArgumentNotValidException, so it needs its own handler if you want a consistent error body.
Groups, for when the rules differ
The same object often has different requirements on create and update. Instead of two nearly identical DTOs, tag the constraints:
public interface OnCreate {}
public record SaveUserRequest(
@Null(groups = OnCreate.class) Long id,
@NotBlank String name
) {}
@PostMapping("/users")
public UserResponse create(@Validated(OnCreate.class) @RequestBody SaveUserRequest request) { ... }
Note @Validated rather than @Valid on the parameter: only Spring's annotation accepts groups.
Use this sparingly. Two clear DTOs beat one DTO with four groups almost every time, and the version with groups is much harder to read at a glance.
Writing your own constraint
When the rule is domain specific, an annotation plus a validator keeps it declarative:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SlugValidator.class)
public @interface Slug {
String message() default "must be lowercase words separated by hyphens";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class SlugValidator implements ConstraintValidator<Slug, String> {
private static final Pattern PATTERN = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || PATTERN.matcher(value).matches();
}
}
The value == null check is deliberate. A constraint that rejects null duplicates @NotNull and takes the choice away from whoever uses the annotation. Let them compose @NotNull @Slug when they want both.
Validators are Spring beans, so they can take dependencies through the constructor if the rule needs a lookup. Be careful with that: a constraint that hits the database runs on every request and turns a cheap check into a query.
What validation is not
Bean Validation checks the shape of a request. It does not check that the email is not already taken, or that the account has enough balance, because those are questions about state that can change between the check and the write. Those belong in the service, enforced by a database constraint underneath, where the answer cannot go stale.