Bean Validation in Spring Boot
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));
}
@Validtriggers 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
- Add
@Validon@RequestBodyto trigger validation. @NotBlankfor strings,@NotNull+@Positivefor numbers.- Add
@Validatedon the controller class for path/query param validation. - Create custom validators for business rules (unique ISBN, etc.).
- Handle
MethodArgumentNotValidExceptionfor clean error responses.
What’s Next?
Next: Exception Handling — @ControllerAdvice, global error handling, and RFC 7807 ProblemDetail.