Skip to main content
Java intermediate Lesson 53 of 58

Validation in Spring Boot

Validate request data in Spring Boot using Bean Validation annotations, custom validators, and global error handling for clean API error responses.

Validation prevents bad data from reaching your business logic. Without it, you end up with null checks and error handling scattered throughout your service layer. Spring Boot integrates Bean Validation (JSR-380) via Hibernate Validator — add the starter, annotate your request DTOs, add @Valid in your controllers, and Spring handles the rest.

Setup

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

Built-in Constraints

Annotate request DTO fields with the constraints they must satisfy. The message attribute customises the error text returned to the client — make it descriptive enough that the client knows exactly how to fix the value.

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 = "Must be a valid email address")
    String email,

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    String password,

    @Min(value = 0,   message = "Age cannot be negative")
    @Max(value = 150, message = "Age cannot exceed 150")
    Integer age,

    @Past(message = "Birth date must be in the past")
    LocalDate birthDate,

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

    @NotEmpty(message = "At least one tag is required")
    List<@NotBlank String> tags   // validates each element in the list individually
) {}

Common Annotations

AnnotationValidates
@NotNullNot null
@NotBlankNot null, not empty, not whitespace-only
@NotEmptyNot null, not empty (String/Collection)
@Size(min, max)String/Collection length range
@Min(value)Number ≥ value
@Max(value)Number ≤ value
@PositiveNumber > 0
@PositiveOrZeroNumber ≥ 0
@EmailValid email format
@Pattern(regexp)Matches regex
@Past / @FutureDate before/after now
@PastOrPresentDate ≤ now
@Digits(integer, fraction)Number format

Triggering Validation in Controllers

@Valid on a @RequestBody parameter tells Spring to validate the object before calling your method. If any constraint fails, Spring throws MethodArgumentNotValidException and your method body is never reached. For path variables and query parameters, add @Validated to the controller class and put constraints directly on the parameters.

@RestController
@RequestMapping("/api/users")
@Validated  // required to enable @PathVariable and @RequestParam validation
public class UserController {

    @PostMapping
    public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest req) {
        // Only reached if all constraints in CreateUserRequest pass
        return ResponseEntity.status(201).body(userService.create(req));
    }

    @GetMapping("/{id}")
    public UserResponse getById(@PathVariable @Min(1) Long id) {
        // @Min(1) ensures we never query the database with id <= 0
        return userService.findById(id);
    }

    @GetMapping
    public Page<UserResponse> search(
            @RequestParam @Min(0) int page,
            @RequestParam @Min(1) @Max(100) int size) {
        return userService.findAll(page, size);
    }
}

Global Exception Handler

Without a handler, validation failures produce an ugly Spring default response or a 500. A @RestControllerAdvice intercepts the exceptions thrown by failed validation and transforms them into the clean JSON structure your API contract promises. Note that @RequestBody failures throw MethodArgumentNotValidException, while @PathVariable/@RequestParam failures throw ConstraintViolationException — you need to handle both.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles @RequestBody validation failures — field-level errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );
        return new ErrorResponse("Validation failed", fieldErrors);
    }

    // Handles @PathVariable and @RequestParam validation failures
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleConstraintViolations(ConstraintViolationException ex) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getConstraintViolations().forEach(v -> {
            String field = v.getPropertyPath().toString();
            // Strip the method and parameter prefix — return just the parameter name
            errors.put(field.substring(field.lastIndexOf('.') + 1), v.getMessage());
        });
        return new ErrorResponse("Validation failed", errors);
    }

    // Handles domain-level not found
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
        return new ErrorResponse(ex.getMessage(), null);
    }

    // Catch-all — prevents raw stack traces from leaking to clients
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleUnexpected(Exception ex) {
        return new ErrorResponse("An unexpected error occurred", null);
    }
}

public record ErrorResponse(String message, Map<String, String> errors) {}

Validation error response:

{
  "message": "Validation failed",
  "errors": {
    "email": "Must be a valid email address",
    "password": "Password must be at least 8 characters",
    "age": "Age cannot be negative"
  }
}

Nested Object Validation

@Valid cascades validation into nested objects and collections. Without it, the constraints on the nested type are ignored. Add @Valid to any field that is itself annotated with constraints.

public record CreateOrderRequest(
    @NotBlank String customerId,

    @NotEmpty List<@Valid OrderItemRequest> items,  // validates each item in the list

    @Valid @NotNull ShippingAddressRequest shippingAddress  // validates the nested object
) {}

public record OrderItemRequest(
    @NotBlank String sku,
    @Min(1) int quantity,
    @Positive double unitPrice
) {}

public record ShippingAddressRequest(
    @NotBlank String street,
    @NotBlank String city,
    @NotBlank @Size(min = 2, max = 2) String countryCode  // exactly 2 characters
) {}

Custom Constraint

When the built-in annotations don’t cover your domain rule, create a custom constraint. It has three parts: an annotation that declares the constraint, a validator class that implements the check, and usage on a field or parameter.

// Step 1: declare the annotation — it's meta-annotated with @Constraint to link the validator
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface ValidPhone {
    String message() default "Invalid phone number";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// Step 2: implement the validation logic
public class PhoneNumberValidator implements ConstraintValidator<ValidPhone, String> {
    private static final Pattern PATTERN = Pattern.compile("^\\+?[1-9]\\d{7,14}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null) return true;  // let @NotNull handle null separately — single responsibility
        return PATTERN.matcher(value).matches();
    }
}

// Step 3: use it exactly like any built-in annotation
public record ContactRequest(
    @NotBlank String name,
    @ValidPhone String phone
) {}

Cross-Field Validation

Sometimes a constraint involves more than one field — for example, confirming that two password fields match. Class-level constraints receive the whole object and can check any combination of fields.

// Class-level annotation — targets the whole object, not a single field
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
    String message() default "Passwords do not match";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext ctx) {
        if (obj instanceof RegisterRequest req) {
            return req.password().equals(req.confirmPassword());
        }
        return true;
    }
}

@PasswordMatch  // applied at class level — checked after all field-level constraints pass
public record RegisterRequest(
    @NotBlank String username,
    @NotBlank @Size(min = 8) String password,
    @NotBlank String confirmPassword
) {}

Validation Groups

Groups let you apply different constraints in different scenarios — for example, requiring a password on create but not on update. This avoids having separate request classes for create and update when the only difference is which fields are required.

// Marker interfaces — no methods needed, just used as identifiers
public interface OnCreate {}
public interface OnUpdate {}

public class UserRequest {
    @NotBlank(groups = OnCreate.class)  // required only when creating; ignored on update
    private String password;

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})  // required in both cases
    private String name;
}

// @Validated(group) activates only constraints for that group
@PostMapping
public UserResponse create(@RequestBody @Validated(OnCreate.class) UserRequest req) { ... }

@PutMapping("/{id}")
public UserResponse update(@PathVariable Long id,
                           @RequestBody @Validated(OnUpdate.class) UserRequest req) { ... }

Frequently Asked Questions

What is the difference between @NotNull, @NotEmpty, and @NotBlank?
@NotNull rejects null only — empty string passes. @NotEmpty rejects null and empty string/collection. @NotBlank rejects null, empty, and whitespace-only strings. For text fields you almost always want @NotBlank.
Where should I put @Valid?
Put @Valid on the method parameter in the controller (@RequestBody @Valid CreateUserRequest req). Spring triggers validation before the method executes. If validation fails it throws MethodArgumentNotValidException, which you handle in a @ControllerAdvice.
How do I validate a path variable or query parameter?
Add @Validated to the controller class and put constraints directly on the parameter: @GetMapping('/{id}') public User get(@PathVariable @Min(1) Long id). Violations throw ConstraintViolationException, not MethodArgumentNotValidException — handle both in your global exception handler.