@Valid and How It Works

Turning Validation On

Annotations alone do nothing. Put @Valid on the @RequestBody parameter. Spring then validates the object before your method runs; if anything fails it throws MethodArgumentNotValidException and returns 400 Bad Request.

import jakarta.validation.Valid;

@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
    // only reached when request is valid
    ...
}

Example

Without a custom handler the default error body does not say which field was wrong:

curl -i -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"","price":-5,"stock":10,"category":"Electronics"}'

Example output

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"timestamp":"2026-10-12T10:20:11.402+00:00","status":400,"error":"Bad Request","path":"/api/products"}

Tip: The topic "Validation Error Response" below shows how to return the field errors to the client.

Under the Hood

Example

Bean Validation reads annotations with reflection, checks each value and collects every violation (it does not stop at the first one). Here is a tiny version of that idea in plain Java, with our own annotations:

import java.lang.annotation.*;
import java.lang.reflect.RecordComponent;
import java.math.BigDecimal;
import java.util.*;

public class Main {
    static Map<String, String> validate(Record obj) throws Exception {
        Map<String, String> errors = new LinkedHashMap<>();
        for (RecordComponent rc : obj.getClass().getRecordComponents()) {
            Object value = rc.getAccessor().invoke(obj);
            String field = rc.getName();
            NotBlank nb = rc.getAnnotation(NotBlank.class);
            if (nb != null && (value == null || value.toString().isBlank())) errors.put(field, nb.message());
            Size size = rc.getAnnotation(Size.class);
            if (size != null && value != null && value.toString().length() > size.max())
                errors.put(field, "size must be between 0 and " + size.max());
            Positive pos = rc.getAnnotation(Positive.class);
            if (pos != null && value != null && ((BigDecimal) value).signum() <= 0)
                errors.put(field, "must be greater than 0");
            Min min = rc.getAnnotation(Min.class);
            if (min != null && value != null && ((Integer) value) < min.value())
                errors.put(field, "must be greater than or equal to " + min.value());
        }
        return errors;
    }

    public static void main(String[] args) throws Exception {
        var good = new ProductRequest("Wireless Mouse", "Electronics", new BigDecimal("799.00"), 120);
        var bad = new ProductRequest("  ", "Electronics and Home Appliances", new BigDecimal("-5"), -1);
        System.out.println("good -> " + validate(good));
        System.out.println("bad  -> " + validate(bad));
    }
}

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT)
@interface NotBlank { String message() default "must not be blank"; }

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT)
@interface Size { int max(); }

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT)
@interface Positive {}

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT)
@interface Min { int value(); }

record ProductRequest(@NotBlank String name, @Size(max = 20) String category,
                      @Positive BigDecimal price, @Min(0) int stock) {}

Output

good -> {}
bad  -> {name=must not be blank, category=size must be between 0 and 20, price=must be greater than 0, stock=must be greater than or equal to 0}

Validating Path Variables and Query Parameters

Constraints can also go directly on simple parameters. Since Spring Boot 3.2, Spring MVC validates them itself and answers 400 (HandlerMethodValidationException) when they fail:

@GetMapping("/{id}")
public ProductResponse get(@PathVariable @Positive Long id) { ... }

@GetMapping
public List<ProductResponse> list(@RequestParam(defaultValue = "20") @Max(100) int size) { ... }

Note: Nested objects are validated only when the field is marked @Valid too, e.g. @Valid List<OrderItemRequest> items inside an order request.