Data Validation
Data validation is the first line of defense for an API—invalid input should not reach the business logic, and the sooner it is rejected, the better.
1. What You'll Learn
@Valid/@ValidatedAnnotation and Verification Trigger Mechanism- Common constraint annotations:
@NotNull/@Size/@Pattern/@Email/@Min/@Max - Custom Validator
ConstraintValidator<A, T>Implementation - Group Validation
groupsDistinguish validation rules by scenario (Create vs. Update) - Uniform formatting of validation error responses
2. A True Story from an API Developer
(1) Pain Point: Dirty Data Being Stored in the Database
Alice discovered some strange data in the OrderFlow database: an order quantity of -5, an email address formatted as "abc," and a product price of 0. Bob reported that a user had submitted a product with negative inventory via the API, causing anomalies in the report statistics. Alice had previously written a lot of if-else validation code in the service layer, which was both verbose and prone to oversights.
(2) The Bean Validation Solution
Declare validation rules using annotations, and Spring automatically triggers validation:
public record CreateOrderRequest(
@NotNull Long productId,
@Min(1) @Max(100) Integer quantity,
@Email String customerEmail
) {}
Invalid requests are rejected before they reach the controller.
(3) Revenue
Alice replaced all manual validation code with Bean Validation, reducing the amount of code in the controller by 40% and ensuring that no validation rules are ever overlooked. There is no longer any dirty data in the database.
3. The Bean Validation Annotation System
(1) Verify the Execution Flow
graph TD
A["Client Request<br/>@RequestBody"] --> B{"@Valid<br/>Triggered?"}
B -->|Yes| C["Hibernate Validator<br/>Check Constraints"]
C --> D{"All Valid?"}
D -->|Yes| E["Controller Method<br/>Executes"]
D -->|No| F["MethodArgumentNotValidException<br/>400 Bad Request"]
B -->|No| G["Skip Validation<br/>Potential dirty data"]
(3) Verify the trigger method
| Note | Purpose | Location |
|---|---|---|
@Valid |
Trigger cascading validation (including nested objects) | Method parameters, fields |
@Validated |
Supports group verification | Class and method parameters |
@Validated(Group.class) |
Specify Validation Group | Method Parameters |
(1) ▶ Example: Controller Parameter Validation
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@PostMapping
public ResponseEntity<Order> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
// If validation fails, MethodArgumentNotValidException is thrown
// before reaching this line
Order order = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
}
public record CreateOrderRequest(
@NotNull(message = "Product ID is required")
Long productId,
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Quantity cannot exceed 100")
Integer quantity,
@Email(message = "Invalid email format")
String customerEmail
) {}
Output:
// Execution Successful
4. Common Constraint Annotations
(1) Quick Reference for Constraint Annotations
| Note | Applicable Type | Description | Example |
|---|---|---|---|
@NotNull |
All types | Cannot be null | @NotNull Long id |
@NotBlank |
String | Cannot be empty/null/all spaces | @NotBlank String name |
@NotEmpty |
String/Collection | Cannot be empty/null | @NotEmpty List<String> tags |
@Size |
String/Collection | Length/Size Range | @Size(min=2, max=100) |
@Min / @Max |
Data Type | Value Range | @Min(0) @Max(99999) |
@Positive |
Numeric type | Positive number | @Positive BigDecimal price |
@Email |
String | Email Format | @Email String email |
@Pattern |
String | Regular expression match | @Pattern(regexp="^[A-Z]") |
@Past / @Future |
Date Type | Past/Future | @Past LocalDate birthDate |
(1) ▶ Example: Product DTO Validation
public record CreateProductRequest(
@NotBlank(message = "Product name is required")
@Size(min = 2, max = 200, message = "Name must be 2-200 characters")
String name,
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
@DecimalMin(value = "0.01", message = "Price must be at least 0.01")
BigDecimal price,
@NotNull(message = "Stock is required")
@Min(value = 0, message = "Stock cannot be negative")
Integer stock,
@Email(message = "Supplier email must be valid")
String supplierEmail,
@Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU format: XXX-0000")
String sku
) {}
Output:
// Execution Successful
| Annotation Comparison | null | "" | " " | "abc" |
|---|---|---|---|---|
@NotNull |
❌ | ✅ | ✅ | ✅ |
@NotBlank |
❌ | ❌ | ❌ | ✅ |
@NotEmpty |
❌ | ❌ | ✅ | ✅ |
@NotBlank rather than @NotNull, because an empty string is usually invalid as well.
5. Custom Validators
(1) Implementation Steps
- Define Constraint Annotations
- Implementation
ConstraintValidator<A, T> - Use on DTO fields
(1) ▶ Example: Custom @ValidOrderQuantity validator
// Step 1: Define constraint annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderQuantityValidator.class)
public @interface ValidOrderQuantity {
String message() default "Order quantity exceeds product stock limit";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Step 2: Implement ConstraintValidator
public class OrderQuantityValidator
implements ConstraintValidator<ValidOrderQuantity, Integer> {
private static final int MAX_QUANTITY_PER_ITEM = 100;
@Override
public boolean isValid(Integer quantity, ConstraintValidatorContext context) {
if (quantity == null) {
return true; // Let @NotNull handle null check
}
return quantity >= 1 && quantity <= MAX_QUANTITY_PER_ITEM;
}
}
// Step 3: Use in DTO
public record CreateOrderRequest(
@NotNull Long productId,
@ValidOrderQuantity Integer quantity
) {}
Output:
// Execution Successful
6. Group Validation
(1) Categorizing Validation Rules by Scenario
"Create" and "Update" scenarios typically require different validation rules:
| Scenario | Product ID | Name | Price |
|---|---|---|---|
| Create | Generated automatically; not required | Required | Required |
| Update | Required (to indicate who made the change) | Optional | Optional |
(1) ▶ Example: Group Validation
// Define group interfaces
public interface Create {}
public interface Update {}
// DTO with group-aware validation
public record ProductRequest(
@Null(groups = Create.class, message = "ID must be null for creation")
@NotNull(groups = Update.class, message = "ID is required for update")
Long id,
@NotBlank(groups = Create.class, message = "Name is required for creation")
@Size(min = 2, max = 200)
String name,
@NotNull(groups = Create.class, message = "Price is required for creation")
@Positive BigDecimal price
) {}
Output:
// Execution Successful
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
@PostMapping
public ResponseEntity<Product> create(
@Validated(Create.class) @RequestBody ProductRequest request) {
// Only Create group validations are applied
// ...
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(
@PathVariable Long id,
@Validated(Update.class) @RequestBody ProductRequest request) {
// Only Update group validations are applied
// ...
return ResponseEntity.ok().build();
}
}
(2) ▶ Example: Nested Validation
public record CreateOrderRequest(
@NotNull Long productId,
@ValidOrderQuantity Integer quantity,
@Valid @NotNull ShippingAddress shippingAddress
) {}
public record ShippingAddress(
@NotBlank String street,
@NotBlank String city,
@NotBlank String zipCode,
@Pattern(regexp = "^[A-Z]{2}$") String country
) {}
Output:
// Execution Successful
@Valid; otherwise, nested field validation will not take effect. @Validated does not support nested cascading validation.
7. Validating the Format of Error Responses
(1) Standardize the error response format
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", HttpStatus.BAD_REQUEST.value());
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "",
"rejectedValue", fe.getRejectedValue() != null ? fe.getRejectedValue().toString() : "null"
))
.toList();
body.put("errors", errors);
return ResponseEntity.badRequest().body(body);
}
}
{
"timestamp": "2024-01-15T10:00:00Z",
"status": 400,
"errors": [
{"field": "quantity", "message": "Quantity must be at least 1", "rejectedValue": "0"},
{"field": "customerEmail", "message": "Invalid email format", "rejectedValue": "abc"}
]
}
8. Comprehensive Example: The Complete OrderFlow Verification System
// Validation groups
package com.orderflow.validation;
public interface Create {}
public interface Update {}
// Custom validator: @ValidShippingAddress
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ShippingAddressValidator.class)
public @interface ValidShippingAddress {
String message() default "Invalid shipping address";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class ShippingAddressValidator
implements ConstraintValidator<ValidShippingAddress, String> {
@Override
public boolean isValid(String address, ConstraintValidatorContext ctx) {
if (address == null || address.isBlank()) return false;
return address.length() >= 10 && address.length() <= 500;
}
}
// DTOs
public record CreateOrderRequest(
@NotNull(groups = Create.class) Long productId,
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Quantity cannot exceed 100")
Integer quantity,
@Email String customerEmail,
@ValidShippingAddress String shippingAddress
) {}
public record CreateProductRequest(
@Null(groups = Create.class) @NotNull(groups = Update.class) Long id,
@NotBlank(groups = Create.class) @Size(min = 2, max = 200) String name,
@NotNull(groups = Create.class) @Positive BigDecimal price,
@Min(0) Integer stock,
@Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU: XXX-0000") String sku
) {}
// Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@PostMapping
public ResponseEntity<Void> create(
@Validated(Create.class) @RequestBody CreateOrderRequest req) {
// Validation passed, proceed to business logic
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
❓ FAQ
public interface Create extends Default {}.resources/ValidationMessages.properties, such as order.quantity.invalid=Order quantity must be between {min} and {max}; multilingual files are supported.📖 Summary
@Valid/@Validatedtriggers validation; if validation fails,MethodArgumentNotValidExceptionis thrown- Common annotations: Use
@NotBlankfor strings,@Min/@Max/@Positivefor numbers, and@Emailfor email addresses - Three Steps to Creating a Custom Validator: Define an annotation → Implement ConstraintValidator → Use it
- Group validation distinguishes between Create and Update scenarios;
@Validated(Group.class)specifies the group - Nested objects must have
@Validadded to them in order to undergo cascading validation @RestControllerAdviceStandardized Formatting of Error Responses
📝 Exercises
-
Basic Exercise (Difficulty: ⭐): Add Bean Validation annotations to OrderFlow's
CreateOrderRequestandCreateProductRequestto validate invalid input and return a 400 error. -
Advanced Exercise (Difficulty ⭐⭐): Implement grouped validation—for
Createoperations,nameandpriceare required; forUpdateoperations,idis required andnameandpriceare optional. Implement a custom@ValidShippingAddressvalidator. -
Challenge (Difficulty: ⭐⭐⭐): Create a
@UniqueProductSkuvalidator that injects theProductRepositoryto check whether an SKU already exists, implementing database-level uniqueness validation. Consider the boundary between the responsibilities of the validator and the business layer.



