Introduction
Bean Validation (JSR 380) is the Java standard for validating objects, method parameters, and return values. It provides a declarative way to define constraints through annotations, making validation logic concise, reusable, and maintainable.
Setup and Dependencies
Maven Dependencies
<dependencies> <!-- Bean Validation API --> <dependency> <groupId>jakarta.validation</groupId> <artifactId>jakarta.validation-api</artifactId> <version>3.0.2</version> </dependency> <!-- Hibernate Validator (Reference Implementation) --> <dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>8.0.1.Final</version> </dependency> <!-- Expression Language (Required for Hibernate Validator) --> <dependency> <groupId>org.glassfish</groupId> <artifactId>jakarta.el</artifactId> <version>4.0.2</version> </dependency> <!-- Spring Boot Starter Validation --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>
Configuration
@Configuration
public class ValidationConfig {
@Bean
public Validator validator() {
return Validation.buildDefaultValidatorFactory().getValidator();
}
@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
return new MethodValidationPostProcessor();
}
// For Spring MVC
@Bean
public LocalValidatorFactoryBean localValidatorFactoryBean() {
return new LocalValidatorFactoryBean();
}
}
Basic Bean Validation
Entity Validation with Annotations
import javax.validation.constraints.*;
import java.time.LocalDate;
import java.util.List;
public class User {
@NotNull(message = "User ID cannot be null")
private Long id;
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username can only contain letters, numbers, and underscores")
private String username;
@Email(message = "Email should be valid")
@NotBlank(message = "Email is required")
private String email;
@Size(min = 8, message = "Password must be at least 8 characters long")
@Pattern(
regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\\S+$).{8,}$",
message = "Password must contain at least one digit, one lowercase, one uppercase, one special character and no whitespace"
)
private String password;
@Min(value = 18, message = "Age must be at least 18")
@Max(value = 120, message = "Age must be less than 120")
private Integer age;
@PositiveOrZero(message = "Salary cannot be negative")
private BigDecimal salary;
@Past(message = "Birth date must be in the past")
private LocalDate birthDate;
@Future(message = "Membership expiry must be in the future")
private LocalDate membershipExpiry;
@AssertTrue(message = "Must agree to terms and conditions")
private Boolean agreedToTerms;
@NotEmpty(message = "At least one role must be specified")
private List<@NotBlank String> roles;
@Valid // Cascade validation to nested object
private Address address;
// Constructors
public User() {}
public User(String username, String email, String password, Integer age) {
this.username = username;
this.email = email;
this.password = password;
this.age = age;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public BigDecimal getSalary() { return salary; }
public void setSalary(BigDecimal salary) { this.salary = salary; }
public LocalDate getBirthDate() { return birthDate; }
public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; }
public LocalDate getMembershipExpiry() { return membershipExpiry; }
public void setMembershipExpiry(LocalDate membershipExpiry) { this.membershipExpiry = membershipExpiry; }
public Boolean getAgreedToTerms() { return agreedToTerms; }
public void setAgreedToTerms(Boolean agreedToTerms) { this.agreedToTerms = agreedToTerms; }
public List<String> getRoles() { return roles; }
public void setRoles(List<String> roles) { this.roles = roles; }
public Address getAddress() { return address; }
public void setAddress(Address address) { this.address = address; }
}
// Nested object for cascade validation
class Address {
@NotBlank(message = "Street is required")
private String street;
@NotBlank(message = "City is required")
private String city;
@NotBlank(message = "State is required")
@Size(min = 2, max = 2, message = "State must be 2 characters")
private String state;
@NotBlank(message = "Zip code is required")
@Pattern(regexp = "^\\d{5}(-\\d{4})?$", message = "Zip code must be in format 12345 or 12345-6789")
private String zipCode;
@NotBlank(message = "Country is required")
private String country;
// Constructors, getters, setters
public Address() {}
public Address(String street, String city, String state, String zipCode, String country) {
this.street = street;
this.city = city;
this.state = state;
this.zipCode = zipCode;
this.country = country;
}
public String getStreet() { return street; }
public void setStreet(String street) { this.street = street; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getState() { return state; }
public void setState(String state) { this.state = state; }
public String getZipCode() { return zipCode; }
public void setZipCode(String zipCode) { this.zipCode = zipCode; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
}
Product Entity with Custom Validation
import javax.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class Product {
@Null(message = "ID must be null for new products") // Only for creation
private Long id;
@NotBlank(message = "Product name is required")
@Size(max = 100, message = "Product name cannot exceed 100 characters")
private String name;
@Size(max = 500, message = "Description cannot exceed 500 characters")
private String description;
@NotNull(message = "Price is required")
@DecimalMin(value = "0.0", inclusive = false, message = "Price must be greater than 0")
@Digits(integer = 10, fraction = 2, message = "Price must have at most 10 integer digits and 2 fraction digits")
private BigDecimal price;
@NotNull(message = "Stock quantity is required")
@Min(value = 0, message = "Stock quantity cannot be negative")
private Integer stockQuantity;
@NotNull(message = "Category is required")
private ProductCategory category;
@NotNull(message = "Created date is required")
@PastOrPresent(message = "Created date cannot be in the future")
private LocalDateTime createdAt;
@URL(message = "Product image URL must be valid")
private String imageUrl;
@AssertTrue(message = "Product must be active if in stock")
public boolean isActiveIfInStock() {
return stockQuantity == 0 || (stockQuantity > 0 && isActive());
}
// This would be a field in real scenario
private boolean active = true;
// Constructors
public Product() {
this.createdAt = LocalDateTime.now();
}
public Product(String name, String description, BigDecimal price, Integer stockQuantity, ProductCategory category) {
this();
this.name = name;
this.description = description;
this.price = price;
this.stockQuantity = stockQuantity;
this.category = category;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public Integer getStockQuantity() { return stockQuantity; }
public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
public ProductCategory getCategory() { return category; }
public void setCategory(ProductCategory category) { this.category = category; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}
enum ProductCategory {
ELECTRONICS, CLOTHING, BOOKS, HOME_APPLIANCES, SPORTS, BEAUTY
}
Custom Validators
Custom Constraint Annotations
// Custom validation for strong passwords
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
@Documented
public @interface StrongPassword {
String message() default "Password is not strong enough";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int minLength() default 8;
boolean requireUppercase() default true;
boolean requireLowercase() default true;
boolean requireDigit() default true;
boolean requireSpecialChar() default true;
}
// Validator implementation
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
private int minLength;
private boolean requireUppercase;
private boolean requireLowercase;
private boolean requireDigit;
private boolean requireSpecialChar;
@Override
public void initialize(StrongPassword constraintAnnotation) {
this.minLength = constraintAnnotation.minLength();
this.requireUppercase = constraintAnnotation.requireUppercase();
this.requireLowercase = constraintAnnotation.requireLowercase();
this.requireDigit = constraintAnnotation.requireDigit();
this.requireSpecialChar = constraintAnnotation.requireSpecialChar();
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if (password == null) {
return true; // Use @NotNull for null checks
}
if (password.length() < minLength) {
return false;
}
if (requireUppercase && !password.matches(".*[A-Z].*")) {
return false;
}
if (requireLowercase && !password.matches(".*[a-z].*")) {
return false;
}
if (requireDigit && !password.matches(".*\\d.*")) {
return false;
}
if (requireSpecialChar && !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*")) {
return false;
}
return true;
}
}
// Custom validation for unique username
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Documented
public @interface UniqueUsername {
String message() default "Username already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator with service dependency
@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
@Autowired
private UserService userService;
@Override
public void initialize(UniqueUsername constraintAnnotation) {
// Initialization if needed
}
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
if (username == null) {
return true; // Use @NotNull for null checks
}
return !userService.existsByUsername(username);
}
}
// Custom validation for date range
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
@Documented
public @interface ValidDateRange {
String message() default "Start date must be before end date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String startDate();
String endDate();
}
// Class-level validator
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, Object> {
private String startDateField;
private String endDateField;
@Override
public void initialize(ValidDateRange constraintAnnotation) {
this.startDateField = constraintAnnotation.startDate();
this.endDateField = constraintAnnotation.endDate();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
try {
Field startDateField = value.getClass().getDeclaredField(this.startDateField);
Field endDateField = value.getClass().getDeclaredField(this.endDateField);
startDateField.setAccessible(true);
endDateField.setAccessible(true);
LocalDate startDate = (LocalDate) startDateField.get(value);
LocalDate endDate = (LocalDate) endDateField.get(value);
if (startDate == null || endDate == null) {
return true; // Let @NotNull handle null checks
}
return !startDate.isAfter(endDate);
} catch (Exception e) {
throw new RuntimeException("Error validating date range", e);
}
}
}
// Entity using custom validators
@ValidDateRange(startDate = "startDate", endDate = "endDate", message = "End date must be after start date")
public class Event {
@NotBlank(message = "Event name is required")
private String name;
@NotNull(message = "Start date is required")
@Future(message = "Start date must be in the future")
private LocalDate startDate;
@NotNull(message = "End date is required")
private LocalDate endDate;
@StrongPassword(minLength = 10, message = "Event access code must be strong")
private String accessCode;
// Constructors, getters, setters
public Event() {}
public Event(String name, LocalDate startDate, LocalDate endDate) {
this.name = name;
this.startDate = startDate;
this.endDate = endDate;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public LocalDate getStartDate() { return startDate; }
public void setStartDate(LocalDate startDate) { this.startDate = startDate; }
public LocalDate getEndDate() { return endDate; }
public void setEndDate(LocalDate endDate) { this.endDate = endDate; }
public String getAccessCode() { return accessCode; }
public void setAccessCode(String accessCode) { this.accessCode = accessCode; }
}
Validation Groups
Group-based Validation
// Validation groups for different scenarios
public interface ValidationGroups {
interface Create {}
interface Update {}
interface PartialUpdate {}
}
// Entity using validation groups
public class UserWithGroups {
@NotNull(groups = Update.class, message = "ID is required for update")
@Null(groups = Create.class, message = "ID must be null for creation")
private Long id;
@NotBlank(groups = {Create.class, Update.class}, message = "Username is required")
@Size(min = 3, max = 50, groups = {Create.class, Update.class})
private String username;
@NotBlank(groups = Create.class, message = "Email is required for creation")
@Email(groups = {Create.class, Update.class})
private String email;
@NotBlank(groups = Create.class, message = "Password is required for creation")
@Size(min = 8, groups = Create.class)
private String password;
// For partial updates, no constraints
private String firstName;
private String lastName;
// Constructors, getters, setters
public UserWithGroups() {}
public UserWithGroups(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
}
Validation Service Layer
Validation Utility Classes
@Service
public class ValidationService {
private final Validator validator;
public ValidationService(Validator validator) {
this.validator = validator;
}
// Basic validation
public <T> ValidationResult validate(T object) {
Set<ConstraintViolation<T>> violations = validator.validate(object);
return new ValidationResult(violations);
}
// Validation with specific groups
public <T> ValidationResult validate(T object, Class<?>... groups) {
Set<ConstraintViolation<T>> violations = validator.validate(object, groups);
return new ValidationResult(violations);
}
// Validate specific property
public <T> ValidationResult validateProperty(T object, String propertyName) {
Set<ConstraintViolation<T>> violations = validator.validateProperty(object, propertyName);
return new ValidationResult(violations);
}
// Validate value for a specific class
public <T> ValidationResult validateValue(Class<T> beanType, String propertyName, Object value) {
Set<ConstraintViolation<T>> violations = validator.validateValue(beanType, propertyName, value);
return new ValidationResult(violations);
}
// Quick validation that throws exception
public <T> void validateAndThrow(T object) {
ValidationResult result = validate(object);
if (result.hasErrors()) {
throw new ValidationException(result.getErrorMessages());
}
}
// Validation result wrapper
public static class ValidationResult {
private final Set<? extends ConstraintViolation<?>> violations;
public ValidationResult(Set<? extends ConstraintViolation<?>> violations) {
this.violations = violations != null ? violations : Collections.emptySet();
}
public boolean isValid() {
return violations.isEmpty();
}
public boolean hasErrors() {
return !violations.isEmpty();
}
public Set<String> getErrorMessages() {
return violations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.toSet());
}
public Map<String, String> getErrorMap() {
return violations.stream()
.collect(Collectors.toMap(
violation -> violation.getPropertyPath().toString(),
ConstraintViolation::getMessage,
(existing, replacement) -> existing + "; " + replacement
));
}
public List<FieldError> getFieldErrors() {
return violations.stream()
.map(violation -> new FieldError(
violation.getPropertyPath().toString(),
violation.getMessage(),
violation.getInvalidValue()
))
.collect(Collectors.toList());
}
public int getErrorCount() {
return violations.size();
}
public void throwIfInvalid() {
if (hasErrors()) {
throw new ValidationException(getErrorMessages());
}
}
}
// Field error representation
public static class FieldError {
private final String field;
private final String message;
private final Object rejectedValue;
public FieldError(String field, String message, Object rejectedValue) {
this.field = field;
this.message = message;
this.rejectedValue = rejectedValue;
}
// Getters
public String getField() { return field; }
public String getMessage() { return message; }
public Object getRejectedValue() { return rejectedValue; }
}
}
// Custom exception for validation errors
public class ValidationException extends RuntimeException {
private final Set<String> errorMessages;
public ValidationException(Set<String> errorMessages) {
super("Validation failed: " + String.join(", ", errorMessages));
this.errorMessages = errorMessages;
}
public ValidationException(String message) {
super(message);
this.errorMessages = Set.of(message);
}
public Set<String> getErrorMessages() {
return errorMessages;
}
}
Spring MVC Integration
REST Controller with Validation
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {
private final UserService userService;
private final ValidationService validationService;
public UserController(UserService userService, ValidationService validationService) {
this.userService = userService;
this.validationService = validationService;
}
// Create user with request body validation
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody User user) {
User created = userService.createUser(user);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// Update user with validation groups
@PutMapping("/{id}")
public ResponseEntity<?> updateUser(
@PathVariable Long id,
@Validated(ValidationGroups.Update.class) @RequestBody UserWithGroups user) {
user.setId(id);
User updated = userService.updateUser(user);
return ResponseEntity.ok(updated);
}
// Method parameter validation
@GetMapping("/search")
public ResponseEntity<List<User>> searchUsers(
@RequestParam @NotBlank String query,
@RequestParam @Min(0) int page,
@RequestParam @Min(1) @Max(100) int size) {
List<User> users = userService.searchUsers(query, page, size);
return ResponseEntity.ok(users);
}
// Path variable validation
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable @Min(1) Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
// Custom validation with service
@PostMapping("/register")
public ResponseEntity<?> registerUser(@Valid @RequestBody RegistrationRequest request) {
User user = userService.registerUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
// Manual validation example
@PostMapping("/manual-validation")
public ResponseEntity<?> createUserManual(@RequestBody User user) {
ValidationService.ValidationResult result = validationService.validate(user);
if (result.hasErrors()) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("errors", result.getErrorMap());
return ResponseEntity.badRequest().body(response);
}
User created = userService.createUser(user);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
// Registration request with custom validation
class RegistrationRequest {
@NotBlank(message = "Username is required")
@UniqueUsername(message = "Username already exists")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
private String email;
@NotBlank(message = "Password is required")
@StrongPassword(minLength = 8, message = "Password is not strong enough")
private String password;
@NotBlank(message = "Password confirmation is required")
private String confirmPassword;
@AssertTrue(message = "Passwords must match")
public boolean isPasswordMatch() {
return password != null && password.equals(confirmPassword);
}
// Getters and setters
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getConfirmPassword() { return confirmPassword; }
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
}
Global Exception Handler
@ControllerAdvice
public class GlobalExceptionHandler {
// Handle validation errors from @Valid
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "Validation failed");
response.put("errors", errors);
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.badRequest().body(response);
}
// Handle constraint violation errors from @Validated
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handleConstraintViolationExceptions(
ConstraintViolationException ex) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(violation -> {
String fieldName = getFieldNameFromPath(violation.getPropertyPath().toString());
String errorMessage = violation.getMessage();
errors.put(fieldName, errorMessage);
});
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "Constraint violation");
response.put("errors", errors);
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.badRequest().body(response);
}
// Handle custom validation exceptions
@ExceptionHandler(ValidationException.class)
public ResponseEntity<Map<String, Object>> handleValidationException(
ValidationException ex) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "Validation failed");
response.put("errors", ex.getErrorMessages());
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.badRequest().body(response);
}
private String getFieldNameFromPath(String propertyPath) {
// Extract field name from property path
// For method parameters: "createUser.arg0.username" -> "username"
String[] parts = propertyPath.split("\\.");
return parts.length > 0 ? parts[parts.length - 1] : propertyPath;
}
}
Advanced Validation Scenarios
Conditional Validation
// Conditional validation based on other fields
public class ConditionalValidationExample {
@NotBlank(message = "Payment method is required")
private String paymentMethod;
@NotBlank(message = "Credit card number is required when payment method is CREDIT_CARD")
private String creditCardNumber;
@NotBlank(message = "PayPal email is required when payment method is PAYPAL")
@Email(message = "PayPal email must be valid")
private String paypalEmail;
// Conditional validation method
@AssertTrue(message = "Credit card details are required for credit card payments")
public boolean isCreditCardValid() {
if (!"CREDIT_CARD".equals(paymentMethod)) {
return true;
}
return creditCardNumber != null && !creditCardNumber.trim().isEmpty();
}
@AssertTrue(message = "PayPal email is required for PayPal payments")
public boolean isPaypalValid() {
if (!"PAYPAL".equals(paymentMethod)) {
return true;
}
return paypalEmail != null && !paypalEmail.trim().isEmpty();
}
// Getters and setters
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
public String getCreditCardNumber() { return creditCardNumber; }
public void setCreditCardNumber(String creditCardNumber) { this.creditCardNumber = creditCardNumber; }
public String getPaypalEmail() { return paypalEmail; }
public void setPaypalEmail(String paypalEmail) { this.paypalEmail = paypalEmail; }
}
// Cross-field validation with custom annotation
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FieldMatchValidator.class)
public @interface FieldMatch {
String message() default "Fields must match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String first();
String second();
}
public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> {
private String firstFieldName;
private String secondFieldName;
@Override
public void initialize(FieldMatch constraintAnnotation) {
firstFieldName = constraintAnnotation.first();
secondFieldName = constraintAnnotation.second();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
try {
Object firstObj = getFieldValue(value, firstFieldName);
Object secondObj = getFieldValue(value, secondFieldName);
return firstObj == null && secondObj == null ||
firstObj != null && firstObj.equals(secondObj);
} catch (Exception e) {
return false;
}
}
private Object getFieldValue(Object object, String fieldName) throws Exception {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(object);
}
}
// Usage of field match
@FieldMatch(first = "password", second = "confirmPassword", message = "Passwords must match")
class PasswordResetRequest {
@NotBlank
@StrongPassword
private String password;
@NotBlank
private String confirmPassword;
// Getters and setters
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getConfirmPassword() { return confirmPassword; }
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
}
Testing Validation
Comprehensive Test Suite
@SpringBootTest
public class ValidationTest {
@Autowired
private Validator validator;
@Autowired
private ValidationService validationService;
@Test
public void testUserValidation() {
User user = new User();
user.setUsername("ab"); // Too short
user.setEmail("invalid-email"); // Invalid email
user.setPassword("weak"); // Too weak
user.setAge(17); // Under 18
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertThat(violations).hasSize(4);
Map<String, String> errorMap = violations.stream()
.collect(Collectors.toMap(
v -> v.getPropertyPath().toString(),
ConstraintViolation::getMessage
));
assertThat(errorMap).containsKeys("username", "email", "password", "age");
}
@Test
public void testValidUser() {
User user = new User();
user.setUsername("validuser");
user.setEmail("[email protected]");
user.setPassword("StrongPass123!");
user.setAge(25);
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertThat(violations).isEmpty();
}
@Test
public void testCustomValidator() {
Event event = new Event();
event.setName("Test Event");
event.setStartDate(LocalDate.now().plusDays(2));
event.setEndDate(LocalDate.now().plusDays(1)); // End before start
Set<ConstraintViolation<Event>> violations = validator.validate(event);
assertThat(violations).hasSize(1);
assertThat(violations.iterator().next().getMessage())
.isEqualTo("End date must be after start date");
}
@Test
public void testValidationService() {
User invalidUser = new User();
invalidUser.setUsername("ab"); // Too short
ValidationService.ValidationResult result = validationService.validate(invalidUser);
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrorCount()).isEqualTo(4); // username, email, password, age
assertThat(result.getErrorMessages()).isNotEmpty();
}
@Test
public void testValidationGroups() {
UserWithGroups user = new UserWithGroups();
user.setId(1L); // Should be null for create group
// Test create group - should fail because ID is not null
Set<ConstraintViolation<UserWithGroups>> createViolations =
validator.validate(user, ValidationGroups.Create.class);
assertThat(createViolations).hasSize(1);
// Test update group - should pass for ID
Set<ConstraintViolation<UserWithGroups>> updateViolations =
validator.validate(user, ValidationGroups.Update.class);
assertThat(updateViolations.stream()
.filter(v -> v.getPropertyPath().toString().equals("id"))
.count()).isEqualTo(0);
}
@Test
public void testStrongPasswordValidator() {
StrongPasswordValidator passwordValidator = new StrongPasswordValidator();
StrongPassword constraint = createStrongPasswordAnnotation();
passwordValidator.initialize(constraint);
assertThat(passwordValidator.isValid("Weak", null)).isFalse();
assertThat(passwordValidator.isValid("StrongPass123!", null)).isTrue();
assertThat(passwordValidator.isValid("NoSpecial123", null)).isFalse();
}
private StrongPassword createStrongPasswordAnnotation() {
return new StrongPassword() {
@Override
public Class<? extends Annotation> annotationType() {
return StrongPassword.class;
}
@Override
public String message() {
return "Password is not strong enough";
}
@Override
public Class<?>[] groups() {
return new Class<?>[0];
}
@Override
public Class<? extends Payload>[] payload() {
return new Class[0];
}
@Override
public int minLength() {
return 8;
}
@Override
public boolean requireUppercase() {
return true;
}
@Override
public boolean requireLowercase() {
return true;
}
@Override
public boolean requireDigit() {
return true;
}
@Override
public boolean requireSpecialChar() {
return true;
}
};
}
}
Performance Considerations
Validation Performance Optimization
@Component
public class ValidationCache {
private final Map<Class<?>, Validator> validatorCache = new ConcurrentHashMap<>();
private final ValidatorFactory validatorFactory;
public ValidationCache() {
this.validatorFactory = Validation.buildDefaultValidatorFactory();
}
public <T> Validator getValidator(Class<T> clazz) {
return validatorCache.computeIfAbsent(clazz, k -> validatorFactory.getValidator());
}
public void clearCache() {
validatorCache.clear();
}
}
@Service
public class OptimizedValidationService {
private final ValidationCache validationCache;
public OptimizedValidationService(ValidationCache validationCache) {
this.validationCache = validationCache;
}
// Batch validation for better performance
public <T> Map<T, ValidationService.ValidationResult> validateBatch(Collection<T> objects) {
return objects.parallelStream()
.collect(Collectors.toMap(
obj -> obj,
this::validate
));
}
public <T> ValidationService.ValidationResult validate(T object) {
Validator validator = validationCache.getValidator(object.getClass());
Set<ConstraintViolation<T>> violations = validator.validate(object);
return new ValidationService.ValidationResult(violations);
}
// Validate only specific properties for performance
public <T> ValidationService.ValidationResult validateProperties(T object, String... properties) {
Validator validator = validationCache.getValidator(object.getClass());
Set<ConstraintViolation<T>> allViolations = new HashSet<>();
for (String property : properties) {
Set<ConstraintViolation<T>> violations = validator.validateProperty(object, property);
allViolations.addAll(violations);
}
return new ValidationService.ValidationResult(allViolations);
}
}
Conclusion
Bean Validation provides a powerful, declarative approach to input validation in Java applications:
Key Benefits:
- Declarative validation through annotations
- Reusable validation logic across different layers
- Rich set of built-in constraints
- Custom validation support for complex business rules
- Integration with Spring and other frameworks
- Internationalization support for error messages
Best Practices:
- Use appropriate constraint annotations for each field
- Create custom validators for complex business rules
- Use validation groups for different scenarios
- Handle validation errors gracefully in controllers
- Consider performance for bulk validation operations
- Write comprehensive tests for validation logic
Common Patterns:
- Entity validation for data integrity
- DTO validation for API boundaries
- Method parameter validation for service layers
- Cross-field validation for complex rules
- Conditional validation based on business logic
Bean Validation significantly reduces boilerplate code and makes validation logic more maintainable and testable.