02-web-rest-apis

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.

October 8, 2026
spring-bootvalidationbean-validationjakarta-validationConstraintValidatorrest-api

Binding Is Not Validation

From the previous guide, this request body binds cleanly:

json
{ "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.

java
public record NewOrderRequest(
        @NotBlank @Size(max = 100) String customerName,
        @Min(1) @Max(1000) int quantity,
        @Email String email) {
}
java
@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:

groovy
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

AnnotationChecksApplies to
@NotNullNot null (empty string passes)Any
@NotEmptyNot null and not zero-lengthString, Collection, Map, array
@NotBlankNot null and has non-whitespaceString
@Size(min, max)Length or size in rangeString, Collection, Map, array
@Min / @MaxNumeric boundsIntegral numbers
@Positive / @PositiveOrZeroSignNumbers
@DecimalMin / @DecimalMaxBounds with precisionBigDecimal, etc.
@Digits(integer, fraction)Digit countsNumbers
@EmailEmail shapeString
@Pattern(regexp)Regex matchString
@Past / @FutureTemporalDates 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:

java
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:

  1. Validation groups — run a subset of constraints.
  2. 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.
java
@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:

java
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) {
}
java
@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.

ContextExceptionDefault status
@Valid on a @RequestBody or model attributeMethodArgumentNotValidException400
@Validated bean method, or constraints on simple paramsConstraintViolationException500

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:

java
@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:

java
@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 {};
}
java
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:

java
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:

java
@NotBlank(message = "{order.customerName.required}")
String customerName
properties
# 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:

json
{
  "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.
java
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

  1. spring-boot-starter-validation is required. Without it @Valid is silently inert.
  2. @NotBlank for human strings, not @NotNull — and cascade with @Valid on nested objects and collection elements.
  3. @Valid marks a parameter; @Validated enables method validation on a bean and selects groups.
  4. Two exceptions: MethodArgumentNotValidException is already 400; ConstraintViolationException is a 500 until you handle it.
  5. Custom validators treat null as valid, so each constraint tests one thing.
  6. 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.