Input Validation with Bean Validation in Java

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:

  1. Use appropriate constraint annotations for each field
  2. Create custom validators for complex business rules
  3. Use validation groups for different scenarios
  4. Handle validation errors gracefully in controllers
  5. Consider performance for bulk validation operations
  6. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper