Skip to main content
Java · lesson 53 of 66
Open the Java page →
Java intermediate Lesson 53 of 66

Validation in Spring Boot

Validate Spring Boot 3 requests with Bean Validation: @Valid bodies, error JSON from @RestControllerAdvice, path params, nested objects, custom rules.

By SkillByExample · Updated

Bean Validation (Jakarta Validation, implemented by Hibernate Validator) lets you declare rules on request types with annotations and have Spring check them before your controller method runs. Add spring-boot-starter-validation next to spring-boot-starter-web. Every example is part of one Spring Boot 3.5 project, started with java -jar target/val-0.0.1-SNAPSHOT.jar, and every response is what curl received. Code blocks start at the type declaration; the imports are jakarta.validation.*, jakarta.validation.constraints.* and Spring’s org.springframework.web.bind.annotation.*.

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

Constraints and @Valid

public record CreateUserRequest(
        @NotBlank String name,
        @NotBlank @Email String email,
        @NotNull @Min(18) @Max(120) Integer age,
        @Size(min = 8, max = 64, message = "must be 8-64 characters") String password) {}

@RestController
@RequestMapping("/api/users")
public class UserController {

    record UserCreated(String name, String email) {}

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserCreated create(@Valid @RequestBody CreateUserRequest request) {   // @Valid triggers the checks
        return new UserCreated(request.name(), request.email());
    }
}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com","age":36,"password":"correct-horse"}'
{"name":"Ada","email":"ada@example.com"}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":" ","email":"not-an-email","age":12,"password":"short"}'
{"timestamp":"2026-10-07T22:08:35.216+00:00","status":400,"error":"Bad Request","path":"/api/users"}

The server log for the second request:

2026-10-07T22:08:35.207Z  WARN 4902 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.val.UserController$UserCreated com.example.val.UserController.create(com.example.val.CreateUserRequest) with 4 errors: [Field error in object 'createUserRequest' on field ...

@Valid made Spring run all four checks before create was called; all four failed, so the method never ran and the request got a 400. The default body, though, says nothing about which fields were wrong — the details only reached the server log. age is an Integer, not int, so that a missing value is null and @NotNull can report it; an int would silently default to 0.

Turning violations into a useful 400

@RestControllerAdvice                                   // applies to every controller
public class ValidationErrors {

    record ErrorBody(int status, Map<String, String> errors) {}

    @ExceptionHandler(MethodArgumentNotValidException.class)      // @Valid @RequestBody failed
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    ErrorBody body(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new TreeMap<>();               // sorted by field name
        ex.getBindingResult().getFieldErrors().forEach(e ->
                errors.merge(e.getField(), e.getDefaultMessage(), (a, b) -> a + "; " + b));
        ex.getBindingResult().getGlobalErrors().forEach(e ->        // class-level constraints
                errors.put(e.getObjectName(), e.getDefaultMessage()));
        return new ErrorBody(400, errors);
    }

    @ExceptionHandler(HandlerMethodValidationException.class)     // @PathVariable / @RequestParam failed
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    ErrorBody params(HandlerMethodValidationException ex) {
        Map<String, String> errors = new TreeMap<>();
        ex.getParameterValidationResults().forEach(r -> r.getResolvableErrors().forEach(e ->
                errors.put(r.getMethodParameter().getParameterName(), e.getDefaultMessage())));
        return new ErrorBody(400, errors);
    }
}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":" ","email":"not-an-email","age":12,"password":"short"}'
{"status":400,"errors":{"age":"must be greater than or equal to 18","email":"must be a well-formed email address","name":"must not be blank","password":"must be 8-64 characters"}}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"email":""}'
{"status":400,"errors":{"age":"must not be null","email":"must not be blank","name":"must not be blank"}}

A @RestControllerAdvice handles exceptions thrown from any controller. Every violation is reported at once, so a client can fix a whole form in one round trip; the TreeMap keeps fields in a stable order. Look at the second response: "" failed @NotBlank but not @Email, and the missing password produced no error at all. Every constraint except the null checks treats null (and @Email treats empty) as valid — @Size alone does not make a field required. The second handler is for the next section.

Path variables and query parameters

@RestController
@RequestMapping("/api/orders")
public class LookupController {

    record OrderPage(long customerId, int page, String status) {}

    @GetMapping("/{customerId}")
    public OrderPage orders(@PathVariable @Min(1) long customerId,
                            @RequestParam(defaultValue = "0") @Min(0) @Max(50) int page,
                            @RequestParam(defaultValue = "OPEN") @Pattern(regexp = "OPEN|SHIPPED") String status) {
        return new OrderPage(customerId, page, status);
    }
}
$ curl -s -w '\n' 'localhost:8080/api/orders/7?page=2&status=SHIPPED'
{"customerId":7,"page":2,"status":"SHIPPED"}
$ curl -s -w '\n' 'localhost:8080/api/orders/0?page=99&status=LOST'
{"status":400,"errors":{"customerId":"must be greater than or equal to 1","page":"must be less than or equal to 50","status":"must match \"OPEN|SHIPPED\""}}

Constraints directly on method parameters are checked by Spring MVC itself (since Spring Framework 6.1, Boot 3.2) — no @Validated on the class is needed. A failure throws a different exception, HandlerMethodValidationException, which is why the advice has a second handler; without it these requests would get the generic 400 body again.

Nested objects and lists

public record OrderRequest(
        @NotNull @Valid Address shipTo,                         // @Valid: check inside it too
        @NotEmpty List<@Valid Item> items) {

    public record Address(@NotBlank String street, @Pattern(regexp = "\\d{5}") String zip) {}

    public record Item(@NotBlank String sku, @Positive int quantity) {}
}

@RestController
@RequestMapping("/api/orders")                            // POST here; LookupController keeps GET /{customerId}
public class OrderController {

    record OrderAccepted(String zip, int lines) {}

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderAccepted place(@Valid @RequestBody OrderRequest order) {
        return new OrderAccepted(order.shipTo().zip(), order.items().size());
    }
}
$ curl -s -w '\n' localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"shipTo":{"street":"1 Main St","zip":"12345"},"items":[{"sku":"KB-1","quantity":2}]}'
{"zip":"12345","lines":1}
$ curl -s -w '\n' localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"shipTo":{"street":"","zip":"ABC"},"items":[{"sku":"KB-1","quantity":2},{"sku":"","quantity":0}]}'
{"status":400,"errors":{"items[1].quantity":"must be greater than 0","items[1].sku":"must not be blank","shipTo.street":"must not be blank","shipTo.zip":"must match \"\\d{5}\""}}
$ curl -s -w '\n' localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"items":[]}'
{"status":400,"errors":{"items":"must not be empty","shipTo":"must not be null"}}

Only the valid order reached place, which echoed its zip and line count; the other two never ran it. Validation does not descend into a nested object unless the field is marked @Valid — without it, shipTo would only be checked for null. List<@Valid Item> validates every element, and the error paths say exactly where: items[1].sku is the second item. The last request shows the outer constraints still apply when there is nothing to descend into.

A custom constraint

When no built-in annotation fits, write one: an annotation marked @Constraint, plus a validator. The next section’s SignupRequest uses it, and the output for both follows there.

@Documented
@Constraint(validatedBy = UsernameValidator.class)      // the class that does the check
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface Username {
    String message() default "must be 3-16 lowercase letters or digits and not reserved";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class UsernameValidator implements ConstraintValidator<Username, String> {

    private static final Set<String> RESERVED = Set.of("admin", "root", "support");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;                  // leave null to @NotNull
        return value.matches("[a-z0-9]{3,16}") && !RESERVED.contains(value);
    }
}

A cross-field constraint

A rule involving two fields goes on the class, so its validator receives the whole object.

@Constraint(validatedBy = PasswordsMatchValidator.class)
@Target(ElementType.TYPE)                                 // on the class, so it sees every field
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordsMatch {
    String message() default "passwords do not match";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, SignupRequest> {
    @Override
    public boolean isValid(SignupRequest r, ConstraintValidatorContext context) {
        return Objects.equals(r.password(), r.confirmPassword());
    }
}

@PasswordsMatch
public record SignupRequest(
        @NotNull @Username String username,
        @NotBlank String password,
        @NotBlank String confirmPassword) {}

@RestController
@RequestMapping("/api/signup")
public class SignupController {

    record Welcome(String username) {}

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Welcome signup(@Valid @RequestBody SignupRequest request) {
        return new Welcome(request.username());
    }
}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"ada99","password":"s3cret","confirmPassword":"s3cret"}'
{"username":"ada99"}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"admin","password":"s3cret","confirmPassword":"s3cret"}'
{"status":400,"errors":{"username":"must be 3-16 lowercase letters or digits and not reserved"}}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"Ada!","password":"s3cret","confirmPassword":"secret"}'
{"status":400,"errors":{"signupRequest":"passwords do not match","username":"must be 3-16 lowercase letters or digits and not reserved"}}

@Username is now as reusable as @Email. Its validator returns true for null so it composes with @NotNull instead of duplicating it. @PasswordsMatch failed as a global error on the object, not on a field — the advice’s getGlobalErrors() loop reported it under the object name signupRequest. Field and class constraints are all evaluated, so both problems arrived together.

Practice

  1. Add an optional phone to CreateUserRequest that, when present, must be 7-15 digits with an optional leading +. Show that omitting it is still accepted.
Solution
public record CreateUserRequest(
        @NotBlank String name,
        @NotBlank @Email String email,
        @NotNull @Min(18) @Max(120) Integer age,
        @Size(min = 8, max = 64, message = "must be 8-64 characters") String password,
        @Pattern(regexp = "\\+?[0-9]{7,15}", message = "must be 7-15 digits, optional leading +") String phone) {}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com","age":36,"password":"correct-horse","phone":"12-34"}'
{"status":400,"errors":{"phone":"must be 7-15 digits, optional leading +"}}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com","age":36,"password":"correct-horse"}'
{"name":"Ada","email":"ada@example.com"}

@Pattern treats null as valid, which is exactly “optional, but well-formed if given”.

  1. Tighten @Username so a username must start with a letter (9lives is rejected), and update its message.
Solution
// UsernameValidator.isValid
return value.matches("[a-z][a-z0-9]{2,15}") && !RESERVED.contains(value);

// Username
String message() default "must be 3-16 lowercase letters or digits, start with a letter, and not be reserved";
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"9lives","password":"s3cret","confirmPassword":"s3cret"}'
{"status":400,"errors":{"username":"must be 3-16 lowercase letters or digits, start with a letter, and not be reserved"}}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"ada99","password":"s3cret","confirmPassword":"s3cret"}'
{"username":"ada99"}
  1. Validation is not tied to HTTP. Write a plain JUnit test that validates new SignupRequest("9lives", "pw", "different") with a jakarta.validation.Validator and prints each violation (using the stricter rule from task 2).
Solution
package com.example.val;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;

import jakarta.validation.Validation;
import jakarta.validation.Validator;

import org.junit.jupiter.api.Test;

class SignupRequestTest {

    private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void reportsEveryBrokenRule() {
        var request = new SignupRequest("9lives", "pw", "different");
        List<String> problems = validator.validate(request).stream()
                .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                .sorted()
                .toList();
        problems.forEach(System.out::println);
        assertEquals(2, problems.size());
    }
}
$ mvn -B test 2>&1 | grep -E '^[a-z]*: |Tests run:|BUILD'
: passwords do not match
username: must be 3-16 lowercase letters or digits, start with a letter, and not be reserved
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.309 s -- in com.example.val.SignupRequestTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

The class-level violation has an empty property path — it belongs to the object, not a field. In a Spring bean you would inject the Validator instead of building one.

Frequently Asked Questions

What is the difference between @NotNull, @NotEmpty, and @NotBlank?
@NotNull rejects only null, so an empty string passes. @NotEmpty rejects null and empty strings or collections. @NotBlank, for strings only, also rejects whitespace such as a single space. For required text fields you almost always want @NotBlank, and @NotEmpty for required lists.
Where do I put @Valid in a Spring Boot controller?
Put @Valid on the controller parameter, as in create(@Valid @RequestBody CreateUserRequest request). Spring validates the object before the method runs and, if any constraint fails, throws MethodArgumentNotValidException, which becomes a 400 response. Without @Valid the annotations on the record are ignored.
How do I validate a path variable or query parameter in Spring Boot 3?
Put constraints such as @Min(1) directly on the @PathVariable or @RequestParam. Since Spring Framework 6.1 the controller validates them automatically and throws HandlerMethodValidationException on failure, so handle that exception alongside MethodArgumentNotValidException in your @RestControllerAdvice.
Why does a null field pass @Email or @Size?
By Bean Validation convention every constraint except the null checks treats null as valid, so that optionality is expressed separately. Combine @NotNull or @NotBlank with @Email when the field is required, and write custom validators the same way, returning true for null.
How do I validate that two fields match?
Create a class-level constraint: an annotation with @Target(ElementType.TYPE) and a ConstraintValidator that receives the whole object, then compares the two fields. Its violation is reported as a global error for the object rather than against one field, so read it from getGlobalErrors() in your exception handler.