Bean Validation with @Valid
Add the validation starter, annotate request DTOs with Jakarta constraints and put @Valid on @RequestBody so bad input is rejected with a 400.
record Dto(@NotBlank String name, @Email String email) { }
@PostMapping
ResponseEntity<Out> create(@Valid @RequestBody Dto dto)Jackson turns a JSON body into a record, but it only checks that the shape fits. A missing name, an empty email or a negative quantity all deserialise happily. Jakarta Bean Validation is the standard way to state the rules on the DTO itself and have Spring check them before your controller method runs.
Adding it
Validation is not part of spring-boot-starter-webmvc. Add spring-boot-starter-validation, which brings Hibernate Validator (9.1 in Spring Boot 4.1) and the jakarta.validation API. Without it the annotations still compile, because the API jar may be on the classpath through another dependency, but nothing enforces them. That is the most common reason people say validation "does nothing".
How a request is checked
- Jackson reads the body into the record.
- Spring sees
@Validon the parameter and validates the object. - Any violation raises
MethodArgumentNotValidException, which Spring MVC maps to400 Bad Request. Your method never runs.
record CreateUserRequest(@NotBlank String name, @NotBlank @Email String email) { }
@PostMapping
UserResponse create(@Valid @RequestBody CreateUserRequest request) { ... }Forget @Valid and the constraints are ignored for that endpoint, silently. Put it on every @RequestBody that carries constraints, and write a test that sends an invalid body, so a refactor cannot drop it unnoticed.
Nested objects and lists
Validation does not walk into nested objects unless you ask. Mark the field @Valid to cascade, and use List<@Valid LineItem> to validate each element of a collection. An order whose line items are never checked is a classic production bug: the order passes, the items carry a quantity of -3.
What belongs here and what does not
Bean Validation is for rules you can check from the request alone: required fields, lengths, ranges, formats. Rules that need the database, such as "this email is not already registered", belong in the service, backed by a unique constraint in the table. A validator that queries the database is racy: two requests can both pass the check and both insert.
@Valid comes from jakarta.validation. Spring's own @Validated does the same job on a parameter and also accepts validation groups; on a class it switches on method validation for that bean, which a later lesson covers.
Example
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.net.URI;
import java.util.Locale;
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;
record CreateUserRequest(
(max = 80) String name,
(max = 254) String email) {
}
record UserResponse(long id, String name, String email) {
}
("/api/users")
public class UserController {
public ResponseEntity<UserResponse> create( CreateUserRequest request) {
// Only reached when every constraint on the request passed.
var created = new UserResponse(42L, request.name(), request.email().toLowerCase(Locale.ROOT));
return ResponseEntity
.created(URI.create("/api/users/" + created.id()))
.body(created);
}
}
When to use it
- Reject a sign-up request with a blank name or a malformed email before any code touches the database.
- Check every line item of an order body, including quantity and SKU, in one pass.
- Guard a service that is called from both a controller and a Kafka listener with the same argument rules.
More examples
Adding the validation starter
The web starter does not include a validator. The validation starter adds Hibernate Validator and lets Spring Boot auto-configure a validator bean that Spring MVC picks up for @Valid parameters. The version comes from the Spring Boot parent, so leave it out.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Cascading into nested objects and list elements
@NotNull says the address must be present; @Valid on the same component says its own constraints must hold. You usually want both. List<@Valid LineItem> validates every element, and the error path comes back as items[2].quantity, so the client knows exactly which line is wrong. @Size(max = 50) on the list puts a cap on how much work one request can cause.
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
record Address(@NotBlank String line1, @NotBlank String city, @NotBlank @Size(max = 10) String postcode) {
}
record LineItem(@NotBlank String sku, @Positive int quantity) {
}
record CreateOrderRequest(
@NotNull @Valid Address deliveryAddress,
@NotEmpty @Size(max = 50) List<@Valid LineItem> items) {
}
@RestController
public class OrderController {
@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.CREATED)
public void create(@Valid @RequestBody CreateOrderRequest request) {
// deliveryAddress and every LineItem have been validated too.
}
}
Validating service method arguments
@Validated on a bean class makes Spring wrap it in a proxy that validates method arguments, so the rules hold for callers that are not controllers, such as message listeners and scheduled jobs. A violation throws jakarta.validation.ConstraintViolationException, which Spring MVC does not map to 400 on its own: left unhandled it becomes a 500. Setting spring.validation.method.adapt-constraint-violations=true (default false) makes the proxy throw Spring's MethodValidationException instead, with errors grouped per parameter, but that is still a 500 by default, so you need a handler either way. Calls from inside the same class bypass the proxy and are not checked.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@Service
@Validated
public class TransferService {
public String transfer(@NotBlank String fromAccount,
@NotBlank String toAccount,
@Positive BigDecimal amount) {
// A proxy checks the arguments before this line runs.
return "transfer of " + amount + " from " + fromAccount + " to " + toAccount;
}
}
Discussion