Hibernate Validator provides a powerful framework for validating Java objects. While it comes with many built-in constraints, you can create custom constraints to handle specific business validation rules.
Why Custom Constraints?
- Domain-specific validation: Business rules unique to your application
- Complex validation logic: Multi-field validation or conditional rules
- Reusable validation: Apply the same rules across multiple classes
- Cleaner code: Keep validation logic separate from business logic
Project Setup
Maven Dependencies
<dependencies> <!-- Hibernate Validator --> <dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>8.0.1.Final</version> </dependency> <!-- Validation API --> <dependency> <groupId>jakarta.validation</groupId> <artifactId>jakarta.validation-api</artifactId> <version>3.0.2</version> </dependency> <!-- Expression Language (for message interpolation) --> <dependency> <groupId>org.glassfish</groupId> <artifactId>jakarta.el</artifactId> <version>4.0.2</version> </dependency> </dependencies>
Basic Custom Constraint Structure
A custom constraint consists of three parts:
- Annotation: Defines the constraint with attributes
- Validator: Contains the actual validation logic
- Error Messages: Custom validation messages
Simple Custom Constraint Examples
Example 1: Basic Custom Annotation
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
// Custom annotation for valid email domains
@Documented
@Constraint(validatedBy = ValidEmailDomainValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidEmailDomain {
// Default error message
String message() default "Invalid email domain";
// Required by validation API
Class<?>[] groups() default {};
// Required by validation API
Class<? extends Payload>[] payload() default {};
// Custom attribute - allowed domains
String[] allowedDomains() default { "company.com", "example.com" };
}
Example 2: Corresponding Validator
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class ValidEmailDomainValidator
implements ConstraintValidator<ValidEmailDomain, String> {
private String[] allowedDomains;
@Override
public void initialize(ValidEmailDomain constraintAnnotation) {
this.allowedDomains = constraintAnnotation.allowedDomains();
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
// Null values are considered valid (use @NotNull for null checks)
if (email == null) {
return true;
}
// Check if email contains @ and has valid domain
if (!email.contains("@")) {
return false;
}
String domain = email.substring(email.indexOf("@") + 1);
// Check against allowed domains
for (String allowedDomain : allowedDomains) {
if (domain.equalsIgnoreCase(allowedDomain)) {
return true;
}
}
return false;
}
}
Example 3: Using the Custom Constraint
public class User {
@NotBlank(message = "Name is required")
private String name;
@ValidEmailDomain(
allowedDomains = {"company.com", "partner.com"},
message = "Email must be from company or partner domain"
)
private String email;
// Constructors, getters, setters
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
Advanced Custom Constraints
Example 4: Cross-Field Validation
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
// Annotation for validating that two fields match
@Documented
@Constraint(validatedBy = FieldMatchValidator.class)
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldMatch {
String message() default "Fields must match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
// First field
String first();
// Second field
String second();
// Allow multiple annotations of the same type
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@interface List {
FieldMatch[] value();
}
}
Example 5: Cross-Field Validator
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.lang.reflect.Field;
public class FieldMatchValidator
implements ConstraintValidator<FieldMatch, Object> {
private String firstFieldName;
private String secondFieldName;
@Override
public void initialize(FieldMatch constraintAnnotation) {
this.firstFieldName = constraintAnnotation.first();
this.secondFieldName = constraintAnnotation.second();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
try {
// Use reflection to get field values
Object firstValue = getFieldValue(value, firstFieldName);
Object secondValue = getFieldValue(value, secondFieldName);
// Check if both values are equal
boolean isValid = (firstValue == null && secondValue == null) ||
(firstValue != null && firstValue.equals(secondValue));
// Customize error message if validation fails
if (!isValid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addPropertyNode(secondFieldName)
.addConstraintViolation();
}
return isValid;
} catch (Exception e) {
throw new RuntimeException("Error during field match validation", e);
}
}
private Object getFieldValue(Object object, String fieldName) throws Exception {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(object);
}
}
Example 6: Using Cross-Field Validation
@FieldMatch.List({
@FieldMatch(first = "password", second = "confirmPassword",
message = "Password fields must match"),
@FieldMatch(first = "email", second = "confirmEmail",
message = "Email fields must match")
})
public class RegistrationForm {
@NotBlank
private String username;
@Email
private String email;
@NotBlank
private String confirmEmail;
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
private String confirmPassword;
// Constructors, getters, setters
public RegistrationForm() {}
public RegistrationForm(String username, String email, String confirmEmail,
String password, String confirmPassword) {
this.username = username;
this.email = email;
this.confirmEmail = confirmEmail;
this.password = password;
this.confirmPassword = 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 getConfirmEmail() { return confirmEmail; }
public void setConfirmEmail(String confirmEmail) { this.confirmEmail = confirmEmail; }
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; }
}
Complex Business Logic Constraints
Example 7: Age Validation Constraint
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = AgeValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidAge {
String message() default "Invalid age";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int min() default 0;
int max() default 150;
}
Example 8: Age Validator
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.time.LocalDate;
import java.time.Period;
public class AgeValidator implements ConstraintValidator<ValidAge, LocalDate> {
private int minAge;
private int maxAge;
@Override
public void initialize(ValidAge constraintAnnotation) {
this.minAge = constraintAnnotation.min();
this.maxAge = constraintAnnotation.max();
}
@Override
public boolean isValid(LocalDate birthDate, ConstraintValidatorContext context) {
// Null values are considered valid
if (birthDate == null) {
return true;
}
// Calculate age
LocalDate today = LocalDate.now();
Period period = Period.between(birthDate, today);
int age = period.getYears();
// Validate age range
boolean isValid = age >= minAge && age <= maxAge;
// Custom error message
if (!isValid) {
context.disableDefaultConstraintViolation();
String message = String.format("Age must be between %d and %d years", minAge, maxAge);
context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
}
return isValid;
}
}
Example 9: Conditional Validation Constraint
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = ConditionalValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ConditionalValidation {
String message() default "Conditional validation failed";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
// Field that determines the condition
String conditionField();
// Expected value for the condition
String conditionValue();
// Fields that are required when condition is met
String[] requiredFields();
}
Example 10: Conditional Validator
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.lang.reflect.Field;
public class ConditionalValidator
implements ConstraintValidator<ConditionalValidation, Object> {
private String conditionField;
private String conditionValue;
private String[] requiredFields;
@Override
public void initialize(ConditionalValidation constraintAnnotation) {
this.conditionField = constraintAnnotation.conditionField();
this.conditionValue = constraintAnnotation.conditionValue();
this.requiredFields = constraintAnnotation.requiredFields();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
try {
// Check if condition is met
Object conditionFieldValue = getFieldValue(value, conditionField);
boolean conditionMet = conditionValue.equals(String.valueOf(conditionFieldValue));
if (!conditionMet) {
return true; // Condition not met, no further validation needed
}
// Condition met, validate required fields
boolean isValid = true;
context.disableDefaultConstraintViolation();
for (String requiredField : requiredFields) {
Object fieldValue = getFieldValue(value, requiredField);
if (fieldValue == null ||
(fieldValue instanceof String && ((String) fieldValue).trim().isEmpty())) {
isValid = false;
context.buildConstraintViolationWithTemplate(
"Field '" + requiredField + "' is required when " +
conditionField + " is '" + conditionValue + "'")
.addPropertyNode(requiredField)
.addConstraintViolation();
}
}
return isValid;
} catch (Exception e) {
throw new RuntimeException("Error during conditional validation", e);
}
}
private Object getFieldValue(Object object, String fieldName) throws Exception {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(object);
}
}
Using Custom Constraints
Example 11: Complete Usage Example
@ConditionalValidation(
conditionField = "userType",
conditionValue = "BUSINESS",
requiredFields = {"companyName", "taxId"},
message = "Company name and tax ID are required for business users"
)
public class CompleteUser {
@NotBlank
private String username;
@ValidEmailDomain(allowedDomains = {"company.com"})
private String email;
@ValidAge(min = 18, max = 100, message = "Must be between 18 and 100 years old")
private LocalDate birthDate;
private String userType; // PERSONAL or BUSINESS
private String companyName;
private String taxId;
@FieldMatch(first = "password", second = "confirmPassword")
private String password;
private String confirmPassword;
// Constructors
public CompleteUser() {}
public CompleteUser(String username, String email, LocalDate birthDate,
String userType, String companyName, String taxId) {
this.username = username;
this.email = email;
this.birthDate = birthDate;
this.userType = userType;
this.companyName = companyName;
this.taxId = taxId;
}
// 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 LocalDate getBirthDate() { return birthDate; }
public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; }
public String getUserType() { return userType; }
public void setUserType(String userType) { this.userType = userType; }
public String getCompanyName() { return companyName; }
public void setCompanyName(String companyName) { this.companyName = companyName; }
public String getTaxId() { return taxId; }
public void setTaxId(String taxId) { this.taxId = taxId; }
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 Custom Constraints
Example 12: Unit Testing Validators
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.time.LocalDate;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class CustomConstraintTest {
private Validator validator;
@BeforeEach
void setUp() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
@Test
void testValidEmailDomain() {
CompleteUser user = new CompleteUser();
user.setEmail("[email protected]");
user.setUsername("john");
user.setBirthDate(LocalDate.of(1990, 1, 1));
user.setUserType("PERSONAL");
Set<ConstraintViolation<CompleteUser>> violations = validator.validate(user);
assertEquals(0, violations.size());
}
@Test
void testInvalidEmailDomain() {
CompleteUser user = new CompleteUser();
user.setEmail("[email protected]"); // Invalid domain
user.setUsername("john");
user.setBirthDate(LocalDate.of(1990, 1, 1));
user.setUserType("PERSONAL");
Set<ConstraintViolation<CompleteUser>> violations = validator.validate(user);
assertEquals(1, violations.size());
assertTrue(violations.iterator().next().getMessage().contains("email domain"));
}
@Test
void testConditionalValidation() {
CompleteUser user = new CompleteUser();
user.setEmail("[email protected]");
user.setUsername("john");
user.setBirthDate(LocalDate.of(1990, 1, 1));
user.setUserType("BUSINESS"); // Business user but missing required fields
Set<ConstraintViolation<CompleteUser>> violations = validator.validate(user);
assertEquals(2, violations.size()); // companyName and taxId are required
}
@Test
void testAgeValidation() {
CompleteUser user = new CompleteUser();
user.setEmail("[email protected]");
user.setUsername("john");
user.setBirthDate(LocalDate.of(2010, 1, 1)); // Under 18
user.setUserType("PERSONAL");
Set<ConstraintViolation<CompleteUser>> violations = validator.validate(user);
assertEquals(1, violations.size());
assertTrue(violations.iterator().next().getMessage().contains("between 18 and 100"));
}
@Test
void testFieldMatchValidation() {
CompleteUser user = new CompleteUser();
user.setEmail("[email protected]");
user.setUsername("john");
user.setBirthDate(LocalDate.of(1990, 1, 1));
user.setUserType("PERSONAL");
user.setPassword("password123");
user.setConfirmPassword("differentpassword"); // Doesn't match
Set<ConstraintViolation<CompleteUser>> violations = validator.validate(user);
assertEquals(1, violations.size());
assertTrue(violations.iterator().next().getMessage().contains("must match"));
}
}
Integration with Spring Boot
Example 13: Spring Boot Configuration
@Configuration
public class ValidationConfig {
@Bean
public Validator validator() {
return Validation.buildDefaultValidatorFactory().getValidator();
}
@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
return new MethodValidationPostProcessor();
}
}
// Service using validation
@Service
@Validated
public class UserService {
public void createUser(@Valid CompleteUser user) {
// Business logic here
System.out.println("Creating user: " + user.getUsername());
}
public void updateEmail(@ValidEmailDomain String email) {
// Method parameter validation
System.out.println("Updating email: " + email);
}
}
// REST Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public ResponseEntity<?> createUser(@RequestBody @Valid CompleteUser user) {
userService.createUser(user);
return ResponseEntity.ok("User created successfully");
}
}
Best Practices
- Keep Validators Simple: Focus on single responsibility
- Use Composition: Combine multiple simple validators rather than creating complex ones
- Proper Error Messages: Provide meaningful, user-friendly error messages
- Test Thoroughly: Write comprehensive tests for your validators
- Consider Performance: Avoid expensive operations in validators
- Use Standard Constraints First: Leverage built-in constraints when possible
- Document Custom Constraints: Provide clear documentation for your custom constraints
Common Patterns
Example 14: Enum Validation
// Custom constraint for enum values
@Documented
@Constraint(validatedBy = EnumValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidEnum {
String message() default "Invalid enum value";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass();
}
// Enum validator
public class EnumValidator implements ConstraintValidator<ValidEnum, String> {
private Class<? extends Enum<?>> enumClass;
private Set<String> validValues;
@Override
public void initialize(ValidEnum constraintAnnotation) {
this.enumClass = constraintAnnotation.enumClass();
this.validValues = Arrays.stream(enumClass.getEnumConstants())
.map(Enum::name)
.collect(Collectors.toSet());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return validValues.contains(value);
}
}
Conclusion
Hibernate Validator custom constraints provide powerful capabilities for:
Key Benefits:
- Domain-specific validation: Tailor validation to your business rules
- Reusable validation logic: Apply consistent rules across your application
- Clean separation: Keep validation logic separate from business logic
- Complex validation: Handle multi-field and conditional validation scenarios
Common Use Cases:
- Cross-field validation (password confirmation, email confirmation)
- Business rule validation (age restrictions, domain restrictions)
- Conditional validation (required fields based on other field values)
- Enum validation (restrict input to specific enum values)
- Custom format validation (phone numbers, IDs, codes)
By creating custom constraints, you can build a robust validation framework that ensures data integrity and provides clear, user-friendly error messages throughout your application.
Remember: Always test your custom validators thoroughly and consider performance implications, especially for validators that use reflection or make external calls.