“How do you validate incoming requests in Spring Boot?” is a beginner question with a surprising number of traps. Many candidates know @NotNull and @Valid, but interviewers then ask: what happens if the validation starter is missing, how are nested objects validated, what error does the client see, and where does “email must be unique” belong? A request that skips validation silently is worse than one that fails loudly, and the traps above are exactly how that happens.
Before you start
You should know how @RestController methods receive JSON with @RequestBody, and how path variables and query parameters bind with @PathVariable and @RequestParam. Examples target Spring Boot 3.x (Spring Framework 6.1+) with jakarta.validation annotations from spring-boot-starter-validation.
The short answer
Add spring-boot-starter-validation, declare Jakarta constraints such as @NotBlank, @Email, @Positive and @Size on request DTO fields, and annotate the controller parameter with @Valid. Invalid bodies make Spring throw MethodArgumentNotValidException, which Boot turns into a 400; map it to a ProblemDetail that lists field errors. Nested objects and list elements need @Valid too. Since Spring 6.1, constraints directly on @PathVariable and @RequestParam parameters are validated by built-in method validation. Validation checks request shape; rules that need the database belong in the service.
How it works
Request DTOs carry constraints:
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<@Valid OrderLineRequest> lines,
@Valid @NotNull Address shippingAddress,
@Size(max = 500) String note) { }
public record OrderLineRequest(
@NotBlank String sku,
@Positive @Max(100) int quantity) { }
public record Address(
@NotBlank String line1,
@NotBlank @Pattern(regexp = "[A-Z]{2}") String country) { }The controller triggers validation with @Valid:
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) { this.orders = orders; }
@PostMapping
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
OrderResponse created = orders.create(request);
return ResponseEntity.created(URI.create("/orders/" + created.id())).body(created);
}
@GetMapping("/{id}")
OrderResponse get(@PathVariable @Positive long id) {
return orders.find(id);
}
}After Jackson deserializes the body, Spring MVC’s argument resolver sees @Valid and calls the Validator that Boot auto-configured (Hibernate Validator). Violations are collected into a BindingResult, and the resolver throws MethodArgumentNotValidException before your method runs. Validation cascades into shippingAddress and into each element of lines only because of the @Valid markers there.
For @Positive long id, Spring 6.1 detects constraint annotations on method parameters and applies method validation automatically, throwing HandlerMethodValidationException (also a 400). On earlier versions you needed @Validated on the controller class, which produced ConstraintViolationException and a 500 unless you mapped it.
Step-by-step walkthrough
Step 1: Add the starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Since Spring Boot 2.3 it is not included in spring-boot-starter-web. Without it, the jakarta.validation annotations may still compile (if the API jar arrives transitively) but no validator exists, and nothing is checked.
Step 2: Choose the right constraints
@NotNull rejects null only. @NotEmpty also rejects empty strings and collections. @NotBlank also rejects whitespace-only strings. Use primitives (int) carefully: a missing JSON field becomes 0, which @NotNull cannot detect, so prefer Integer with @NotNull when “missing” must be an error, or rely on @Positive as above.
Step 3: Shape the error response
Boot’s default 400 body is generic. Extend ResponseEntityExceptionHandler (see exception advice) or enable spring.mvc.problemdetails.enabled=true, then add the field errors:
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody();
pd.setDetail("Request has invalid fields");
pd.setProperty("errors", ex.getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage())))
.toList());
return ResponseEntity.badRequest().body(pd);
}A request with an empty lines list and a bad country produces:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request has invalid fields",
"instance": "/orders",
"errors": [
{ "field": "lines", "message": "must not be empty" },
{ "field": "shippingAddress.country", "message": "must match \"[A-Z]{2}\"" }
]
}The order of errors is not guaranteed, so clients should not rely on it.
Step 4: Keep business rules in the service
“Customer exists”, “SKU is in stock” and “email not already registered” need the database and can change between validation and save. Check them in the service inside the transaction and throw domain exceptions mapped to 404 or 409. Use database constraints as the final guarantee for uniqueness.
Worked scenario
A team adds @NotBlank and @Email to a signup DTO, writes a controller with @RequestBody SignupRequest request, and ships. A month later, analytics show accounts with empty emails.
Two mistakes stacked up. The parameter lacked @Valid, so constraints were decorative. When a reviewer added @Valid, nested @NotBlank fields in request.profile() still were not checked, because the profile field lacked its own @Valid. And in one service the validation starter was missing altogether because the dependency had been removed during a “trim unused dependencies” pass.
The fix adds @Valid at both levels, restores the starter, and adds a WebMvc test per DTO that sends an invalid payload and asserts a 400 with the expected field names. The empty-email accounts are cleaned up with a migration, and a NOT NULL plus CHECK (email <> '') constraint is added so the database rejects the case even if validation regresses.
Common mistake
- Forgetting
@Validon the controller parameter. Constraints are metadata; something must trigger them. - Forgetting
@Validon nested fields and collection elements. Validation does not cascade by default. - Missing
spring-boot-starter-validation. No validator, no validation, no error. - Validating in the controller with
ifstatements that duplicate constraints and drift. - Putting uniqueness checks in a custom constraint that queries the database. It races with concurrent requests and couples validation to persistence.
- Returning 500 for validation of path variables on older versions because
ConstraintViolationExceptionwas not mapped.
Verify the behavior
@WebMvcTest(OrderController.class)
class CreateOrderValidationTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orders;
@Test
void rejectsEmptyLinesAndBadCountry() throws Exception {
String body = """
{"customerId":"c-1","lines":[],
"shippingAddress":{"line1":"1 Main St","country":"usa"}}
""";
mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[*].field",
containsInAnyOrder("lines", "shippingAddress.country")));
verifyNoInteractions(orders); // the controller method never ran
}
}The errors assertion relies on the advice from Step 3, which @WebMvcTest picks up automatically. The verifyNoInteractions line proves validation stopped the request before any business code.
Follow-up questions
What is the difference between @Valid and @Validated? @Valid is the Jakarta standard and triggers cascading. Spring’s @Validated additionally supports validation groups and, on a class, enables method validation for Spring beans.
How do you write a custom constraint? Create an annotation with @Constraint(validatedBy = MyValidator.class) and implement ConstraintValidator<MyAnnotation, T>. Keep it pure: no database calls.
How do you validate the same DTO differently for create and update? Use validation groups with @Validated(OnCreate.class), or better, separate DTOs per operation. See validation groups.
How do you validate service method arguments? Annotate the service class with @Validated and its parameters with constraints; Spring applies method validation through a proxy.
Interview exercise
Design validation for PUT /users/{id}/password with a body containing currentPassword and newPassword. The new password must be 12 to 128 characters and differ from the current one, and the current one must be correct.
Answer and reasoning
Validate {id} with @Positive on the path variable. On the DTO, use @NotBlank on currentPassword and @NotBlank @Size(min = 12, max = 128) on newPassword. “Must differ” compares two fields, which a class-level custom constraint (for example @PasswordsDiffer) can check without any external state, so it can live in validation. “Current password is correct” requires the stored hash, so it belongs in the service, which throws a domain exception mapped to 400 or 403 with a deliberately vague message. Never echo password values in error messages or logs. The reasoning: structural and self-contained rules go in constraints; anything that needs stored data goes in the service.