HomeSpring BootSpring Boot Validation: @Valid, Errors, Custom Validators
Spring Boot

Spring Boot Validation: @Valid, Errors, Custom Validators

Spring Boot validation explained: @Valid vs @Validated, constraints, exception handling, custom validators, nested objects, groups and tests, with examples.

Spring Boot Validation in 30 Seconds

Spring Boot validates requests with Jakarta Bean Validation (the implementation is Hibernate Validator). The recipe has four steps: 1. Add the spring-boot-starter-validation dependency. 2. Put constraint annotations (@NotBlank, @Email, @Size, @Min and so on) on the fields of your request DTO. 3. Add @Valid to the @RequestBody parameter of the controller method. 4. Handle the validation exceptions in a @RestControllerAdvice and return a 400 response with the field errors. Which exception you get depends on what failed: - @Valid @RequestBody (or @ModelAttribute) fails: MethodArgumentNotValidException, status 400. - Constraint annotations directly on controller parameters (@PathVariable @Positive Long id), Spring Framework 6.1 / Spring Boot 3.2 or later, and no class-level @Validated: HandlerMethodValidationException, status 400. - Class-level @Validated on a controller or service: ConstraintViolationException, which is a 500 unless you handle it. - Malformed JSON, wrong types or unknown enum values: HttpMessageNotReadableException, status 400. Validation never runs because the body could not be read. Handle all of them, and return one consistent error body.

Setup and Core Annotations

Add spring-boot-starter-validation to bring in Hibernate Validator. Since Spring Boot 2.3 it is no longer part of spring-boot-starter-web, and without it no validation happens at all: the annotations compile but nothing enforces them. Annotate DTO fields with Jakarta constraint annotations (package jakarta.validation.constraints in Spring Boot 3; javax.validation.constraints in Spring Boot 2) and add @Valid to the @RequestBody parameter. Spring validates the object before the controller method runs and throws MethodArgumentNotValidException on failure.
XML
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Gradle: implementation 'org.springframework.boot:spring-boot-starter-validation' -->

Constraint Annotations Quick Reference

The most used built-in constraints, and the differences that cause bugs: Null and emptiness: - @NotNull: the value is not null. Accepts "" and " ". - @NotEmpty: not null and not empty (strings, collections, maps, arrays). Accepts " ". - @NotBlank: strings only; not null and contains at least one non-whitespace character. Use this for required text. - @Null: must be null. Size and numbers: - @Size(min, max): length of a string or size of a collection, map or array. Null is valid. - @Min, @Max: integer bounds. @DecimalMin, @DecimalMax: decimal bounds, given as strings ("0.01"), with inclusive = false for exclusive bounds. - @Positive, @PositiveOrZero, @Negative, @NegativeOrZero. - @Digits(integer, fraction): maximum digits, useful for money. Text: - @Pattern(regexp): the whole string must match the regular expression. Null is valid. - @Email: simple e-mail check. Hibernate Validator's default accepts addresses without a top-level domain (a@b), so add a stricter regexp if you need that. Dates: - @Past, @PastOrPresent, @Future, @FutureOrPresent (LocalDate, Instant, ZonedDateTime and so on). Other: @AssertTrue, @AssertFalse, and Hibernate-specific @Length, @Range, @URL (in org.hibernate.validator.constraints). Rule that explains many surprises: almost every constraint treats null as valid. A constraint such as @Size, @Email or @Pattern does not make a field required. Combine it with @NotNull or @NotBlank. Also, use wrapper types (Integer, Long, Boolean) for DTO fields. A primitive int can never be null, so a missing JSON field silently becomes 0 and @NotNull never fires.

Validating a Request Body

A record works well as a DTO, because it is immutable and the constraints sit directly on the components. The annotations propagate to the fields, which is where Hibernate Validator reads them. Do not log or serialize the request object blindly: a record's generated toString() includes every component, so a password field ends up in your logs.
Java
// ── Request DTO with constraints ──────────────────────────────────────
public record CreateUserRequest(

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "Email must be a valid address")
    String email,

    @NotNull(message = "Age is required")
    @Min(value = 18, message = "Age must be at least 18")
    @Max(value = 120, message = "Age must not exceed 120")
    Integer age,                       // wrapper type: a missing field is null, not 0

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(
        regexp = "^(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).+$",
        message = "Password must contain an uppercase letter, a digit, and a special character"
    )
    String password,

    @NotNull(message = "Role is required")
    UserRole role
) {}

// ── Controller — @Valid triggers validation ───────────────────────────
@PostMapping
public ResponseEntity<UserResponse> create(
        @RequestBody @Valid CreateUserRequest request,
        UriComponentsBuilder uriBuilder) {
    UserResponse created = userService.create(request);
    URI location = uriBuilder
        .path("/api/v1/users/{id}")
        .buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

Validation Error Response

A good validation response is complete (every violation, not just the first), machine-readable (field names that match the JSON), and safe (no stack traces, no rejected password values). The handler below covers the four exceptions you will meet in a REST API: - MethodArgumentNotValidException: @Valid on a body. It has field errors and, for class-level constraints, global errors. Global errors are easy to forget: a class-level constraint without a property node (like a date-range check) is a global error, and a handler that reads only getFieldErrors() returns an empty map for it. - HandlerMethodValidationException (Spring 6.1+): constraints directly on parameters. The exception holds one ParameterValidationResult per validated parameter. Use getParameterValidationResults() on Spring 6.2 and later (getAllValidationResults() on 6.1). - ConstraintViolationException: class-level @Validated, validation in the service layer, or a programmatic Validator call. Its property path looks like findById.id, so take the last node as the field name. - HttpMessageNotReadableException: malformed JSON, a wrong type, or an invalid enum value. Return a generic message; the parser's text can leak internals. Several violations on one field are possible (a blank password fails @NotBlank, @Size and @Pattern at once), so the map holds a list of messages per field. If you prefer a standard format, extend ResponseEntityExceptionHandler, which already handles these exceptions and produces RFC 9457 ProblemDetail bodies, and override handleMethodArgumentNotValid and handleHandlerMethodValidationException to add your field errors. Do not declare your own @ExceptionHandler for an exception that the base class already handles; Spring reports an ambiguous mapping.
Java
// ── Standardised error body ───────────────────────────────────────────
public record ValidationErrorResponse(
    int                       status,
    String                    error,
    String                    message,
    Instant                   timestamp,
    Map<String, List<String>> fieldErrors,
    List<String>              globalErrors
) {}

// ── Global handler ────────────────────────────────────────────────────
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // ── @Valid on @RequestBody / @ModelAttribute ───────────────────────
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleBodyValidation(
            MethodArgumentNotValidException ex) {

        Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fe ->
            fieldErrors.computeIfAbsent(fe.getField(), k -> new ArrayList<>())
                       .add(fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value"));

        // class-level constraints without a property node end up here:
        List<String> globalErrors = ex.getBindingResult().getGlobalErrors().stream()
            .map(ObjectError::getDefaultMessage)
            .filter(Objects::nonNull)
            .toList();

        return badRequest("One or more fields failed validation", fieldErrors, globalErrors);
    }

    // ── Constraints directly on parameters (Spring 6.1+ / Boot 3.2+) ───
    @ExceptionHandler(HandlerMethodValidationException.class)
    public ResponseEntity<ValidationErrorResponse> handleMethodValidation(
            HandlerMethodValidationException ex) {

        Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
        List<String> globalErrors = new ArrayList<>();

        for (ParameterValidationResult result : ex.getParameterValidationResults()) {  // 6.1: getAllValidationResults()
            String param = result.getMethodParameter().getParameterName();
            if (result instanceof ParameterErrors errors) {                            // a validated bean (@Valid)
                errors.getFieldErrors().forEach(fe ->
                    fieldErrors.computeIfAbsent(param + "." + fe.getField(), k -> new ArrayList<>())
                               .add(fe.getDefaultMessage()));
                errors.getGlobalErrors().forEach(ge -> globalErrors.add(ge.getDefaultMessage()));
            } else {                                                                   // a simple value (@PathVariable, @RequestParam)
                result.getResolvableErrors().forEach(e ->
                    fieldErrors.computeIfAbsent(param, k -> new ArrayList<>())
                               .add(e.getDefaultMessage()));
            }
        }
        return badRequest("Parameter validation failed", fieldErrors, globalErrors);
    }

    // ── Class-level @Validated, service-layer validation, Validator calls ──
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ValidationErrorResponse> handleConstraintViolation(
            ConstraintViolationException ex) {

        Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
        ex.getConstraintViolations().forEach(cv ->
            fieldErrors.computeIfAbsent(lastNode(cv.getPropertyPath()), k -> new ArrayList<>())
                       .add(cv.getMessage()));

        return badRequest("Parameter validation failed", fieldErrors, List.of());
    }

    // ── Malformed JSON, wrong types, unknown enum values ───────────────
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ValidationErrorResponse> handleUnreadable(HttpMessageNotReadableException ex) {
        log.debug("Unreadable request body", ex);           // details only in the log
        return badRequest("Malformed request body", Map.of(), List.of());
    }

    private ResponseEntity<ValidationErrorResponse> badRequest(
            String message, Map<String, List<String>> fieldErrors, List<String> globalErrors) {
        return ResponseEntity.badRequest().body(new ValidationErrorResponse(
            400, "Validation Failed", message, Instant.now(), fieldErrors, globalErrors));
    }

    private static String lastNode(Path path) {
        String name = path.toString();
        for (Path.Node node : path) {
            name = node.getName();                           // "findById.id" -> "id"
        }
        return name;
    }
}

// ── Example error response ────────────────────────────────────────────
// {
//   "status": 400,
//   "error": "Validation Failed",
//   "message": "One or more fields failed validation",
//   "timestamp": "2026-10-31T10:30:00Z",
//   "fieldErrors": {
//     "email": ["Email must be a valid address"],
//     "password": ["Password must be at least 8 characters",
//                  "Password must contain an uppercase letter, a digit, and a special character"]
//   },
//   "globalErrors": []
// }

Validating Path Variables and Query Parameters

@Valid alone validates complex objects (a @RequestBody, @ModelAttribute or @RequestPart). To validate simple values such as a @PathVariable or @RequestParam, put the constraint annotation on the parameter itself. How it is enforced depends on your Spring version: - Spring Framework 6.1 and later (Spring Boot 3.2+): Spring MVC has built-in method validation. Leave the class-level @Validated off the controller, and a violation raises HandlerMethodValidationException (400). This is the recommended setup, and the Spring reference documentation tells you to remove class-level @Validated to benefit from it. - Older versions, or when the controller class has @Validated: validation runs through an AOP proxy and a violation raises ConstraintViolationException, which is a 500 unless you handle it. Because either can occur depending on the method signature, handle both (the handler in the previous section does). If a method has constraints on simple parameters and also an @Valid body, Spring uses method validation for the whole method, so body errors can arrive as HandlerMethodValidationException instead of MethodArgumentNotValidException. One more variant: if you add a BindingResult or Errors parameter right after an @Valid argument, Spring does not throw for that argument, and you inspect the errors yourself.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
// No class-level @Validated on Spring 6.1+: built-in method validation is used
// (HandlerMethodValidationException). On older versions add @Validated here and
// handle ConstraintViolationException instead.
public class UserController {

    private final UserService userService;

    // ── Validate path variable ─────────────────────────────────────────
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(
            @PathVariable @Positive(message = "ID must be a positive number") Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    // ── Validate query parameters ──────────────────────────────────────
    @GetMapping
    public ResponseEntity<Page<UserResponse>> findAll(
            @RequestParam(required = false)
            @Size(max = 100, message = "Search term must not exceed 100 characters")
            String search,

            @RequestParam(required = false)
            @Min(value = 0, message = "Min age must not be negative") Integer minAge,

            @RequestParam(required = false)
            @Max(value = 120, message = "Max age must not exceed 120") Integer maxAge,

            @PageableDefault(size = 20) Pageable pageable) {
        return ResponseEntity.ok(userService.findAll(search, minAge, maxAge, pageable));
    }

    // ── Validate both body and path variable ──────────────────────────
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable @Positive Long id,
            @RequestBody @Valid UpdateUserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }

    // ── Handle errors yourself with BindingResult (no exception is thrown) ──
    @PostMapping("/preview")
    public ResponseEntity<?> preview(@RequestBody @Valid CreateUserRequest request,
                                     BindingResult errors) {
        if (errors.hasErrors()) {
            return ResponseEntity.unprocessableEntity().body(errors.getAllErrors());
        }
        return ResponseEntity.ok(userService.preview(request));
    }
}

Cross-Field and Class-Level Validation

Some constraints span multiple fields: confirming that two password fields match, or that a date range is valid. Implement these with a custom class-level constraint annotation and a ConstraintValidator that receives the whole object. Where the error appears matters. If you build the violation with addPropertyNode("confirmPassword"), it is reported as a field error on that field, which is what a form client wants. If you leave the default violation, it has no property and becomes a global (object-level) error, which your error handler must read from getGlobalErrors(). Each validator should return true for null values and leave null checks to @NotNull and @NotBlank; otherwise the same problem is reported twice.
Java
// ── Constraint annotation ─────────────────────────────────────────────
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
    String message() default "Passwords do not match";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// ── Validator ─────────────────────────────────────────────────────────
public class PasswordMatchValidator
        implements ConstraintValidator<PasswordMatch, ChangePasswordRequest> {

    @Override
    public boolean isValid(ChangePasswordRequest request,
                           ConstraintValidatorContext context) {
        if (request.password() == null || request.confirmPassword() == null) {
            return true;   // let @NotBlank handle null checks
        }
        boolean match = request.password().equals(request.confirmPassword());
        if (!match) {
            // Attach the violation to the confirmPassword field (a field error)
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("Passwords do not match")
                   .addPropertyNode("confirmPassword")
                   .addConstraintViolation();
        }
        return match;
    }
}

// ── DTO with class-level constraint ───────────────────────────────────
@PasswordMatch
public record ChangePasswordRequest(
    @NotBlank String currentPassword,
    @NotBlank @Size(min = 8) String password,
    @NotBlank String confirmPassword
) {}

// ── Date range example (no property node: reported as a GLOBAL error) ──
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
    String message() default "Start date must be before end date";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class DateRangeValidator
        implements ConstraintValidator<ValidDateRange, DateRangeRequest> {

    @Override
    public boolean isValid(DateRangeRequest req, ConstraintValidatorContext ctx) {
        if (req.startDate() == null || req.endDate() == null) return true;
        return !req.startDate().isAfter(req.endDate());
    }
}

@ValidDateRange
public record DateRangeRequest(
    @NotNull LocalDate startDate,
    @NotNull LocalDate endDate
) {}

// Response for an invalid range (read from getGlobalErrors()):
// "fieldErrors": {},
// "globalErrors": ["Start date must be before end date"]

Custom Field-Level Validators

Create a custom field-level constraint when built-in annotations are insufficient: validating a phone number format, checking that a value belongs to a known set, or verifying uniqueness against the database. Spring creates ConstraintValidator instances through its bean factory, so a validator can have repositories and services injected (constructor injection works) without being declared a @Component. A uniqueness check in a validator is a convenience, not a guarantee. Two concurrent requests can both pass the check and then both insert. Keep a unique constraint in the database as the real safeguard, and translate the DataIntegrityViolationException into a 409 Conflict. Also remember that the validator runs on updates too, so a user keeping their own e-mail would fail the check unless the validator knows which record is being updated; for that case do the check in the service layer instead. For phone numbers, a regular expression for E.164 is a reasonable first filter, but for real validation of regional formats use a library such as libphonenumber.
Java
// ── @PhoneNumber constraint ───────────────────────────────────────────
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface PhoneNumber {
    String message() default "Invalid phone number format";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PhoneNumberValidator
        implements ConstraintValidator<PhoneNumber, String> {

    // E.164: a plus sign, then 8 to 15 digits, not starting with 0
    private static final Pattern E164 =
        Pattern.compile("^\\+[1-9]\\d{7,14}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null) return true;   // @NotBlank handles null
        return E164.matcher(value).matches();
    }
}

// ── @UniqueEmail — database-backed constraint ─────────────────────────
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
    String message() default "Email address is already registered";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class UniqueEmailValidator
        implements ConstraintValidator<UniqueEmail, String> {

    private final UserRepository userRepo;

    // Constructor injection: Spring builds validators through its bean factory
    public UniqueEmailValidator(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    @Override
    public boolean isValid(String email, ConstraintValidatorContext ctx) {
        if (email == null) return true;
        return !userRepo.existsByEmailIgnoreCase(email);
    }
}

// ── The database is the real guarantee: translate the race-condition failure ──
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
        .body(ErrorResponse.of(409, "Conflict", "Resource already exists"));
}

// ── Apply on DTO ──────────────────────────────────────────────────────
public record RegisterUserRequest(
    @NotBlank @Size(min = 2, max = 100) String name,

    @NotBlank @Email @UniqueEmail
    String email,

    @NotBlank @PhoneNumber
    String phone
) {}

Nested Object and Collection Validation

Validation cascades into nested objects and collection elements only when @Valid is present on the field. Without it, the nested object's constraints are silently skipped. Apply @Valid on every nested DTO field and on collection fields to validate each element. The container-element form List<@Valid OrderItemRequest> is equivalent, and it also lets you constrain the elements themselves, for example List<@NotBlank String> tags. The error paths include the nesting, with indexes for list elements, which makes it easy for a client to attach each message to the right input.
Java
// ── Nested DTO ────────────────────────────────────────────────────────
public record AddressRequest(
    @NotBlank(message = "Street is required") String street,
    @NotBlank(message = "City is required")   String city,
    @NotBlank(message = "Country is required") String country,

    @Pattern(regexp = "^[A-Z]{2}$",
             message = "Country code must be a 2-letter ISO code")
    String countryCode,

    @NotBlank @Size(min = 3, max = 10)
    String postalCode
) {}

// ── Parent DTO with nested object and collection ───────────────────────
public record CreateOrderRequest(

    @NotNull @Positive
    Long customerId,

    @Valid                                   // ← cascades into AddressRequest
    @NotNull(message = "Shipping address is required")
    AddressRequest shippingAddress,

    @Valid                                   // ← validates each OrderItemRequest
    @NotEmpty(message = "Order must contain at least one item")
    @Size(max = 50, message = "Order cannot exceed 50 items")
    List<OrderItemRequest> items,

    // Equivalent container-element style, also constraining each element:
    // List<@Valid OrderItemRequest> items
    // List<@NotBlank String> couponCodes

    @NotNull @FutureOrPresent
    LocalDate deliveryDate
) {}

public record OrderItemRequest(
    @NotNull @Positive Long productId,
    @NotNull @Positive @Max(999) Integer quantity,
    @NotNull @DecimalMin("0.01") BigDecimal unitPrice
) {}

// ── Controller ────────────────────────────────────────────────────────
@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(
        @RequestBody @Valid CreateOrderRequest request,
        UriComponentsBuilder uriBuilder) {
    OrderResponse created = orderService.create(request);
    URI location = uriBuilder
        .path("/api/v1/orders/{id}")
        .buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

// ── Validation error includes nested paths ────────────────────────────
// "fieldErrors": {
//   "shippingAddress.postalCode": ["size must be between 3 and 10"],
//   "items[0].quantity": ["must be less than or equal to 999"],
//   "items[1].unitPrice": ["must be greater than or equal to 0.01"]
// }

Validation Groups

Validation groups apply different constraint subsets depending on the operation. A field might be required on create but optional on patch. Define marker interfaces as group names, assign them to constraints, and pass the group to @Validated on the controller parameter. The rule that surprises everybody: a constraint without a groups attribute belongs to the Default group. When you write @Validated(OnCreate.class), Hibernate Validator validates only the constraints in OnCreate, so every constraint left in Default is skipped. Either put all relevant constraints in explicit groups, or make your groups extend javax/jakarta.validation.groups.Default, as shown below. Groups also mean one DTO has several personalities, which gets confusing as it grows. In many codebases separate DTOs (CreateUserRequest, UpdateUserRequest, PatchUserRequest) are clearer, and groups are kept for the cases where duplication really hurts.
Java
// ── Group marker interfaces ───────────────────────────────────────────
public interface ValidationGroups {
    interface OnCreate extends Default {}   // also runs constraints without a group
    interface OnUpdate extends Default {}
    interface OnPatch  {}                   // only constraints explicitly in OnPatch
}

// ── DTO with group-specific constraints ───────────────────────────────
// (import static / nested imports for OnCreate, OnUpdate and OnPatch as needed)
public record UserRequest(

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    @Size(min = 2, max = 100,
          groups = {OnCreate.class, OnUpdate.class})
    String name,

    @NotBlank(groups = OnCreate.class)   // required on create only
    @Email(groups = {OnCreate.class, OnUpdate.class})
    String email,

    @NotBlank(groups = OnCreate.class)   // required on create only
    @Size(min = 8, groups = OnCreate.class)
    String password,

    // Optional on patch — no NotNull, just format validation if present
    @PhoneNumber(groups = OnPatch.class)
    String phone
) {}

// ── Controller using groups ───────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @RequestBody @Validated(OnCreate.class) UserRequest request,
            UriComponentsBuilder uriBuilder) {
        UserResponse created = userService.create(request);
        URI location = uriBuilder
            .path("/api/v1/users/{id}")
            .buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable Long id,
            @RequestBody @Validated(OnUpdate.class) UserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }

    @PatchMapping("/{id}")
    public ResponseEntity<UserResponse> patch(
            @PathVariable Long id,
            @RequestBody @Validated(OnPatch.class) UserRequest request) {
        return ResponseEntity.ok(userService.patch(id, request));
    }
}

// Note: @Valid (jakarta) cannot select groups; use Spring's @Validated(Group.class).
// A nested @Valid object is validated with the same groups.

Validation Beyond the Controller

Controller validation protects the API boundary, but the same rules often matter in other places. Service layer: put @Validated on the service class and constraint annotations or @Valid on method parameters. Spring wraps the bean in an AOP proxy and throws ConstraintViolationException. This works only for calls that go through the proxy, so a call from another method in the same class (self-invocation) is not validated, and it does not work on final classes. Programmatic validation: inject the jakarta.validation.Validator bean (Spring Boot provides it) to validate any object on demand, for example for data that does not come through a controller (messages from a queue, files). JPA entities: Hibernate Validator runs automatically before persist and update when constraints are on entities, throwing ConstraintViolationException, usually wrapped by Spring. Treat that as a last line of defense. Validate DTOs at the edge, and keep database constraints (NOT NULL, UNIQUE, CHECK) as the final guarantee. Configuration properties: add @Validated to a @ConfigurationProperties class so that missing or invalid settings stop the application at startup.
Java
// ── Service-layer validation ──────────────────────────────────────────
@Service
@Validated                                // proxy-based; handle ConstraintViolationException
public class TransferService {

    public void transfer(@NotNull Long fromId,
                         @NotNull Long toId,
                         @Positive @Digits(integer = 12, fraction = 2) BigDecimal amount) {
        // ...
    }

    public void process(@Valid TransferRequest request) {    // validates the nested object
        // ...
    }
}

// ── Programmatic validation ───────────────────────────────────────────
@Component
@RequiredArgsConstructor
public class MessageHandler {

    private final Validator validator;       // jakarta.validation.Validator

    public void handle(OrderPlacedEvent event) {
        Set<ConstraintViolation<OrderPlacedEvent>> violations = validator.validate(event);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
        // ...
    }
}

// ── Configuration properties validated at startup ──────────────────────
@ConfigurationProperties(prefix = "app.mail")
@Validated
public record MailProperties(@NotBlank String host, @Min(1) @Max(65535) int port) {}

Validation Messages and Internationalization

Messages can be literal text, as in the examples above, or keys in braces that are resolved from a resource bundle. Hibernate Validator always reads ValidationMessages.properties (and locale variants such as ValidationMessages_de.properties) from the classpath, and recent Spring Boot versions also resolve the keys from your normal messages.properties. Messages can also use constraint attributes ({min}, {max}) and the validated value (${validatedValue}). The locale comes from the request (Accept-Language header) when you use Spring's locale resolution. Always test with a non-default locale, because a missing key is displayed as the raw key in braces.
application.properties
# src/main/resources/ValidationMessages.properties
user.name.required=Name is required
user.name.size=Name must be between {min} and {max} characters
user.age.min=Age must be at least {value}

# src/main/resources/ValidationMessages_de.properties
user.name.required=Name ist erforderlich

// ── Use keys in the DTO ───────────────────────────────────────────────
public record CreateUserRequest(
    @NotBlank(message = "{user.name.required}")
    @Size(min = 2, max = 100, message = "{user.name.size}")
    String name,

    @Min(value = 18, message = "{user.age.min}")
    Integer age
) {}

// Request with Accept-Language: de returns the German message for name.

Testing Validation

Test validation at two levels. A plain unit test with the Validator checks the constraints on a DTO quickly, with no Spring context. A @WebMvcTest checks the whole path: JSON in, status code and error body out, including your exception handler. Test the failure cases (missing field, bad format, boundary values) as carefully as the success case, and assert on the field names, because those are your API contract.
Java
// ── Unit test of the constraints (no Spring context) ──────────────────
class CreateUserRequestTest {

    private static Validator validator;

    @BeforeAll
    static void init() {
        validator = Validation.buildDefaultValidatorFactory().getValidator();
    }

    @Test
    void shortPasswordIsRejected() {
        var request = new CreateUserRequest("Ana", "ana@example.com", 30, "abc", UserRole.USER);

        Set<ConstraintViolation<CreateUserRequest>> violations = validator.validate(request);

        assertThat(violations)
            .extracting(v -> v.getPropertyPath().toString())
            .contains("password");
    }

    @Test
    void validRequestHasNoViolations() {
        var request = new CreateUserRequest("Ana", "ana@example.com", 30, "Str0ng!Pass", UserRole.USER);
        assertThat(validator.validate(request)).isEmpty();
    }
}

// ── Web-layer test: status code and error body ────────────────────────
@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerValidationTest {

    @Autowired MockMvc mvc;
    @MockitoBean UserService userService;     // @MockBean on older Spring Boot versions

    @Test
    void invalidBodyReturns400WithFieldErrors() throws Exception {
        mvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"name": "", "email": "not-an-email", "age": 12}
                    """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.fieldErrors.name").isArray())
            .andExpect(jsonPath("$.fieldErrors.email[0]").value("Email must be a valid address"))
            .andExpect(jsonPath("$.fieldErrors.age").exists());
    }

    @Test
    void malformedJsonReturns400() throws Exception {
        mvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{ not json"))
            .andExpect(status().isBadRequest());
    }

    @Test
    void negativePathVariableReturns400() throws Exception {
        mvc.perform(get("/api/v1/users/-5"))
            .andExpect(status().isBadRequest());
    }
}

Common Validation Mistakes

1. Validation silently does nothing. Causes: the spring-boot-starter-validation dependency is missing, @Valid is missing on the parameter, or @Valid is missing on a nested object or collection field. 2. A field is "required" but accepts empty input. @Size, @Email and @Pattern accept null, and @NotNull accepts "". Use @NotBlank for required strings and @NotEmpty for required collections. 3. Primitive types in DTOs. An int field cannot be null, so a missing JSON property becomes 0 and passes @NotNull. Use Integer, Long and Boolean. 4. ConstraintViolationException returns 500. It is thrown for class-level @Validated and service validation, and it is not handled by default. Add a handler. Also handle HandlerMethodValidationException on Spring 6.1 and later. 5. Class-level constraint errors are missing from the response. They are global errors. Read getGlobalErrors(), or attach the violation to a property node. 6. Only the first message per field is returned. Collecting with toMap and (a, b) -> a drops the others. Collect into a list per field. 7. Groups validate less than expected. Constraints without a group belong to Default and are skipped when you validate with another group, unless your group interface extends Default. 8. Invalid JSON never reaches validation. A malformed body, a string in a number field or an unknown enum value fails in deserialization with HttpMessageNotReadableException. Handle it and return 400. 9. Declaring a BindingResult parameter by accident. Spring then stops throwing for that argument, and invalid data reaches your method unless you check the errors. 10. Uniqueness only checked in a validator. Race conditions slip through; keep a database unique constraint and map the violation to 409. 11. Echoing sensitive values. Do not return rejected values (passwords, tokens) in error responses or logs, and do not log DTOs that contain them. 12. Service-level @Validated has no effect. Self-invocation bypasses the proxy, and final classes cannot be proxied. 13. Validating only entities. Entities are the last line of defense; validate DTOs at the API boundary so errors are caught early and shaped for clients. 14. @Email is too lenient. Hibernate's default accepts a@b. Add a regexp if you need a stricter rule.

Frequently Asked Questions

What is the difference between @Valid and @Validated? @Valid is the standard Jakarta annotation. It triggers validation of an object and cascades into nested objects. @Validated is Spring's variant; it additionally supports validation groups and, on a class, enables proxy-based method validation for that bean. Do I need @Validated on the controller? On Spring Framework 6.1 and later (Spring Boot 3.2+), no: constraints on controller parameters are validated by Spring MVC itself, and the class-level annotation is better left off. On older versions you need class-level @Validated to validate @PathVariable and @RequestParam. Which exception is thrown when @Valid fails? MethodArgumentNotValidException for a request body or model attribute. For constraints directly on parameters, HandlerMethodValidationException (Spring 6.1+) or ConstraintViolationException (class-level @Validated). Why does validation return 500 instead of 400? Usually a ConstraintViolationException is not handled. Add an @ExceptionHandler that returns 400. What is the difference between @NotNull, @NotEmpty and @NotBlank? @NotNull only rejects null. @NotEmpty also rejects empty strings and collections. @NotBlank, for strings, also rejects text made only of whitespace. Why is my nested object not validated? The field is missing @Valid. Add @Valid to the nested object, or to each collection (List<@Valid Item>). How do I validate a request parameter or path variable? Put the constraint on the parameter, for example @PathVariable @Positive Long id. On Spring 6.1+ leave the class-level @Validated off and handle HandlerMethodValidationException. How do I return all validation errors? Read every field error and global error from the exception and return them in one response body, using a list of messages per field. How do I write a custom validation annotation? Create an annotation with @Constraint(validatedBy = ...), message, groups and payload attributes, and implement ConstraintValidator<A, T>. Return true for null and leave required-checks to @NotNull. Can a validator use Spring beans? Yes. Spring creates validators through its bean factory, so constructor or @Autowired injection works. How do I validate a different set of rules for create and update? Use validation groups with @Validated(Group.class), or use separate DTO classes, which is often clearer. Can I localize validation messages? Yes: use {key} messages and ValidationMessages.properties files per locale. Does validation run on JPA entities? Yes, Hibernate Validator runs on persist and update when the constraints are present, but validate DTOs at the API boundary first.

Key Takeaways

- Add spring-boot-starter-validation, put constraints on DTO fields, and add @Valid on the @RequestBody. - Know your exceptions: MethodArgumentNotValidException (body), HandlerMethodValidationException (parameters, Spring 6.1+), ConstraintViolationException (class-level @Validated, services), HttpMessageNotReadableException (unreadable body). Handle all four. - Most constraints accept null; use @NotBlank, @NotEmpty and @NotNull for required values, and wrapper types for DTO fields. - Nested objects and collections need @Valid, or their constraints are skipped. - Include global errors and all messages per field in the error response, and never echo sensitive values. - Groups: constraints without a group are in Default; extend Default or use separate DTOs. - Back custom uniqueness checks with a database constraint and map it to 409. - Test both the constraints (Validator) and the HTTP contract (@WebMvcTest). - Related topics: Exception Handling, HTTP Status Codes, DTOs and Mapping, Spring Security, @ConfigurationProperties.