Command Palette

Search for a command to run...

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

The product API from the previous articles reads a JSON body into a CreateProductRequest record and stores it. Nothing on that path checks the data. A client can send a blank name, a price of 0, a stock of -3 or a SKU in the wrong format; Jackson builds the record anyway, the controller saves it, and the API answers 201 Created. The bad data turns up later, in a report that does not add up or an order that cannot be shipped.

Bean Validation, the Jakarta Validation standard implemented by Hibernate Validator, puts those rules on the DTO as annotations, and Spring MVC enforces them before the controller method runs. This article adds it to the product API and spends most of its time on the parts that are usually skipped: which values each built-in constraint really accepts, what the default 400 contains and where the details went, when nested objects are silently not validated, why a constraint on a @PathVariable can end in a 500, and how groups, messages, custom constraints and validation outside the web layer behave.

A JSON body passing through an @Valid gate into a CreateProductRequest where one field fails and the request is answered with 400

Everything below was produced on OpenJDK 21.0.6 with Spring Boot 4.1.1 (Spring Framework 7.0.9, Hibernate Validator 9.1.3.Final, Jakarta Validation 3.1.1, Tomcat 11.0.24) and Gradle 9.7.1, in a project generated by Spring Initializr with dependencies=web,validation. The application ran with --server.port=8119, so the requests below use that port. Every response, log line and error message is copied from those runs; log lines have their timestamp prefix trimmed.

Adding spring-boot-starter-validation

Validation is not part of the web starter. It comes from spring-boot-starter-validation, the dependency Spring Initializr calls validation, and a project generated with it also gets a matching test starter:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Running ./gradlew dependencies --configuration runtimeClasspath and keeping only the starter's own subtree:

Text
+--- org.springframework.boot:spring-boot-starter-validation -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1
|    \--- org.springframework.boot:spring-boot-validation:4.1.1
|         +--- org.springframework.boot:spring-boot:4.1.1 (*)
|         +--- org.apache.tomcat.embed:tomcat-embed-el:11.0.24
|         \--- org.hibernate.validator:hibernate-validator:9.1.3.Final
|              +--- jakarta.validation:jakarta.validation-api:3.1.1
|              +--- org.jboss.logging:jboss-logging:3.6.3.Final
|              \--- com.fasterxml:classmate:1.7.1 -> 1.7.3
JarWhat it provides
jakarta.validation-api 3.1.1the standard API: @NotNull, @Valid, ConstraintValidator, Validator
hibernate-validator 9.1.3.Finalthe implementation that actually evaluates constraints
tomcat-embed-el 11.0.24a Jakarta Expression Language implementation, used for ${…} expressions in messages
spring-boot-validation 4.1.1ValidationAutoConfiguration, which defines the defaultValidator and methodValidationPostProcessor beans

defaultValidator is a LocalValidatorFactoryBean wrapping Hibernate Validator, and in this application it is the only jakarta.validation.Validator bean. methodValidationPostProcessor matters once @Validated appears on a class, later in the article.

Validating a request body with @Valid

Constraints are annotations on the components of the request record. The first version of CreateProductRequest has four rules:

src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
 
public record CreateProductRequest(
        @NotBlank String name,
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku,
        @NotNull @DecimalMin("0.01") BigDecimal price,
        @NotNull @PositiveOrZero Integer stock) {
}

@NotBlank rejects a missing or whitespace-only name. @NotNull makes sku, price and stock required, @Pattern fixes the SKU format, @DecimalMin sets a floor for the price and @PositiveOrZero rules out negative stock. The next section goes through each of them.

The rest of the API is a domain record, a response record and an in-memory store, since databases arrive in Chapter 4:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String name, String sku, BigDecimal price, Integer stock) {
}
src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, Integer stock) {
 
    static ProductResponse from(Product product) {
        return new ProductResponse(product.id(), product.name(), product.sku(), product.price(), product.stock());
    }
}
src/main/java/com/example/demo/product/ProductStore.java
package com.example.demo.product;
 
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
 
import org.springframework.stereotype.Component;
 
@Component
public class ProductStore {
 
    private final Map<Long, Product> products = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong(1);
 
    public Product save(CreateProductRequest request) {
        long id = nextId.getAndIncrement();
        Product product = new Product(id, request.name(), request.sku(), request.price(), request.stock());
        products.put(id, product);
        return product;
    }
 
    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(products.get(id));
    }
 
    public List<Product> findAll(int page, int size) {
        return products.values().stream().skip((long) page * size).limit(size).toList();
    }
}

The annotations on their own change nothing. They take effect when a controller parameter asks for validation with @Valid, from jakarta.validation:

src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
 
    public ProductController(ProductStore store) {
        this.store = store;
    }
 
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = store.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(ProductResponse.from(product));
    }
}

A valid product first:

Bash
curl -i -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25}'
Text
HTTP/1.1 201 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 06:59:17 GMT
 
{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25}

Then a body that breaks all four rules:

Bash
curl -i -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"  ","sku":"kbd-1001","price":0,"stock":-3}'
Text
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 06:59:17 GMT
Connection: close
 
{"timestamp":"2026-09-12T06:59:17.579Z","status":400,"error":"Bad Request","path":"/api/products"}

Three things happened, and the client sees only one of them.

  • The request was rejected with 400 before the controller method ran. RequestResponseBodyMethodProcessor, the Spring MVC component that resolves @RequestBody arguments, has Jackson build the record, validates it because the parameter carries @Valid, and throws MethodArgumentNotValidException while it is still resolving the argument. store.save is never called.
  • The body is Spring Boot's generic error response. DefaultHandlerExceptionResolver maps the exception to 400, and Boot's error handling writes timestamp, status, error and path. Nothing in it says which field failed.
  • The details went to the log. The resolver writes one WARN line:
Text
WARN 34552 --- [demo] [nio-8119-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.example.demo.product.ProductResponse> com.example.demo.product.ProductController.create(com.example.demo.product.CreateProductRequest) with 4 errors: [Field error in object 'createProductRequest' on field 'name': rejected value [  ]; codes [NotBlank.createProductRequest.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.name,name]; arguments []; default message [name]]; default message [must not be blank]] [Field error in object 'createProductRequest' on field 'price': rejected value [0]; codes [DecimalMin.createProductRequest.price,DecimalMin.price,DecimalMin.java.math.BigDecimal,DecimalMin]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.price,price]; arguments []; default message [price],true,0.01]; default message [must be greater than or equal to 0.01]] [Field error in object 'createProductRequest' on field 'stock': rejected value [-3]; codes [PositiveOrZero.createProductRequest.stock,PositiveOrZero.stock,PositiveOrZero.java.lang.Integer,PositiveOrZero]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.stock,stock]; arguments []; default message [stock]]; default message [must be greater than or equal to 0]] [Field error in object 'createProductRequest' on field 'sku': rejected value [kbd-1001]; codes [Pattern.createProductRequest.sku,Pattern.sku,Pattern.java.lang.String,Pattern]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.sku,sku]; arguments []; default message [sku],[Ljakarta.validation.constraints.Pattern$Flag;@53c55407,^[A-Z]{3}-\d{4}$]; default message [must match "^[A-Z]{3}-\d{4}$"]] ]

It logs even though spring.mvc.log-resolved-exception defaults to false: the property's description in the 4.1.1 metadata says it covers every resolver except DefaultHandlerExceptionResolver. What the line contains:

  • with 4 errors: every constraint was evaluated. Validation does not stop at the first failure.
  • Field error in object 'createProductRequest' on field 'name': the object name is the parameter type with a lower-case first letter, and each error names its field.
  • rejected value [ ] and default message [must not be blank]: the value that was sent and the constraint's message.
  • codes [NotBlank.createProductRequest.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]: message codes, from most to least specific, that a Spring MessageSource can use to look up a message.
  • The order: name, price, stock, sku. Errors do not come in declaration order.

Turning these field errors into a response body the client can read is the job of the next article, which also answers a body that fails validation with 422 instead of 400, following the status-code design from the HTTP and REST fundamentals article. Until then, lines this long are hard to follow, so from here on each WARN line is shortened to the field, the rejected value and the message of every error. An empty JSON object, for example:

Bash
curl -s -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{}'
Text
{"timestamp":"2026-09-12T06:59:17.590Z","status":400,"error":"Bad Request","path":"/api/products"}
Text
MethodArgumentNotValidException, 4 errors
  stock  [null]  must not be null
  name   [null]  must not be blank
  sku    [null]  must not be null
  price  [null]  must not be null

sku carries both @NotNull and @Pattern, yet only @NotNull reported. The section on null below explains why.

What happens without the validation starter?

It depends on what is left on the classpath.

With only spring-boot-starter-webmvc, the project does not compile, because the web starter does not bring the Jakarta Validation API at all (path shortened):

Text
src/main/java/com/example/demo/product/ProductController.java:3: error: package jakarta.validation does not exist
import jakarta.validation.Valid;
                         ^

The same error follows for every jakarta.validation.constraints import in CreateProductRequest.

The dangerous case is the API without an implementation: jakarta.validation:jakarta.validation-api declared on its own, or pulled in by a library. Everything compiles and starts, and the invalid request from above is stored:

Text
HTTP/1.1 201 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:00:34 GMT
 
{"id":2,"name":"  ","sku":"kbd-1001","price":0,"stock":-3}

The only trace is an INFO line at startup:

Text
INFO 34909 --- [demo] [           main] o.s.v.b.OptionalValidatorFactoryBean     : Failed to set up a Bean Validation provider: jakarta.validation.NoProviderFoundException: Unable to create a Configuration, because no Jakarta Validation provider could be found. Add a provider like Hibernate Validator (RI) to your classpath.

Without a provider, @Valid is silently ignored. @ConfigurationProperties is stricter: the same classpath stops a @Validated properties class at startup, as the article on configuration properties showed. When @Valid seems to do nothing, search the startup log for Failed to set up a Bean Validation provider.

Built-in constraint annotations

The standard constraints live in jakarta.validation.constraints. To cover each kind, the request record gains three components and two more constraints (imports for the new annotations and java.time.LocalDate omitted):

src/main/java/com/example/demo/product/CreateProductRequest.java
public record CreateProductRequest(
        @NotBlank String name, 
        @NotBlank @Size(min = 3, max = 100) String name, 
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku,
        @NotNull @DecimalMin("0.01") BigDecimal price, 
        @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2) BigDecimal price, 
        @NotNull @PositiveOrZero Integer stock) { 
        @NotNull @PositiveOrZero Integer stock, 
        @Min(1) @Max(10) Integer maxPerOrder, 
        @PastOrPresent LocalDate releasedOn, 
        @Future LocalDate saleEndsOn) { 
}

Every result in the tables of this section came from the application's Validator bean, checking one value at a time with validateValue, which runs the constraints of a single property without building an object:

Java
Set<ConstraintViolation<CreateProductRequest>> violations =
        validator.validateValue(CreateProductRequest.class, "sku", "kbd-1001");

Rows whose constraint is not on CreateProductRequest came from small probe records declared the same way. Injecting the Validator into your own code is covered near the end of the article.

@NotNull vs @NotEmpty vs @NotBlank

Three constraints mean "required" in different ways. A probe record puts one on each component:

Java
record Blank(@NotNull String notNull, @NotEmpty String notEmpty, @NotBlank String notBlank) {}
Value@NotNull@NotEmpty@NotBlank
nullmust not be nullmust not be emptymust not be blank
""validmust not be emptymust not be blank
" "validvalidmust not be blank
a tab and a newlinevalidvalidmust not be blank
"a"validvalidvalid
  • @NotNull only rejects null. An empty string and a string of spaces both pass, so on a String it rarely says what you mean.
  • @NotEmpty also rejects "", but a string of spaces passes.
  • @NotBlank needs at least one non-whitespace character; tabs and newlines count as whitespace. It is the right choice for text a person types: names, titles, codes.

Use @NotBlank for strings and @NotNull for everything else that is required: BigDecimal price, Integer stock, a nested object.

Size and number constraints

price carries three constraints, @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2):

priceResult
1290000valid
0.01valid
0.00must be greater than or equal to 0.01
-5must be greater than or equal to 0.01
0.009must be greater than or equal to 0.01 and numeric value out of bounds (<9 digits>.<2 digits> expected)
1290000.50valid
1290000.505numeric value out of bounds (<9 digits>.<2 digits> expected)
1290000.500numeric value out of bounds (<9 digits>.<2 digits> expected)
1234567890numeric value out of bounds (<9 digits>.<2 digits> expected)
nullmust not be null

The other size and number constraints:

ConstraintValueResult
@PositiveOrZero Integer stock0valid
-1must be greater than or equal to 0
@Positive BigDecimal0must be greater than 0
0.001valid
@Min(1) @Max(10) Integer maxPerOrder0must be greater than or equal to 1
1 and 10valid
11must be less than or equal to 10
@Min(1) BigDecimal0.99must be greater than or equal to 1
@DecimalMin(value = "0.00", inclusive = false) BigDecimal0.00must be greater than 0.00
0.01valid
@Size(min = 3, max = 100) String name"ab"size must be between 3 and 100
" x "valid
@Size(max = 2) List<String>three elementssize must be between 0 and 2

What the tables show:

  • @Size counts characters and does not trim. " x " has four characters and passes min = 3. Pair it with @NotBlank when whitespace should not count.
  • @DecimalMin and @DecimalMax take the bound as a string and are inclusive by default. inclusive = false changes both the check and the message. @Min and @Max take a long, so they cannot express 0.01, though @Min(1) does work on a BigDecimal.
  • @Digits counts the digits of the BigDecimal as written, trailing zeros included. 1290000.500 has three fraction digits, and JSON keeps them: "price":1290000.500 in a request body returned 400, while "price":1290000.50 returned 201.
  • One value can break several constraints at once. 0.009 produced two violations, one from each annotation.
  • Required numbers need a wrapper type. Integer stock can be null, so @NotNull has something to catch; an int never is.

Which addresses does @Email accept?

Value@Email
user@example.comvalid
a@bvalid
user@localhostvalid
user@example.cvalid
user@127.0.0.1valid
user@[127.0.0.1]valid
nguyễn@example.vnvalid
a.@b.commust be a well-formed email address
user@@example.commust be a well-formed email address
user@example..commust be a well-formed email address
user name@example.commust be a well-formed email address
user@-example.commust be a well-formed email address
""valid
nullvalid

Hibernate Validator checks the syntax of an address, not whether it could receive mail. A domain without a dot, a one-letter top-level domain, an IP address and non-ASCII letters in the local part are all well-formed. What fails is broken syntax: a dot at the end of the local part, a second @, two dots in a row, a space, a domain label starting with -.

Two consequences follow. @Email accepts both null and "", so a required address needs @NotBlank @Email. And if every address must have a dot in its domain, the constraint's own regexp attribute adds that rule:

Java
@NotBlank @Email(regexp = ".+@.+\\..+") String contactEmail

With that attribute, a@b and user@localhost were rejected with the same must be a well-formed email address, while user@example.com still passed.

@Pattern and regex escaping in Java

sku uses @Pattern(regexp = "^[A-Z]{3}-\\d{4}$"). The backslash is doubled because the regex sits inside a Java string literal: \\d in the source is \d in the pattern, and that is what the message prints.

skuResult
KBD-1001valid
kbd-1001must match "^[A-Z]{3}-\d{4}$"
KBD-100must match "^[A-Z]{3}-\d{4}$"
KBD1001must match "^[A-Z]{3}-\d{4}$"
" KBD-1001"must match "^[A-Z]{3}-\d{4}$"
KBD-1001 followed by a newlinemust match "^[A-Z]{3}-\d{4}$"
""must match "^[A-Z]{3}-\d{4}$"
nullmust not be null, from @NotNull
  • The whole value has to match. A leading space fails, and so does a trailing newline. Nothing is trimmed and a partial match is not enough, so the ^ and $ anchors are optional: @Pattern(regexp = "[A-Z]{3}-\\d{4}") rejected xKBD-1001 and KBD-1001x just the same.
  • An empty string is checked and rejected, null is not checked at all. That is why sku also needs @NotNull.
  • The default message prints the raw regex, which means nothing to an API client. The messages section replaces it.

Date constraints: @Past, @PastOrPresent and @Future

Checked on 2026-09-12:

LocalDate@Past@PastOrPresent@Future
2026-09-11validvalidmust be a future date
2026-09-12, todaymust be a past datevalidmust be a future date
2026-09-13must be a past datemust be a date in the past or in the presentvalid

For a LocalDate, "present" is today's date: @Past rejects today and @PastOrPresent accepts it. releasedOn uses @PastOrPresent, so a product released today is valid, and saleEndsOn uses @Future, so a sale ending today is not.

Over HTTP, with Jackson parsing the dates, the new constraints report together:

Bash
curl -s -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"ab","sku":"KBD-1002","price":1290000.505,"stock":10,"maxPerOrder":0,"releasedOn":"2027-01-01","saleEndsOn":"2026-01-01"}'
Text
{"timestamp":"2026-09-12T07:02:09.436Z","status":400,"error":"Bad Request","path":"/api/products"}
Text
MethodArgumentNotValidException, 5 errors
  price        [1290000.505]  numeric value out of bounds (<9 digits>.<2 digits> expected)
  name         [ab]           size must be between 3 and 100
  releasedOn   [2027-01-01]   must be a date in the past or in the present
  saleEndsOn   [2026-01-01]   must be a future date
  maxPerOrder  [0]            must be greater than or equal to 1

Does null pass validation?

Yes, for every built-in constraint except @NotNull, @NotEmpty and @NotBlank. This probe record puts fifteen constraints on fifteen components, and validates an instance where all of them are null:

Java
record AllNull(@Size(min = 3) String size, @Min(1) Integer min, @Max(10) Integer max,
               @Positive Integer positive, @PositiveOrZero Integer positiveOrZero,
               @DecimalMin("0.01") BigDecimal decimalMin, @Digits(integer = 9, fraction = 2) BigDecimal digits,
               @Email String email, @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String pattern,
               @Past LocalDate past, @PastOrPresent LocalDate pastOrPresent, @Future LocalDate future,
               @NotNull String notNull, @NotEmpty String notEmpty, @NotBlank String notBlank) {}
Text
notBlank: must not be blank
notEmpty: must not be empty
notNull: must not be null

Twelve of fifteen constraints passed. Each built-in constraint checks one property of a value, and whether a value must be present is a separate decision. A required field therefore always carries two annotations: @NotNull @Pattern(...), @NotBlank @Email, @NotNull @DecimalMin(...). The custom constraint later in the article follows the same convention.

Validating nested objects and lists

A catalogue product also has tags, a supplier with a contact address, and variants with their own stock. Two new records hold the nested data:

src/main/java/com/example/demo/product/SupplierRequest.java
package com.example.demo.product;
 
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
 
public record SupplierRequest(
        @NotBlank String name,
        @NotNull @Email String contactEmail) {
}
src/main/java/com/example/demo/product/VariantRequest.java
package com.example.demo.product;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
 
public record VariantRequest(
        @NotBlank String label,
        @NotNull @PositiveOrZero Integer stock) {
}

CreateProductRequest gets three components, deliberately without @Valid for now:

src/main/java/com/example/demo/product/CreateProductRequest.java
public record CreateProductRequest(
        @NotBlank @Size(min = 3, max = 100) String name,
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku,
        @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2) BigDecimal price,
        @NotNull @PositiveOrZero Integer stock,
        @Min(1) @Max(10) Integer maxPerOrder,
        @PastOrPresent LocalDate releasedOn,
        @Future LocalDate saleEndsOn) { 
        @Future LocalDate saleEndsOn, 
        @Size(max = 5) List<@NotBlank String> tags, 
        SupplierRequest supplier, 
        List<VariantRequest> variants) { 
}

This request has valid tags, a supplier with a blank name and a broken address, and a second variant with a blank label and negative stock. -w prints the status after the body:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25,"tags":["keyboard","wireless"],"supplier":{"name":"","contactEmail":"sales@@keychron.example"},"variants":[{"label":"Black","stock":10},{"label":"","stock":-2}]}'
Text
{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25} 201

Created, with no log line. SupplierRequest and VariantRequest have constraints, but Hibernate Validator never evaluated them: it validates the object it is given and follows a reference into another object only when that reference is marked @Valid. Mark both:

src/main/java/com/example/demo/product/CreateProductRequest.java
        @Size(max = 5) List<@NotBlank String> tags,
        SupplierRequest supplier, 
        @Valid SupplierRequest supplier, 
        List<VariantRequest> variants) { 
        List<@Valid VariantRequest> variants) { 
}

The same request now fails:

Text
{"timestamp":"2026-09-12T07:06:16.736Z","status":400,"error":"Bad Request","path":"/api/products"} 400
Text
MethodArgumentNotValidException, 4 errors
  variants[1].stock      [-2]                       must be greater than or equal to 0
  supplier.name          []                         must not be blank
  variants[1].label      []                         must not be blank
  supplier.contactEmail  [sales@@keychron.example]  must be a well-formed email address

The field names are now property paths: supplier.contactEmail for a component of the nested object, variants[1].label for a component of the list element at index 1.

The same CreateProductRequest and body validated without and with @Valid: own components and tags are checked either way, supplier and variants are skipped without @Valid and produce four field errors with it

@Valid only validates a nested object that exists. A request with no supplier and no variants returned 201, because a null reference has nothing to cascade into. When the nested object is required, put @NotNull next to @Valid.

Constraints on the elements of a list

tags behaved the same in both versions. @Size(max = 5) applies to the list itself, and @NotBlank inside the type argument, List<@NotBlank String>, is a container element constraint: it applies to every element. Both constrain the component's own value, so neither needs @Valid. Six tags, one of them blank:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25,"tags":["keyboard","  ","wireless","rgb","mechanical","usb-c"]}'
Text
{"timestamp":"2026-09-12T07:06:16.712Z","status":400,"error":"Bad Request","path":"/api/products"} 400
Text
MethodArgumentNotValidException, 2 errors
  tags[1]  [  ]                                            must not be blank
  tags     [[keyboard,   , wireless, rgb, mechanical, usb-c]]  size must be between 0 and 5

A null element is caught as well: "tags":["keyboard",null] produced the single error tags[1] [null] must not be blank.

Validating a list of objects

For a list of objects, @Valid goes on the type argument, as in List<@Valid VariantRequest>. The older form, @Valid List<VariantRequest>, still cascades in Hibernate Validator 9.1.3: a probe record declaring one list each way reported [0].label and [0].stock errors for both. But the older form logs a deprecation warning when the class is first validated, ending with the name of the affected element:

Text
HV000271: Using `@Valid` on a container (java.util.List) is deprecated. You should apply the annotation on the type argument(s).

Validating @PathVariable and @RequestParam

A path variable or a query parameter is a single value, not an object with constraints of its own, so the constraints go directly on the method parameters. Two read endpoints for the controller (the new imports are jakarta.validation.constraints.Max, Positive and PositiveOrZero):

src/main/java/com/example/demo/product/ProductController.java
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> get(@PathVariable @Positive Long id) {
        return store.findById(id)
                .map(ProductResponse::from)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
 
    @GetMapping
    public List<ProductResponse> list(@RequestParam(defaultValue = "0") @PositiveOrZero int page,
                                      @RequestParam(defaultValue = "20") @Max(100) int size) {
        return store.findAll(page, size).stream().map(ProductResponse::from).toList();
    }

The class still has no @Validated. An invalid id:

Bash
curl -i localhost:8119/api/products/-1
Text
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:02:09 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:02:09.323Z","status":400,"error":"Bad Request","path":"/api/products/-1"}

Two invalid query parameters, then a valid page after one product has been created:

Bash
curl -s -w ' %{http_code}\n' 'localhost:8119/api/products?page=-1&size=500'
Text
{"timestamp":"2026-09-12T07:02:09.361Z","status":400,"error":"Bad Request","path":"/api/products"} 400
Bash
curl -s -w ' %{http_code}\n' 'localhost:8119/api/products?size=100'
Text
[{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25}] 200

This is the method validation built into Spring MVC since Spring Framework 6.1. When a parameter of a controller method carries a constraint annotation, Spring MVC validates the arguments itself before invoking the method and reports a failure as HandlerMethodValidationException. The response looks exactly like the @RequestBody case.

The log does not. It stays empty, because HandlerMethodValidationException extends ResponseStatusException, so it is resolved by ResponseStatusExceptionResolver, and that resolver logs only when spring.mvc.log-resolved-exception is enabled. Running with --spring.mvc.log-resolved-exception=true shows the line for the invalid id:

Text
WARN 38131 --- [demo] [nio-8119-exec-1] .w.s.m.a.ResponseStatusExceptionResolver : Resolved [org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST "Validation failure"]

Even then the line does not say which parameter failed or why. That information is inside the exception object, which is where an exception handler reads it.

What changes with @Validated on the controller?

Many examples put @Validated on the controller class, because before Spring Framework 6.1 that was the way to validate @PathVariable and @RequestParam. Adding it to the same controller:

src/main/java/com/example/demo/product/ProductController.java
@Validated
@RestController
@RequestMapping("/api/products")
public class ProductController {

The same invalid id:

Bash
curl -i localhost:8119/api/products/-1
Text
HTTP/1.1 500 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:06:18 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:06:18.913Z","status":500,"error":"Internal Server Error","path":"/api/products/-1"}

and an ERROR in the log, followed by a full stack trace:

Text
ERROR 38245 --- [demo] [nio-8119-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: jakarta.validation.ConstraintViolationException: get.id: must be greater than 0] with root cause
 
jakarta.validation.ConstraintViolationException: get.id: must be greater than 0

?size=500 failed the same way, with list.size: must be less than or equal to 100.

The status changed because the validation moved. Boot's methodValidationPostProcessor wraps every bean annotated @Validated in a proxy that validates method arguments before each call; the service layer section shows that proxy class. For a controller with @Validated, Spring MVC leaves parameter validation to that proxy instead of doing it itself. The proxy throws jakarta.validation.ConstraintViolationException, none of Spring MVC's default exception resolvers handles it, and the servlet container reports it as a server error. The message format is method.parameter: message, as in get.id: must be greater than 0.

The request body is not affected, because the argument resolver validates it before the proxy is ever called:

RequestNo @Validated on the class@Validated on the class
GET /api/products/-1400, HandlerMethodValidationException, nothing logged500, ConstraintViolationException: get.id: must be greater than 0, ERROR log
GET /api/products?size=500400, HandlerMethodValidationException, nothing logged500, ConstraintViolationException: list.size: must be less than or equal to 100, ERROR log
POST /api/products with an invalid body400, MethodArgumentNotValidException, WARN log400, MethodArgumentNotValidException, WARN log

⚠️ With Spring Boot 4, do not put @Validated on a @RestController. It turns every invalid path variable and query parameter from a 400 into a 500 with a stack trace in the log. Constraint annotations on the parameters are enough on their own.

Three lanes: @Valid on a request body ends in MethodArgumentNotValidException and 400 with a WARN log, constraints on path and query parameters end in HandlerMethodValidationException and 400 with no log, and the same parameters on a @Validated controller go through an AOP proxy to ConstraintViolationException and 500

A constrained @PathVariable next to @Valid @RequestBody

Method validation has one more effect. PUT /api/products/{id} replaces a product, and its path variable has a constraint as well as its body. The store gets a matching replace method:

src/main/java/com/example/demo/product/ProductController.java
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> replace(@PathVariable @Positive Long id,
                                                   @Valid @RequestBody CreateProductRequest request) {
        return store.replace(id, request)
                .map(ProductResponse::from)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
src/main/java/com/example/demo/product/ProductStore.java
    public Optional<Product> replace(Long id, CreateProductRequest request) {
        if (!products.containsKey(id)) {
            return Optional.empty();
        }
        Product product = new Product(id, request.name(), request.sku(), request.price(), request.stock());
        products.put(id, product);
        return Optional.of(product);
    }

A valid id with an invalid body, on the controller without @Validated:

Bash
curl -s -w ' %{http_code}\n' -X PUT localhost:8119/api/products/1 -H 'Content-Type: application/json' -d '{"name":"  ","sku":"KBD-1001","price":0,"stock":25}'
Text
{"timestamp":"2026-09-12T07:02:09.459Z","status":400,"error":"Bad Request","path":"/api/products/1"} 400

No WARN line appeared, although the body was invalid. With --spring.mvc.log-resolved-exception=true, the log shows why:

Text
WARN 38131 --- [demo] [nio-8119-exec-3] .w.s.m.a.ResponseStatusExceptionResolver : Resolved [org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST "Validation failure"]

Once any parameter of a method has a constraint, Spring MVC validates all arguments of that method in one pass, the @Valid @RequestBody included, and reports the result as a single HandlerMethodValidationException. The status is still 400, but the exception type differs from POST /api/products, and body errors vanish from the default log. An exception handler for validation errors has to cover both types.

For completeness, with @Validated on the class the same PUT with an invalid body went back to MethodArgumentNotValidException (Validation failed for argument [1]), and PUT /api/products/-1 with a valid body became a 500 with replace.id: must be greater than 0.

@Valid vs @Validated and validation groups

Both annotations trigger validation, but they come from different places and do different jobs:

@Valid@Validated
Packagejakarta.validationorg.springframework.validation.annotation
Defined bythe Jakarta Validation specificationSpring
On a @RequestBody parametervalidates the Default group, MethodArgumentNotValidExceptionthe same without attributes, or only the listed groups
Groupsnot supported@Validated(OnCreate.class)
Cascading into a nested field or a type argumentyes; its targets are methods, fields, constructors, parameters and type usesno; its targets are types, methods and parameters only
On a classnot allowedturns on proxy-based method validation for that bean

A plain @Validated @RequestBody CreateProductRequest request produced the same MethodArgumentNotValidException as @Valid. The difference is the value attribute, which selects validation groups.

Validation groups with @Validated(OnCreate.class)

Creating a product needs a name, a SKU and a price. A partial update with PATCH accepts any subset of fields, but whatever it does send must still be valid. Groups let one record express both. A group is just a marker interface:

src/main/java/com/example/demo/product/OnCreate.java
package com.example.demo.product;
 
public interface OnCreate {
}
src/main/java/com/example/demo/product/OnUpdate.java
package com.example.demo.product;
 
public interface OnUpdate {
}

Each constraint lists the groups it belongs to:

src/main/java/com/example/demo/product/ProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
 
public record ProductRequest(
        @NotBlank(groups = OnCreate.class)
        @Size(min = 3, max = 100, groups = {OnCreate.class, OnUpdate.class})
        String name,
 
        @NotNull(groups = OnCreate.class)
        @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", groups = {OnCreate.class, OnUpdate.class})
        String sku,
 
        @NotNull(groups = OnCreate.class)
        @DecimalMin(value = "0.01", groups = {OnCreate.class, OnUpdate.class})
        BigDecimal price,
 
        @PositiveOrZero
        Integer stock) {
}

and each endpoint picks a group with @Validated:

src/main/java/com/example/demo/product/ProductController.java
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Validated(OnCreate.class) @RequestBody ProductRequest request) {
        Product product = store.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(ProductResponse.from(product));
    }
 
    @PatchMapping("/{id}")
    public ResponseEntity<ProductResponse> update(@PathVariable Long id,
                                                  @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
        return store.update(id, request)
                .map(ProductResponse::from)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

store.save and store.update are overloads for ProductRequest; update copies the non-null fields onto the stored product. The results, request by request:

RequestBodyResult
POST /api/products{"name":" ","price":0,"stock":-5}400: name size must be between 3 and 100, sku must not be null, name must not be blank, price must be greater than or equal to 0.01
PATCH /api/products/1{"price":0}400: price must be greater than or equal to 0.01
PATCH /api/products/1{"sku":"KBD-2002"}200
PATCH /api/products/1{"name":" "}400: name size must be between 3 and 100 only

That is exactly what groups are for. @NotBlank(groups = OnCreate.class) runs on create only, so the update accepts a request without a name, while @Size(..., groups = {OnCreate.class, OnUpdate.class}) checks a name on both.

Now look at stock. Its @PositiveOrZero has no groups, so it belongs to the Default group, and @Validated(OnCreate.class) validates OnCreate and nothing else. The first POST above sent "stock":-5 and no error mentioned it. Sent with otherwise valid fields, it is simply stored:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":-5}'
Text
{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":-5} 201

PATCH /api/products/1 with {"stock":-7} returned 200 and stored -7 as well. A constraint without groups is silently dropped as soon as an endpoint names a group. The fix is to make each group include Default:

src/main/java/com/example/demo/product/OnCreate.java
package com.example.demo.product;
 
import jakarta.validation.groups.Default; 
 
public interface OnCreate { 
public interface OnCreate extends Default { 
}

With OnUpdate changed the same way, the POST with "stock":-5 returned 400 with stock [-5] must be greater than or equal to 0, and the PATCH with "stock":-7 returned 400 with the same message.

When is a separate DTO simpler than groups?

Most of the time. Groups tie two operations to one class, every constraint must repeat its groups, a constraint that forgets them disappears without warning, and a reader has to work out which rules apply to which endpoint by reading every attribute. Two records, CreateProductRequest with @NotBlank and @NotNull where creation needs them and UpdateProductRequest with only the format constraints, say the same thing with no attributes at all, and each can gain fields the other does not have.

Groups earn their place when the shapes really are identical and only requiredness differs, or when one class is validated in several distinct steps, such as a multi-page form validated page by page. For a create and an update endpoint in a REST API, start with two DTOs.

Customising validation messages

The default messages are written for developers: must match "^[A-Z]{3}-\d{4}$" is accurate and useless to an API client. Every constraint has a message attribute, and it accepts three kinds of content. Back on CreateProductRequest:

src/main/java/com/example/demo/product/CreateProductRequest.java
public record CreateProductRequest(
        @NotBlank @Size(min = 3, max = 100) String name, 
        @NotBlank(message = "name is required") 
        @Size(min = 3, max = 100, message = "name must be between {min} and {max} characters") 
        String name, 
 
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku, 
        @NotNull
        @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "'${validatedValue}' is not a SKU like ABC-1234") 
        String sku, 
 
        @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2) BigDecimal price, 
        @NotNull
        @DecimalMin(value = "0.01", message = "{product.price.min}") 
        @Digits(integer = 9, fraction = 2) 
        BigDecimal price, 
 
        @NotNull @PositiveOrZero Integer stock,
        // maxPerOrder, releasedOn, saleEndsOn, tags, supplier and variants are unchanged

{product.price.min} is a key, so it needs a file at the root of the classpath, plus a Vietnamese version next to it:

src/main/resources/ValidationMessages.properties
product.price.min=price must be at least {value}
src/main/resources/ValidationMessages_vi.properties
product.price.min=giá phải từ {value} trở lên

A request that breaks four rules, without an Accept-Language header:

Bash
curl -s -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"ab","sku":"kbd-1","price":0,"stock":-1}'
Text
MethodArgumentNotValidException, 4 errors
  name   [ab]     name must be between 3 and 100 characters
  sku    [kbd-1]  'kbd-1' is not a SKU like ABC-1234
  stock  [-1]     must be greater than or equal to 0
  price  [0]      price must be at least 0.01
  • Literal text, as in name is required, is used as written.
  • {min} and {max} are attributes of the annotation, replaced with 3 and 100. {value} inside the properties file works the same way: it is @DecimalMin's value.
  • {product.price.min} is not an attribute, so it is resolved as a key from ValidationMessages.properties. The default messages are keys too: @PositiveOrZero's message is {jakarta.validation.constraints.PositiveOrZero.message}, which is how the table in the next section translates one.
  • ${validatedValue} is an Expression Language expression, evaluated with the EL implementation the starter brings. It inserts the rejected value into the message. Use it only in messages you write; never build a message template out of user input, because the template itself is evaluated.

Does Accept-Language change the messages?

The same request, sent with four different Accept-Language values:

Bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -H 'Accept-Language: de' -d '{"name":"ab","sku":"kbd-1","price":0,"stock":-1}'
Text
400
Accept-Languagestock, default messageprice, {product.price.min}
nonemust be greater than or equal to 0price must be at least 0.01
vimust be greater than or equal to 0giá phải từ 0.01 trở lên
demuss größer-gleich 0 seinprice must be at least 0.01
frdoit être supérieur ou égal à 0price must be at least 0.01

The name and sku messages were identical in all four runs, because a literal message and an EL expression have nothing to translate.

Messages follow the request's locale. Spring's LocalValidatorFactoryBean wraps the message interpolator in a LocaleContextMessageInterpolator, which uses the locale Spring MVC resolved for the current request, and Boot's default spring.web.locale-resolver is accept-header. For each message, Hibernate Validator looks for the key in your ValidationMessages files for that locale first, then in its own bundles.

Those bundles cover 27 locales in Hibernate Validator 9.1.3, including de and fr, but not Vietnamese. A request with Accept-Language: vi therefore got the Vietnamese message you wrote and English for every default. To translate a default message, add its key to your own file:

src/main/resources/ValidationMessages_vi.properties
jakarta.validation.constraints.PositiveOrZero.message=phải lớn hơn hoặc bằng 0

With that line, the vi request reported stock [-1] phải lớn hơn hoặc bằng 0.

A request without Accept-Language does not necessarily get English. It gets the JVM's default locale: the same jar, started with JAVA_TOOL_OPTIONS="-Duser.language=de -Duser.country=DE", answered that request with muss größer-gleich 0 sein. An application whose messages must not depend on the machine it runs on should set its locale explicitly.

How is ValidationMessages.properties encoded?

As UTF-8. The Vietnamese text in ValidationMessages_vi.properties above was saved as plain UTF-8, without \u escapes, and came out intact in the log. Putting the same line in the base ValidationMessages.properties, with no _vi file at all, gave giá phải từ 0.01 trở lên just as correctly, for every Accept-Language.

That is the opposite of application.properties, which Spring Boot decodes as ISO-8859-1 by default and where the same characters come out mangled. The difference is the loader: Hibernate Validator reads its message files through java.util.ResourceBundle, which has read properties files as UTF-8 since Java 9.

Writing a custom constraint

@Pattern checks the shape of a SKU. The catalogue needs one more rule: the three letters must be a category that exists. The categories are application data, so the rule belongs in a Spring bean, which in Chapter 4 would be a repository:

src/main/java/com/example/demo/product/CategoryRegistry.java
package com.example.demo.product;
 
import java.util.Set;
 
import org.springframework.stereotype.Component;
 
@Component
public class CategoryRegistry {
 
    private final Set<String> codes = Set.of("KBD", "MSE", "MON");
 
    public boolean exists(String code) {
        return codes.contains(code);
    }
}

A custom constraint has two parts. The annotation:

src/main/java/com/example/demo/product/ValidSku.java
package com.example.demo.product;
 
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
 
@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
 
    String message() default "{com.example.demo.product.ValidSku.message}";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}

and the validator:

src/main/java/com/example/demo/product/SkuValidator.java
package com.example.demo.product;
 
import java.util.regex.Pattern;
 
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
 
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
 
    private static final Pattern FORMAT = Pattern.compile("^[A-Z]{3}-\\d{4}$");
 
    private final CategoryRegistry categories;
 
    public SkuValidator(CategoryRegistry categories) {
        this.categories = categories;
    }
 
    @Override
    public boolean isValid(String sku, ConstraintValidatorContext context) {
        if (sku == null) {
            return true;
        }
        return FORMAT.matcher(sku).matches() && categories.exists(sku.substring(0, 3));
    }
}

Each piece has a job:

  • @Constraint(validatedBy = SkuValidator.class) makes @ValidSku a constraint and names the class that checks it.
  • message, groups and payload are required on every constraint annotation. An annotation that left out payload failed as soon as it was used, with jakarta.validation.ConstraintDefinitionException: HV000074: com.example.demo.probe.NoPayload contains Constraint annotation, but does not contain a payload parameter. The message default is a key, in the same format as the built-in messages.
  • @Target says where the annotation may appear: FIELD covers record components, PARAMETER a controller or service parameter, TYPE_USE a type argument such as List<@ValidSku String>.
  • ConstraintValidator<ValidSku, String> ties the validator to the annotation and to the type it validates.
  • return true for null follows the convention of the built-in constraints. Requiredness stays with @NotNull.
  • The constructor takes a Spring bean. Spring's LocalValidatorFactoryBean creates validators through a SpringConstraintValidatorFactory, which builds them with the application context's bean factory, so constructor injection works like in any other component.

The annotation replaces @Pattern on sku, and the message key goes into both properties files:

src/main/java/com/example/demo/product/CreateProductRequest.java
        @NotNull
        @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "'${validatedValue}' is not a SKU like ABC-1234") 
        String sku, 
        @NotNull @ValidSku String sku, 
src/main/resources/ValidationMessages.properties
com.example.demo.product.ValidSku.message=must be a SKU like ABC-1234 with a known category code
src/main/resources/ValidationMessages_vi.properties
com.example.demo.product.ValidSku.message=phải là SKU dạng ABC-1234 với mã danh mục đã đăng ký

One product at a time, with everything but sku valid:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"ABC-1234","price":1290000,"stock":25}'
Text
{"timestamp":"2026-09-12T07:23:40.727Z","status":400,"error":"Bad Request","path":"/api/products"} 400
sku sentResult
KBD-1001201
ABC-1234400: must be a SKU like ABC-1234 with a known category code
kbd-1001400: must be a SKU like ABC-1234 with a known category code
no sku400: must not be null, from @NotNull alone
ABC-1234 with Accept-Language: vi400: phải là SKU dạng ABC-1234 với mã danh mục đã đăng ký

ABC-1234 has the right shape and an unknown category, so the injected CategoryRegistry did its work. In the WARN line, the first message code of that error is ValidSku.createProductRequest.sku: the annotation's simple name takes the place of NotBlank or Pattern.

How the pieces of @ValidSku connect: the annotation on the record component, @Constraint naming SkuValidator, the CategoryRegistry bean injected into its constructor, isValid returning false, the message key resolved from ValidationMessages.properties, and the message in the field error

A class-level constraint for minPrice and maxPrice

Some rules involve two fields. A product search takes a price range, and minPrice must not be greater than maxPrice. A field constraint only sees its own value, so this one goes on the class:

src/main/java/com/example/demo/product/PriceRange.java
package com.example.demo.product;
 
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
 
@Documented
@Constraint(validatedBy = PriceRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface PriceRange {
 
    String message() default "must be greater than or equal to minPrice";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}
src/main/java/com/example/demo/product/ProductSearchRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.PositiveOrZero;
 
@PriceRange
public record ProductSearchRequest(
        String query,
        @PositiveOrZero BigDecimal minPrice,
        @PositiveOrZero BigDecimal maxPrice) {
}

The validator receives the whole record. When the range is inverted, it replaces the default violation with one attached to maxPrice:

src/main/java/com/example/demo/product/PriceRangeValidator.java
package com.example.demo.product;
 
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
 
public class PriceRangeValidator implements ConstraintValidator<PriceRange, ProductSearchRequest> {
 
    @Override
    public boolean isValid(ProductSearchRequest request, ConstraintValidatorContext context) {
        if (request.minPrice() == null || request.maxPrice() == null
                || request.minPrice().compareTo(request.maxPrice()) <= 0) {
            return true;
        }
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                .addPropertyNode("maxPrice")
                .addConstraintViolation();
        return false;
    }
}

A search endpoint and store method to use it:

src/main/java/com/example/demo/product/ProductController.java
    @PostMapping("/search")
    public List<ProductResponse> search(@Valid @RequestBody ProductSearchRequest request) {
        return store.search(request).stream().map(ProductResponse::from).toList();
    }
src/main/java/com/example/demo/product/ProductStore.java
    public List<Product> search(ProductSearchRequest request) {
        return products.values().stream()
                .filter(p -> request.query() == null || p.name().toLowerCase().contains(request.query().toLowerCase()))
                .filter(p -> request.minPrice() == null || p.price().compareTo(request.minPrice()) >= 0)
                .filter(p -> request.maxPrice() == null || p.price().compareTo(request.maxPrice()) <= 0)
                .toList();
    }

An inverted range:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products/search -H 'Content-Type: application/json' -d '{"minPrice":2000000,"maxPrice":1000000}'
Text
{"timestamp":"2026-09-12T07:23:40.775Z","status":400,"error":"Bad Request","path":"/api/products/search"} 400

The error inside the WARN line, unshortened this time because its shape is the point:

Text
Field error in object 'productSearchRequest' on field 'maxPrice': rejected value [1000000]; codes [PriceRange.productSearchRequest.maxPrice,PriceRange.maxPrice,PriceRange.java.math.BigDecimal,PriceRange]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productSearchRequest.maxPrice,maxPrice]; arguments []; default message [maxPrice]]; default message [must be greater than or equal to minPrice]]

A class-level constraint, reported as a field error on maxPrice with the rejected value of that field. With {"minPrice":2000000,"maxPrice":-1}, both rules reported on the same field: must be greater than or equal to minPrice and must be greater than or equal to 0.

Both calls before return false matter:

  • Without addPropertyNode, a validator that simply returned false produced a global error instead: Error in object 'productSearchRequest': codes [PriceRange.productSearchRequest,PriceRange]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productSearchRequest]; arguments []; default message []]; default message [must be greater than or equal to minPrice]]. There is no field and no rejected value, so a client cannot tell which input to highlight.
  • Without disableDefaultConstraintViolation(), the default violation is reported alongside the custom one. A copy of the validator that only omitted that call returned two violations for the same inverted range, one with an empty property path and one on maxPrice.

Validation outside the web layer

The controller is not the only entry point. A product can also arrive from a CSV import, a message queue or a scheduled job, and those paths never pass through @Valid @RequestBody. @Validated on a service class applies the same constraints to its method calls:

src/main/java/com/example/demo/product/ProductService.java
package com.example.demo.product;
 
import jakarta.validation.Valid;
 
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
 
@Service
@Validated
public class ProductService {
 
    private final ProductStore store;
 
    public ProductService(ProductStore store) {
        this.store = store;
    }
 
    public Product create(@Valid CreateProductRequest request) {
        return store.save(request);
    }
}

Calling it from an ApplicationRunner with an invalid row:

Java
CreateProductRequest row = new CreateProductRequest(null, "ABC-1234", new BigDecimal("0"), 5,
        null, null, null, null, null, null);
try {
    productService.create(row);
} catch (ConstraintViolationException e) {
    System.out.println(e.getClass().getName());
    System.out.println(e.getMessage());
}
Text
jakarta.validation.ConstraintViolationException
create.request.price: price must be at least 0.01, create.request.sku: must be a SKU like ABC-1234 with a known category code, create.request.name: name is required

The call never reached store.save. The bean that was injected is not a plain ProductService: productService.getClass().getName() printed com.example.demo.product.ProductService$$SpringCGLIB$$0. Boot's methodValidationPostProcessor, a FilteredMethodValidationPostProcessor, created that proxy because of @Validated, and the proxy validates the arguments before delegating. The path in each message is method.parameter.property, and @Valid on the parameter is what makes it cascade into the record's components.

If the exception escapes a controller, the result is the same 500 as with a @Validated controller. A controller method that passed an unvalidated body to productService.create produced:

Text
ERROR 44585 --- [demo] [nio-8119-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: jakarta.validation.ConstraintViolationException: create.request.sku: must be a SKU like ABC-1234 with a known category code, create.request.name: name is required, create.request.price: price must be at least 0.01] with root cause

Validate at the edge of the application, where you can return a 400, and treat service-level validation as a safety net for the other entry points.

Calling the Validator programmatically

An import should not stop at the first bad row; it should skip the row and report it. For that, inject jakarta.validation.Validator, Boot's defaultValidator bean, and call it yourself:

src/main/java/com/example/demo/product/ProductImporter.java
package com.example.demo.product;
 
import java.util.Set;
 
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
 
@Component
public class ProductImporter {
 
    private static final Logger log = LoggerFactory.getLogger(ProductImporter.class);
 
    private final Validator validator;
    private final ProductService productService;
 
    public ProductImporter(Validator validator, ProductService productService) {
        this.validator = validator;
        this.productService = productService;
    }
 
    public boolean importRow(int line, CreateProductRequest row) {
        Set<ConstraintViolation<CreateProductRequest>> violations = validator.validate(row);
        if (!violations.isEmpty()) {
            violations.forEach(v -> log.warn("line {}, {}: {}", line, v.getPropertyPath(), v.getMessage()));
            return false;
        }
        productService.create(row);
        return true;
    }
}

Importing a valid row and then the invalid row from above:

Java
System.out.println("import line 1 -> " + importer.importRow(1, validRow));
System.out.println("import line 2 -> " + importer.importRow(2, row));
Text
import line 1 -> true
WARN 44585 --- [demo] [           main] c.example.demo.product.ProductImporter   : line 2, price: price must be at least 0.01
WARN 44585 --- [demo] [           main] c.example.demo.product.ProductImporter   : line 2, sku: must be a SKU like ABC-1234 with a known category code
WARN 44585 --- [demo] [           main] c.example.demo.product.ProductImporter   : line 2, name: name is required
import line 2 -> false

validate throws nothing; it returns a Set<ConstraintViolation>, and each violation carries its property path, message and rejected value. This is the same Validator Boot configured, with the same message files and the same Spring-aware validator factory, which matters for @ValidSku: a validator built outside Spring with Validation.buildDefaultValidatorFactory() could not create SkuValidator at all and failed with jakarta.validation.ValidationException: HV000064: Unable to instantiate ConstraintValidator: com.example.demo.product.SkuValidator., caused by java.lang.NoSuchMethodException: com.example.demo.product.SkuValidator.<init>().

Where validation runs and what it throws

Every row comes from the runs in this article:

Where the constraint sitsWhat triggers validationExceptionDefault result
@Valid or @Validated on a @RequestBody parameterSpring MVC, while resolving the argumentMethodArgumentNotValidException400, WARN log line with every field error
a constraint on a @PathVariable or @RequestParam, no @Validated on the classSpring MVC's built-in method validationHandlerMethodValidationException400, nothing logged
@Valid @RequestBody on a method that also has a constrained parameterthe same built-in method validation, for all argumentsHandlerMethodValidationException400, nothing logged
a constraint on a @PathVariable or @RequestParam, @Validated on the controllerthe AOP proxy from methodValidationPostProcessorConstraintViolationException500, ERROR log with stack trace
a @Valid parameter of a @Validated servicethe same AOP proxy, on every callConstraintViolationExceptionthe caller handles it; 500 if it escapes a controller
any object passed to validator.validate(…)your own codenone, a Set<ConstraintViolation> is returnedwhatever your code does
a @Validated @ConfigurationProperties classbinding at startupConfigurationPropertiesBindExceptionthe application fails to start
a JSON body that Jackson cannot readnothing: validation never runsHttpMessageNotReadableException400, WARN log line

The last row is the boundary of @Valid. A body with "price":"abc" produced this line and no validation error at all, because there was no object to validate:

Text
WARN 44585 --- [demo] [nio-8119-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String "abc": not a valid representation]

FAQ

Why is @Valid ignored in my Spring Boot controller?

The runs above found four causes. There is no Bean Validation provider on the classpath, which leaves a Failed to set up a Bean Validation provider INFO line at startup; add spring-boot-starter-validation. The constraints are on a nested object whose component lacks @Valid. The endpoint uses @Validated(SomeGroup.class) and the constraint has no groups, so it sits in the Default group. Or the value is null, which every constraint except @NotNull, @NotEmpty and @NotBlank accepts.

What is the difference between @Valid and @Validated?

@Valid is the Jakarta standard: it triggers validation of a parameter and cascades into nested objects and type arguments. @Validated is Spring's: on a parameter it does the same and can select validation groups, and on a class it turns on proxy-based method validation. Use @Valid for request bodies and nested fields, @Validated(Group.class) when you need groups, and @Validated on a class for services.

Why does validating a @PathVariable return 500 instead of 400?

Because the controller class is annotated @Validated. That makes Spring validate through an AOP proxy, which throws ConstraintViolationException, and Spring MVC maps that to 500. Remove @Validated from the controller and keep the constraints on the parameters: Spring MVC's built-in method validation then answers 400 with HandlerMethodValidationException.

Should I use @NotNull, @NotEmpty or @NotBlank for a String?

Almost always @NotBlank. @NotNull accepts "" and " ", and @NotEmpty accepts " ". Keep @NotNull for non-text types such as BigDecimal, Integer and nested objects.

Can a ConstraintValidator use Spring beans?

Yes, through its constructor, when the validator is created by Spring Boot's Validator: @Valid in a controller, @Validated on a bean, or an injected jakarta.validation.Validator. SkuValidator above receives CategoryRegistry that way. A factory built with Validation.buildDefaultValidatorFactory() knows nothing about Spring and fails with HV000064: Unable to instantiate ConstraintValidator.

How do I return the validation errors in the response body?

By default the 400 body has only timestamp, status, error and path, and the field errors stay inside the exception. Handling MethodArgumentNotValidException and HandlerMethodValidationException in one place and turning them into a structured body is the subject of the next article, which maps body validation failures to 422.

Conclusion

Bean Validation in Spring Boot 4.1.1 starts with spring-boot-starter-validation, which brings Hibernate Validator 9.1.3; with only the API on the classpath, @Valid is silently ignored. Constraints on a request record plus @Valid on the @RequestBody give a 400 through MethodArgumentNotValidException, with the field errors in a WARN log line rather than in the response. The built-in constraints are narrower than their names suggest: null passes all of them except the @NotNull family, @NotBlank is the only one that rejects whitespace, @Email accepts a@b, and @Digits counts trailing zeros. Nested objects and list elements are validated only through @Valid, while container element constraints such as List<@NotBlank String> need nothing extra.

Constraints on @PathVariable and @RequestParam work without any class annotation and raise HandlerMethodValidationException, which is resolved silently; putting @Validated on the controller turns the same failures into 500s. Groups work, but a constraint without groups disappears as soon as an endpoint names one. Messages accept attributes, keys and EL, their files are UTF-8, and their language follows Accept-Language, falling back to the JVM locale. A custom constraint is an annotation plus a ConstraintValidator that can take Spring beans in its constructor, and a class-level constraint should attach its error to a field with addPropertyNode. Outside the web layer, @Validated services and the injected Validator apply the same rules.

What is still missing is a response the client can use: every failure in this article came back as the same four-field body. The next article centralises that: exception handling with @RestControllerAdvice and @ExceptionHandler, error responses in the ProblemDetail standard, and 422 for a body that fails validation, following the status-code design from the HTTP and REST fundamentals article.

Related Posts

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1, verified on a real project: SLF4J as the facade and Logback 1.5.38 as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.

[Spring Boot Basics] Setting Up Spring Boot: JDK, IDE, Spring Initializr and Your First Application

Install JDK 21 on macOS, Windows and Linux, fix a JAVA_HOME pointing at the wrong JDK, compare IntelliJ IDEA with VS Code, generate a Spring Boot 4.1.1 project from Spring Initializr or one curl command, run it with the Gradle wrapper, read the startup log line by line, write a @RestController that returns JSON, change server.port, and fix the five errors every beginner hits.

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi 3.1.1 on Spring Boot 4.1.1, checked on a running jar: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] Spring Framework vs Spring Boot: What Auto-Configuration, Starters and the Embedded Server Actually Do

Spring Framework 7.0.9 versus Spring Boot 4.1.1 on Java 21: what a plain Spring web application made you write, what starters and the spring-boot-dependencies BOM replace it with, why Boot 4 renamed the web starter to spring-boot-starter-webmvc, how auto-configuration backs off, embedded Tomcat against a WAR, and a map of the Spring ecosystem.