Bean Validation in Spring Boot

2 minute read

Never trust client input. Spring Boot integrates with Jakarta Bean Validation to validate request data declaratively.


Setup

Add the dependency (included with spring-boot-starter-validation):

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

Validating Request Bodies

public record CreateBookRequest(

    @NotBlank(message = "Title is required")
    @Size(min = 1, max = 200, message = "Title must be 1-200 characters")
    String title,

    @NotBlank(message = "Author is required")
    String author,

    @Pattern(regexp = "^\\d{3}-\\d{10}$", message = "ISBN must match format: 978-0132350884")
    String isbn,

    @NotNull(message = "Price is required")
    @Positive(message = "Price must be positive")
    @DecimalMax(value = "9999.99", message = "Price cannot exceed 9999.99")
    BigDecimal price,

    @Email(message = "Invalid publisher email")
    String publisherEmail
) {}
@PostMapping
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest request) {
    // If validation fails, Spring throws MethodArgumentNotValidException
    // before reaching this code
    Book book = bookService.create(request);
    return ResponseEntity.created(URI.create("/api/books/" + book.getId()))
        .body(BookResponse.from(book));
}

@Valid triggers validation — without it, annotations are ignored.


Common Validation Annotations

Annotation Use
@NotNull Not null (allows empty string)
@NotBlank Not null, not empty, not whitespace
@NotEmpty Not null, not empty (works on collections too)
@Size(min, max) String length or collection size
@Min / @Max Numeric min/max
@Positive / @Negative Number sign
@Email Valid email format
@Pattern(regexp) Regex match
@Past / @Future Date constraints
@DecimalMin / @DecimalMax Decimal bounds

Validating Path Variables and Query Params

Add @Validated on the controller class:

@RestController
@RequestMapping("/api/books")
@Validated                                    // enables method-level validation
public class BookController {

    @GetMapping("/{id}")
    public Book getById(@PathVariable @Min(1) Long id) {
        return bookService.findById(id);
    }

    @GetMapping("/search")
    public List<Book> search(
        @RequestParam @Size(min = 2) String query
    ) {
        return bookService.search(query);
    }
}

Custom Validator

// 1. Define the annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueIsbnValidator.class)
public @interface UniqueIsbn {
    String message() default "ISBN already exists";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 2. Implement the validator
@Component
public class UniqueIsbnValidator implements ConstraintValidator<UniqueIsbn, String> {

    private final BookRepository bookRepository;

    public UniqueIsbnValidator(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public boolean isValid(String isbn, ConstraintValidatorContext context) {
        if (isbn == null) return true;  // let @NotNull handle null
        return !bookRepository.existsByIsbn(isbn);
    }
}

// 3. Use it
public record CreateBookRequest(
    @NotBlank String title,
    @UniqueIsbn String isbn
) {}

Handling Validation Errors

Without custom handling, Spring returns a 400 with a generic error. Add a handler:

@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException ex) {

        Map<String, String> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );

        Map<String, Object> body = Map.of(
            "status", 400,
            "error", "Validation Failed",
            "errors", fieldErrors
        );

        return ResponseEntity.badRequest().body(body);
    }
}

Response:

{
  "status": 400,
  "error": "Validation Failed",
  "errors": {
    "title": "Title is required",
    "price": "Price must be positive",
    "isbn": "ISBN already exists"
  }
}

Nested Validation

public record CreateOrderRequest(

    @NotNull @Valid                    // @Valid triggers nested validation
    Address shippingAddress,

    @NotEmpty
    List<@Valid OrderItem> items      // validates each item
) {}

public record Address(
    @NotBlank String street,
    @NotBlank String city,
    @NotBlank String zipCode
) {}

public record OrderItem(
    @NotNull Long bookId,
    @Positive int quantity
) {}

Key Takeaways

  1. Add @Valid on @RequestBody to trigger validation.
  2. @NotBlank for strings, @NotNull + @Positive for numbers.
  3. Add @Validated on the controller class for path/query param validation.
  4. Create custom validators for business rules (unique ISBN, etc.).
  5. Handle MethodArgumentNotValidException for clean error responses.

What’s Next?

Next: Exception Handling — @ControllerAdvice, global error handling, and RFC 7807 ProblemDetail.

Updated: