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.