Introduction
Data validation is one of those topics that every backend developer knows is important—but it often doesn’t get the attention it deserves. A solid validation strategy keeps your APIs clean, secure, and predictable.
In this guide, we’ll explore how to use Spring Boot validation effectively with @Valid and @Validated. You’ll learn how to:
- Validate request DTOs cleanly
- Handle nested objects and lists
- Customize error messages
- Manage validation groups for create/update operations
- Build centralized exception handling
- Implement custom validation annotations with
ConstraintValidator
All examples are based on a simple product and category API built with Spring Boot 3, Hibernate Validator, and OpenAPI documentation.
1. Why Validation Matters
Validation helps ensure data integrity, security, and user experience:
- Prevents invalid or malicious data from reaching your business logic.
- Improves API predictability by enforcing consistent input rules.
- Provides clear feedback to clients via structured error responses.
Spring Boot natively supports validation via the Bean Validation API (JSR 380), implemented by Hibernate Validator under the hood.
2. Enabling Validation
Add the spring-boot-starter-validation dependency to your project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Once included, Spring automatically integrates it into your controller request mappings.
3. Basic Validation Annotations
Spring Boot’s validation system supports all standard JSR-380 annotations:
| Annotation | Description | Example |
|---|---|---|
@NotNull |
Field cannot be null | @NotNull Double price |
@NotBlank |
String cannot be null or empty (ignores spaces) | @NotBlank String name |
@Size |
Limits size of strings, arrays, or lists | @Size(max = 5) |
@Email |
Validates email format | @Email String contactEmail |
@Min, @Max
|
Numeric boundaries | @Min(1) @Max(10000) |
@Positive, @Negative
|
Must be > 0 or < 0 | @Positive Integer quantity |
@Past, @Future
|
For dates | @Future LocalDate availabilityDate |
4. Example: ProductRequest DTO
Here’s a real-world DTO used in our API:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ProductRequest {
@NotBlank(message = "Product name cannot be blank", groups = {OnCreate.class, OnUpdate.class})
@Size(min = 2, max = 100, message = "Product name must be between 2 and 100 characters", groups = {OnCreate.class, OnUpdate.class})
String name;
@NotNull(message = "Price cannot be null", groups = OnCreate.class)
@Positive(message = "Price must be positive", groups = {OnCreate.class, OnUpdate.class})
Double price;
@NotNull(message = "Category ID is required", groups = OnCreate.class)
Long categoryId;
@Valid
@Size(max = 5, message = "You can add up to 5 tags", groups = {OnCreate.class, OnUpdate.class})
List<@NotBlank(message = "Tag cannot be blank") String> tags;
@Email(message = "Invalid email format for warranty", groups = {OnCreate.class, OnUpdate.class})
String emailForWarranty;
@Min(value = 0, message = "Discount cannot be negative", groups = {OnCreate.class, OnUpdate.class})
@Max(value = 80, message = "Discount cannot exceed 80%", groups = {OnCreate.class, OnUpdate.class})
Integer discountPercentage;
@Future(message = "Availability date must be in the future", groups = {OnCreate.class, OnUpdate.class})
LocalDate availabilityDate;
@Sku(message = "SKU must be 8 uppercase letters or digits", groups = {OnCreate.class, OnUpdate.class})
String sku;
}
This DTO combines multiple annotation types and supports both create and update operations through validation groups.
5. Nested Object and List Validation
To validate nested objects or lists, use @Valid on the field that contains them.
@Valid
List<@NotBlank(message = "Tag cannot be blank") String> tags;
Spring will automatically cascade the validation down into each element of the list.
For complex nested DTOs (e.g. AddressRequest inside a UserRequest), you’d also annotate the nested field with @Valid.
6. @Valid vs @Validated
A common source of confusion!
| Annotation | Used on | Supports Groups | Typical Use Case |
|---|---|---|---|
@Valid |
Method parameters and fields | No | Validate request bodies |
@Validated |
Class level or method level | Yes | Used on method parameters, path variables, or when validation groups are needed |
Example:
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
@Validated(OnCreate.class) @RequestBody ProductRequest request) {
...
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable @Min(1) Long id,
@Validated(OnUpdate.class) @RequestBody ProductRequest request) {
...
}
Here, @Validated enables group-based validation, enforcing different constraints on creation vs update.
Meanwhile, in simpler cases (like categories), you can still use @Valid:
@PostMapping
public ResponseEntity<CategoryResponse> createCategory(
@Valid @RequestBody CategoryRequest request) {
...
}
7. Validation Groups Explained
Validation groups let you define which constraints apply depending on context.
public interface OnCreate {}
public interface OnUpdate {}
Then assign them in your annotations:
@NotNull(groups = OnCreate.class)
@Positive(groups = {OnCreate.class, OnUpdate.class})
Double price;
When validating with @Validated(OnCreate.class), only constraints belonging to that group will be triggered.
This approach is essential for APIs where fields are optional on update but mandatory on create.
8. Custom Error Messages
You can define messages directly in annotations (as shown earlier), or externalize them in a messages.properties file:
product.name.notblank=Product name cannot be blank
product.price.positive=Price must be positive
Then reference them:
@NotBlank(message = "{product.name.notblank}")
This improves maintainability and supports localization.
9. Global Exception Handling for Validation
When validation fails in Spring Boot, two main exceptions are typically thrown:
-
MethodArgumentNotValidException: triggered when a@Validor@Validatedannotated@RequestBodyfails validation. -
ConstraintViolationException: triggered when validation fails on method parameters, such as@PathVariable,@RequestParam, or directly on method arguments annotated with@Validated.
If not handled, these exceptions result in default error responses that are not user-friendly and may expose internal details.
To keep your API responses consistent and developer-friendly, you should implement centralized exception handling using @RestControllerAdvice (or @ControllerAdvice, depending on your use case).
-
@RestControllerAdvice is the preferred choice for REST APIs, as it automatically adds @ResponseBody to all methods, returning structured JSON error responses. -
@ControllerAdvice, on the other hand, is more general-purpose and suitable if your application also serves web pages or uses view templates, since it doesn’t assume a JSON response by default.
Both annotations act as global interceptors for exceptions thrown from controllers, allowing you to define a single, centralized place to handle and format error responses.
A typical example looks like this:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationError(MethodArgumentNotValidException ex) {
Map<String, Object> errors = new LinkedHashMap<>();
errors.put("timestamp", LocalDateTime.now());
errors.put("status", HttpStatus.BAD_REQUEST.value());
errors.put("errors", ex.getBindingResult().getFieldErrors()
.stream()
.map(err -> Map.of("field", err.getField(), "message", err.getDefaultMessage()))
.toList());
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handleConstraintViolation(ConstraintViolationException ex) {
Map<String, Object> errors = new LinkedHashMap<>();
errors.put("timestamp", LocalDateTime.now());
errors.put("status", HttpStatus.BAD_REQUEST.value());
errors.put("errors", ex.getConstraintViolations()
.stream()
.map(v -> Map.of("field", v.getPropertyPath().toString(), "message", v.getMessage()))
.toList());
return ResponseEntity.badRequest().body(errors);
}
}
Why You Should Use @ControllerAdvice
- ✅ Centralization: all validation errors are managed in one place, improving maintainability.
- ✅ Consistency: every endpoint returns validation errors with the same JSON structure.
- ✅ Security: prevents exposing internal exception details to the client.
- ✅ Extensibility: you can easily extend the same class to handle business or authentication errors in the future.
This approach not only improves developer experience but also helps frontend teams rely on predictable, machine-readable error formats.
10. Custom Validators with ConstraintValidator
Sometimes standard annotations aren’t enough. You can create your own validator easily.
Example: SKU Validator
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
public @interface Sku {
String message() default "Invalid SKU format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Validator implementation:
public class SkuValidator implements ConstraintValidator<Sku, String> {
private static final Pattern SKU_PATTERN = Pattern.compile("^[A-Z0-9]{8}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || SKU_PATTERN.matcher(value).matches();
}
}
Then use it in your DTO:
@Sku(message = "SKU must be 8 uppercase letters or digits")
String sku;
11. Best Practices
- ✅ Validate at the edge — always in controllers, before reaching the service layer.
- ✅ Group constraints logically (create/update).
- ✅ Use
@Validfor simple cases,@Validatedfor grouped or parameter-level validation. - ✅ Keep error messages clean and user-friendly.
- ✅ Centralize exception handling with
@ControllerAdvice. - ✅ Externalize messages for easier maintenance.
- ✅ Prefer DTO validation over entity validation to maintain separation of concerns.
12. Conclusion
Validation is more than just checking inputs—it’s part of your API design.
Spring Boot, with Bean Validation, makes it easy to enforce correctness, improve security, and offer better UX.
We’ve covered:
- Basic and advanced annotations
- Nested and list validation
- Custom constraints
- Validation groups
- Global error handling
🧩 Want more? Check out the GitHub repository for more examples and full implementations and my Pro Starter Kit on Gumroad!
💬 Join the Discussion
What’s your approach to validation and error handling in Spring Boot? Have you tried alternative strategies like custom ErrorResponse wrappers or problem-spring-web? Let’s discuss best practices and experiences from real-world projects.
Share your thoughts in the comments! 👇
Your feedback will help other developers (and me!) improve future articles.
Top comments (0)