Validation & Exception Handling

Validating Request Bodies with @Valid

18 min Lesson 3 of 13

Validating Request Bodies with @Valid

You have learned how to annotate a model class with Bean Validation constraints. The next critical step is triggering that validation inside a Spring MVC controller. Without an explicit trigger, the constraints sit inert — Spring deserialises the JSON payload and hands you the object regardless of whether its fields are valid. This lesson covers how @Valid and @Validated tell Spring to run the validator before the method body executes, what happens when validation fails, and the patterns you should follow to keep your controllers clean.

Ensure the Starter Is Present

Before anything else: Spring Boot 3 does not include validation support in spring-boot-starter-web. You must add the dedicated starter explicitly, or @Valid is silently ignored and constraints never run.

<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

This pulls in Hibernate Validator, the reference implementation of Jakarta Validation 3.0. Once it is on the classpath, Spring auto-configures a LocalValidatorFactoryBean and wires it into the MVC layer automatically.

Omitting this starter is the most common "why isn't validation working?" pitfall. There is no compilation error, no startup warning — Spring simply skips constraint processing. Always verify the starter is present when debugging missing validation behaviour.

The Anatomy of a Validated Controller Method

Consider a user-registration endpoint. The request body is a RegisterRequest DTO decorated with Bean Validation constraints. Placing @Valid immediately before the @RequestBody parameter instructs Spring's argument resolver to pass the deserialised object through the validator before the method receives it.

package com.example.demo.web; import com.example.demo.dto.RegisterRequest; import com.example.demo.service.UserService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public UserResponse register(@Valid @RequestBody RegisterRequest request) { // This line only runs when ALL constraints on RegisterRequest pass return userService.register(request); } }

The DTO itself carries the constraint annotations:

package com.example.demo.dto; import jakarta.validation.constraints.*; public class RegisterRequest { @NotBlank(message = "Username is required") @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") private String username; @NotBlank(message = "Email is required") @Email(message = "Must be a valid email address") private String email; @NotNull @Size(min = 8, message = "Password must be at least 8 characters") private String password; // getters and setters (or use a Java record / Lombok) }
@Valid vs @Validated: @Valid comes from the Jakarta Validation spec (jakarta.validation.Valid) and triggers validation on the annotated object, including cascading into any nested objects also marked @Valid. @Validated is a Spring-specific annotation that adds support for validation groups (covered in Lesson 9). For plain request-body validation, @Valid is the standard, spec-compliant choice.

What Happens When Validation Fails

When at least one constraint is violated, Spring throws MethodArgumentNotValidException. By default this produces a 400 Bad Request response. The exception carries a BindingResult holding every individual field error. Your method body is never called — the exception is raised by the framework before control reaches your code.

Capturing BindingResult Manually

There is a second, less common pattern: declare a BindingResult parameter immediately after the @RequestBody parameter. When you do this, Spring suppresses the automatic exception and lets you inspect the errors yourself inside the method body.

@PostMapping public ResponseEntity<?> register( @Valid @RequestBody RegisterRequest request, BindingResult bindingResult) { if (bindingResult.hasErrors()) { List<String> errors = bindingResult.getFieldErrors() .stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .toList(); return ResponseEntity.badRequest().body(Map.of("errors", errors)); } return ResponseEntity .status(HttpStatus.CREATED) .body(userService.register(request)); }
Avoid the BindingResult pattern in most REST APIs. It scatters error-handling logic into every controller method, making the codebase repetitive and harder to change. The preferred approach is a single @ControllerAdvice (Lesson 7) that catches MethodArgumentNotValidException globally. Only use BindingResult when you genuinely need per-endpoint logic that a global handler cannot express.

Validating Nested Objects

If your DTO contains a nested object, apply @Valid to the nested field. Without it, the constraints on the nested class are silently ignored.

public class OrderRequest { @NotNull @Valid // cascades validation into AddressRequest private AddressRequest shippingAddress; @NotEmpty private List<@Valid LineItem> items; // validates each element in the list }

The @Valid annotation on a collection type parameter (List<@Valid LineItem>) uses container element validation introduced in Bean Validation 2.0 and fully supported in Spring Boot 3. Hibernate Validator validates every element in the list and collects all violations before throwing.

Validating Path Variables and Query Parameters

Bean Validation is not limited to request bodies. You can apply constraint annotations directly to method parameters for path variables and request parameters. For this to work, add @Validated at the class level — this tells Spring to create a proxy around the controller that intercepts method calls and checks parameter-level constraints.

import org.springframework.validation.annotation.Validated; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @RestController @RequestMapping("/api/products") @Validated // class-level annotation required here public class ProductController { @GetMapping("/{id}") public ProductResponse getById( @PathVariable @Min(1) long id) { return productService.findById(id); } @GetMapping public Page<ProductResponse> search( @RequestParam @NotBlank String query, @RequestParam(defaultValue = "0") @Min(0) int page) { return productService.search(query, page); } }

When a path variable or query parameter constraint is violated, Spring throws a ConstraintViolationException rather than MethodArgumentNotValidException. This distinction matters when writing a global exception handler: you need to handle both types to give clients consistent error responses across all input vectors.

Keep DTOs separate from entity classes. A common mistake is annotating a JPA entity with validation constraints and using it directly as a @RequestBody type. This tightly couples your API contract to your database schema. Define dedicated request DTOs — Java records work cleanly in Java 16+ — and map them to entities in the service layer. You retain full control over what fields are exposed, what constraints apply at the API boundary, and how errors are reported to clients.

Putting It Together: Request Flow

When a POST request arrives at /api/users the following sequence occurs:

  1. Deserialization: Jackson reads the JSON body and creates a RegisterRequest instance.
  2. Validation: Because @Valid is present, Spring invokes Hibernate Validator on that instance.
  3. Violation found? Spring throws MethodArgumentNotValidException; the method body is skipped.
  4. No violations: The validated object is passed to register(), which delegates to the service layer.

This pipeline keeps validation concerns entirely outside your business logic. Your service methods can trust that every RegisterRequest they receive already satisfies the declared constraints — no defensive null-checks or manual field inspection needed.

Summary

Triggering validation in a Spring controller requires three things: spring-boot-starter-validation on the classpath, constraint annotations on your DTO, and @Valid placed before the @RequestBody parameter. When a constraint fails, Spring raises MethodArgumentNotValidException before your method body runs. Nested objects require their own @Valid to cascade validation. For path variables and query parameters, use class-level @Validated alongside per-parameter constraints, and be aware that violations produce ConstraintViolationException instead. The next lessons focus on catching these exceptions and shaping the error responses your API clients actually receive.