Bean Validation: Where @Valid Fires, and Where It Doesn't
The real difference between @Valid and @Validated, the two exceptions they throw, writing a ConstraintValidator, and why validation spans several layers.
Binding Is Not Validation
From the previous guide, this request body binds cleanly:
{ "customerName": "", "quantity": -5, "email": "not-an-email" }Every field is the right type, so Jackson is satisfied and your method runs with nonsense. Type correctness and business correctness are different questions, and only the first one is answered for free.
Jakarta Bean Validation answers the second declaratively. You annotate the constraints on the type, and the framework enforces them before your method body executes.
public record NewOrderRequest(
@NotBlank @Size(max = 100) String customerName,
@Min(1) @Max(1000) int quantity,
@Email String email) {
}@PostMapping
ResponseEntity<Order> create(@Valid @RequestBody NewOrderRequest request) {
// reached only if every constraint passed
}@Valid compiles and does nothing without an implementation on the classpath. Add it:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
}This was bundled with starter-web until Spring Boot 2.3 and hasn't been since. There is no warning — constraints are simply never checked, which means invalid data reaching your service layer looks like a logic bug rather than a missing dependency. If @NotBlank isn't rejecting blank input, check this first, every time.
The Constraints Worth Memorising
| Annotation | Checks | Applies to |
|---|---|---|
@NotNull | Not null (empty string passes) | Any |
@NotEmpty | Not null and not zero-length | String, Collection, Map, array |
@NotBlank | Not null and has non-whitespace | String |
@Size(min, max) | Length or size in range | String, Collection, Map, array |
@Min / @Max | Numeric bounds | Integral numbers |
@Positive / @PositiveOrZero | Sign | Numbers |
@DecimalMin / @DecimalMax | Bounds with precision | BigDecimal, etc. |
@Digits(integer, fraction) | Digit counts | Numbers |
@Email | Email shape | String |
@Pattern(regexp) | Regex match | String |
@Past / @Future | Temporal | Dates and times |
The @NotNull / @NotEmpty / @NotBlank trio is the one that bites. For a user-supplied name, @NotNull is almost never what you want — "" and " " both pass it. @NotBlank is the right default for human-entered strings.
@Email accepts a deliberately permissive shape and will pass addresses that no mail server would deliver to — a@b among them. It is a typo filter, not proof of deliverability. If an address genuinely has to work, the only verification is sending mail to it.
Nesting and collections need explicit cascade
Validation does not recurse automatically. A nested object's constraints are ignored unless you cascade with @Valid on the field:
public record NewOrderRequest(
@NotBlank String customerName,
@Valid @NotNull Address shippingAddress, // cascades into Address
@Valid @NotEmpty List<@Valid OrderLine> lines) { // and into each element
}Missing the inner @Valid is a quiet gap: the outer object validates, the nested one doesn't, and malformed nested data reaches your service. The List<@Valid OrderLine> form — the annotation on the type argument — is what validates each element.
@Valid vs @Validated
These are different annotations from different places, and the distinction is the most commonly muddled thing in this topic.
@Valid is jakarta.validation.Valid, from the specification. It marks a method parameter for validation and cascades into nested objects.
@Validated is org.springframework.validation.annotation.Validated, from Spring. It does two things @Valid cannot:
- Validation groups — run a subset of constraints.
- Method-level validation on any Spring bean — applied at class level, it enables constraint checking on that bean's method parameters and return values, not just controller handlers.
@Service
@Validated // enables method validation on this bean
public class OrderService {
public Order create(@Valid NewOrderRequest request,
@NotNull @Positive Integer customerId) {
}
}That @Validated on the class is what makes constraints on customerId enforceable at all. Without it, annotations on service-method parameters are inert decoration — a genuinely common mistake, because the code reads as though it should work.
Validation groups
When one type is validated differently in different operations:
public interface OnCreate {}
public interface OnUpdate {}
public record OrderRequest(
@Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) UUID id,
@NotBlank(groups = { OnCreate.class, OnUpdate.class }) String customerName) {
}@PostMapping
Order create(@Validated(OnCreate.class) @RequestBody OrderRequest request) { }
@PutMapping("/{id}")
Order update(@Validated(OnUpdate.class) @RequestBody OrderRequest request) { }Useful, and also a smell worth noticing: if a type needs three groups, separate CreateOrderRequest and UpdateOrderRequest records are usually clearer than one type with conditional constraints.
Two Exceptions, Two Shapes
Where validation fires determines which exception you get — and you must handle both.
| Context | Exception | Default status |
|---|---|---|
@Valid on a @RequestBody or model attribute | MethodArgumentNotValidException | 400 |
@Validated bean method, or constraints on simple params | ConstraintViolationException | 500 |
That 500 is the trap. ConstraintViolationException is not a Spring MVC exception, so DefaultHandlerExceptionResolver has no mapping for it, and a caller's bad input is reported as a server fault. The fix belongs in the exception handling from the next guide:
@ExceptionHandler(ConstraintViolationException.class)
ProblemDetail onConstraintViolation(ConstraintViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation failed");
problem.setProperty("violations", ex.getConstraintViolations().stream()
.map(v -> Map.of("field", v.getPropertyPath().toString(),
"message", v.getMessage()))
.toList());
return problem;
}Check yourself
A @Service method has @NotNull on a parameter. A caller passes null, and no exception is thrown — the method runs with null. What is missing?
Custom Constraints
When a rule isn't expressible with the built-ins, write it once as an annotation plus a validator. Two pieces:
@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
String message() default "must be a valid SKU";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}public class SkuValidator implements ConstraintValidator<ValidSku, String> {
private static final Pattern FORMAT = Pattern.compile("^[A-Z]{3}-\\d{6}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // null is @NotNull's job, not ours
}
return FORMAT.matcher(value).matches();
}
}Then @ValidSku String sku.
The message(), groups() and payload() members are required by the specification — omit one and the annotation won't work.
Treat null as valid in every custom validator. Each constraint should test exactly one thing. Let @NotNull handle presence and compose them: @NotNull @ValidSku. A validator that rejects null forces presence everywhere it's used, including on genuinely optional fields.
A custom validator is a Spring bean, so it can inject dependencies:
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final CustomerRepository repository;
public UniqueEmailValidator(CustomerRepository repository) {
this.repository = repository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return email == null || !repository.existsByEmail(email);
}
}A database-backed validator like that is convenient and is not a uniqueness guarantee. Two concurrent requests can both pass the check before either inserts — a time-of-check-to-time-of-use race. The guarantee has to be a unique constraint in the database; the validator only improves the error message in the common case. Handle the resulting DataIntegrityViolationException as well, or you will see a 500 under concurrency.
Messages Clients Can Use
Default messages are terse and English-only. Externalise them:
@NotBlank(message = "{order.customerName.required}")
String customerName# messages.properties
order.customerName.required=Customer name is required
order.quantity.range=Quantity must be between {min} and {max}Spring Boot picks up messages.properties automatically, and messages_fr.properties alongside it gives you localisation through the same mechanism. Constraint attributes interpolate — {min}, {max} — and ${validatedValue} inserts the rejected value.
Return field-level detail rather than one opaque string. A client needs to know which field failed to highlight it:
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"errors": [
{ "field": "customerName", "message": "Customer name is required" },
{ "field": "quantity", "message": "Quantity must be between 1 and 1000" }
]
}Guide 3 builds exactly this response from MethodArgumentNotValidException.
Validate at More Than One Layer
A reasonable objection: isn't validating in the controller and the service redundant? No — the two layers answer different questions.
- The controller validates the request. Shape, presence, ranges, formats. Everything checkable from the payload alone, rejected at the boundary with a 400.
- The service enforces invariants. Rules needing other state: does this customer exist, is the item in stock, is this transition legal, is the caller allowed to do this. These are not 400s — they're 404s, 409s and 422s.
public Order create(NewOrderRequest request) {
Customer customer = customers.findById(request.customerId())
.orElseThrow(() -> new CustomerNotFoundException(request.customerId())); // 404
if (!inventory.hasStock(request.sku(), request.quantity())) {
throw new InsufficientStockException(request.sku()); // 409
}
}Annotations cannot express the second kind, and a service invoked from a message listener or a scheduled job never passes through a controller at all. Bean Validation guards the boundary; the domain guards itself.
And both layers sit above the database's own constraints — NOT NULL, UNIQUE, CHECK, foreign keys. Those are the only ones that hold under concurrency and against every writer, so define them even when the application already checks. Validation at each layer is defence in depth, not duplication.
Check yourself
A @ConfigurationProperties record carries @NotBlank on a required API key, and the property is absent in production. When do you find out?
The Mental Model, Restated
spring-boot-starter-validationis required. Without it@Validis silently inert.@NotBlankfor human strings, not@NotNull— and cascade with@Validon nested objects and collection elements.@Validmarks a parameter;@Validatedenables method validation on a bean and selects groups.- Two exceptions:
MethodArgumentNotValidExceptionis already 400;ConstraintViolationExceptionis a 500 until you handle it. - Custom validators treat
nullas valid, so each constraint tests one thing. - Validate the request at the boundary, enforce invariants in the domain, constrain the data in the database.
What's Next
Validation now produces precise failures — and by default they reach the client as Spring Boot's generic error body, with the useful parts stripped out. The next guide covers central error handling: @ControllerAdvice, mapping domain exceptions to the right status codes, and ProblemDetail for responses that follow RFC 9457 instead of being invented per project.