Introduction
Hibernate Validator provides powerful validation capabilities beyond the standard Bean Validation API. Custom constraints allow you to define domain-specific validation rules, complex business logic validations, and cross-field validations that aren't possible with built-in constraints.
Key Concepts
Custom Validation Components
- Constraint Annotation - Defines the validation contract
- Constraint Validator - Implements the validation logic
- Constraint Payload - Carries metadata for validation
- Groups - Organize validations by context
- Message Interpolation - Custom error messages
Dependencies
Maven Dependencies
<properties> <hibernate.validator.version>8.0.1.Final</hibernate.validator.version> <validation.api.version>3.0.2</validation.api.version> </properties> <dependencies> <!-- Bean Validation API --> <dependency> <groupId>jakarta.validation</groupId> <artifactId>jakarta.validation-api</artifactId> <version>${validation.api.version}</version> </dependency> <!-- Hibernate Validator --> <dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>${hibernate.validator.version}</version> </dependency> <!-- Expression Language (required for Hibernate Validator) --> <dependency> <groupId>org.glassfish</groupId> <artifactId>jakarta.el</artifactId> <version>4.0.2</version> </dependency> </dependencies> Basic Custom Constraint
1. Simple Custom Constraint Annotation
package com.example.validation.constraints; import com.example.validation.validators.StrongPasswordValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = StrongPasswordValidator.class) @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface StrongPassword { String message() default "Password must be strong"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; // Custom parameters int minLength() default 8; boolean requireUppercase() default true; boolean requireLowercase() default true; boolean requireDigits() default true; boolean requireSpecialChars() default true; // For multiple constraints of the same type @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { StrongPassword[] value(); } } 2. Constraint Validator Implementation
package com.example.validation.validators; import com.example.validation.constraints.StrongPassword; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> { private int minLength; private boolean requireUppercase; private boolean requireLowercase; private boolean requireDigits; private boolean requireSpecialChars; @Override public void initialize(StrongPassword constraintAnnotation) { this.minLength = constraintAnnotation.minLength(); this.requireUppercase = constraintAnnotation.requireUppercase(); this.requireLowercase = constraintAnnotation.requireLowercase(); this.requireDigits = constraintAnnotation.requireDigits(); this.requireSpecialChars = constraintAnnotation.requireSpecialChars(); } @Override public boolean isValid(String password, ConstraintValidatorContext context) { if (password == null || password.trim().isEmpty()) { return true; // Use @NotNull for null validation } // Build validation messages StringBuilder message = new StringBuilder(); boolean isValid = true; if (password.length() < minLength) { message.append("Password must be at least ").append(minLength).append(" characters long. "); isValid = false; } if (requireUppercase && !password.matches(".*[A-Z].*")) { message.append("Password must contain at least one uppercase letter. "); isValid = false; } if (requireLowercase && !password.matches(".*[a-z].*")) { message.append("Password must contain at least one lowercase letter. "); isValid = false; } if (requireDigits && !password.matches(".*\\d.*")) { message.append("Password must contain at least one digit. "); isValid = false; } if (requireSpecialChars && !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*")) { message.append("Password must contain at least one special character. "); isValid = false; } if (!isValid) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate(message.toString().trim()) .addConstraintViolation(); } return isValid; } } Advanced Custom Constraints
1. Cross-Field Validation
package com.example.validation.constraints; import com.example.validation.validators.FieldsValueMatchValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = FieldsValueMatchValidator.class) @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface FieldsValueMatch { String message() default "Fields values don't match"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String field(); String fieldMatch(); // Custom message for each field String fieldMessage() default ""; String fieldMatchMessage() default ""; @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @interface List { FieldsValueMatch[] value(); } } 2. Cross-Field Validator
package com.example.validation.validators; import com.example.validation.constraints.FieldsValueMatch; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import org.springframework.beans.BeanWrapperImpl; public class FieldsValueMatchValidator implements ConstraintValidator<FieldsValueMatch, Object> { private String field; private String fieldMatch; private String fieldMessage; private String fieldMatchMessage; @Override public void initialize(FieldsValueMatch constraintAnnotation) { this.field = constraintAnnotation.field(); this.fieldMatch = constraintAnnotation.fieldMatch(); this.fieldMessage = constraintAnnotation.fieldMessage(); this.fieldMatchMessage = constraintAnnotation.fieldMatchMessage(); } @Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; } Object fieldValue = new BeanWrapperImpl(value).getPropertyValue(field); Object fieldMatchValue = new BeanWrapperImpl(value).getPropertyValue(fieldMatch); boolean isValid = fieldValue != null && fieldValue.equals(fieldMatchValue); if (!isValid) { context.disableDefaultConstraintViolation(); // Build custom constraint violations for specific fields if (!fieldMessage.isEmpty()) { context.buildConstraintViolationWithTemplate(fieldMessage) .addPropertyNode(field) .addConstraintViolation(); } if (!fieldMatchMessage.isEmpty()) { context.buildConstraintViolationWithTemplate(fieldMatchMessage) .addPropertyNode(fieldMatch) .addConstraintViolation(); } // Default message if (fieldMessage.isEmpty() && fieldMatchMessage.isEmpty()) { context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()) .addPropertyNode(fieldMatch) .addConstraintViolation(); } } return isValid; } } 3. Date/Time Validation
package com.example.validation.constraints; import com.example.validation.validators.FutureAfterValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; import java.time.temporal.ChronoUnit; @Documented @Constraint(validatedBy = FutureAfterValidator.class) @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface FutureAfter { String message() default "Date must be in the future after the specified duration"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int amount() default 0; ChronoUnit unit() default ChronoUnit.DAYS; boolean inclusive() default false; @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { FutureAfter[] value(); } } 4. Date/Time Validator
package com.example.validation.validators; import com.example.validation.constraints.FutureAfter; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.Date; public class FutureAfterValidator implements ConstraintValidator<FutureAfter, Object> { private int amount; private ChronoUnit unit; private boolean inclusive; @Override public void initialize(FutureAfter constraintAnnotation) { this.amount = constraintAnnotation.amount(); this.unit = constraintAnnotation.unit(); this.inclusive = constraintAnnotation.inclusive(); } @Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; // Use @NotNull for null validation } java.time.temporal.Temporal now = getNow(); java.time.temporal.Temporal targetDate = getTargetDate(value); java.time.temporal.Temporal minimumDate = now.plus(amount, unit); if (inclusive) { return !targetDate.isBefore(minimumDate); } else { return targetDate.isAfter(minimumDate); } } private java.time.temporal.Temporal getNow() { return LocalDateTime.now(); } private java.time.temporal.Temporal getTargetDate(Object value) { if (value instanceof LocalDate) { return ((LocalDate) value).atStartOfDay(); } else if (value instanceof LocalDateTime) { return (LocalDateTime) value; } else if (value instanceof Date) { return ((Date) value).toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime(); } else { throw new IllegalArgumentException("Unsupported date type: " + value.getClass()); } } } Business Logic Validations
1. Unique Email Constraint
package com.example.validation.constraints; import com.example.validation.validators.UniqueEmailValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = UniqueEmailValidator.class) @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface UniqueEmail { String message() default "Email already exists"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String excludeId() default ""; @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { UniqueEmail[] value(); } } 2. Unique Email Validator with Service Integration
package com.example.validation.validators; import com.example.service.UserService; import com.example.validation.constraints.UniqueEmail; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> { @Autowired private UserService userService; private String excludeId; @Override public void initialize(UniqueEmail constraintAnnotation) { this.excludeId = constraintAnnotation.excludeId(); } @Override public boolean isValid(String email, ConstraintValidatorContext context) { if (email == null || email.trim().isEmpty()) { return true; // Use @NotNull for null validation } boolean emailExists = userService.emailExists(email); if (excludeId != null && !excludeId.trim().isEmpty()) { // For update operations, exclude current entity Long currentId = Long.parseLong(excludeId); boolean isOwnEmail = userService.isEmailOwnedByUser(email, currentId); return !emailExists || isOwnEmail; } return !emailExists; } } 3. Valid Phone Number Constraint
package com.example.validation.constraints; import com.example.validation.validators.ValidPhoneNumberValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = ValidPhoneNumberValidator.class) @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface ValidPhoneNumber { String message() default "Invalid phone number"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String[] supportedCountries() default {"US", "CA", "GB"}; boolean requireCountryCode() default true; @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { ValidPhoneNumber[] value(); } } 4. Phone Number Validator
package com.example.validation.validators; import com.example.validation.constraints.ValidPhoneNumber; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class ValidPhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> { private PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance(); private String[] supportedCountries; private boolean requireCountryCode; @Override public void initialize(ValidPhoneNumber constraintAnnotation) { this.supportedCountries = constraintAnnotation.supportedCountries(); this.requireCountryCode = constraintAnnotation.requireCountryCode(); } @Override public boolean isValid(String phoneNumber, ConstraintValidatorContext context) { if (phoneNumber == null || phoneNumber.trim().isEmpty()) { return true; // Use @NotNull for null validation } try { // Try to parse the phone number Phonenumber.PhoneNumber parsedNumber = phoneUtil.parse(phoneNumber, null); // Check if the number is valid if (!phoneUtil.isValidNumber(parsedNumber)) { return false; } // Check country support String countryCode = phoneUtil.getRegionCodeForNumber(parsedNumber); if (supportedCountries.length > 0) { boolean countrySupported = false; for (String supportedCountry : supportedCountries) { if (supportedCountry.equalsIgnoreCase(countryCode)) { countrySupported = true; break; } } if (!countrySupported) { return false; } } // Check country code requirement if (requireCountryCode && parsedNumber.getCountryCode() == 0) { return false; } return true; } catch (Exception e) { return false; } } } Complex Object Validation
1. Valid Address Constraint
package com.example.validation.constraints; import com.example.validation.validators.ValidAddressValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = ValidAddressValidator.class) @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface ValidAddress { String message() default "Invalid address"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; boolean validateGeolocation() default false; String[] supportedCountries() default {}; @Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { ValidAddress[] value(); } } 2. Address Validator
package com.example.validation.validators; import com.example.validation.constraints.ValidAddress; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.util.Arrays; import java.util.HashSet; import java.util.Set; public class ValidAddressValidator implements ConstraintValidator<ValidAddress, Object> { private boolean validateGeolocation; private Set<String> supportedCountries; @Override public void initialize(ValidAddress constraintAnnotation) { this.validateGeolocation = constraintAnnotation.validateGeolocation(); this.supportedCountries = new HashSet<>(Arrays.asList(constraintAnnotation.supportedCountries())); } @Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; // Use @NotNull for null validation } try { // Use reflection to access address properties java.lang.reflect.Field streetField = value.getClass().getDeclaredField("street"); java.lang.reflect.Field cityField = value.getClass().getDeclaredField("city"); java.lang.reflect.Field stateField = value.getClass().getDeclaredField("state"); java.lang.reflect.Field zipCodeField = value.getClass().getDeclaredField("zipCode"); java.lang.reflect.Field countryField = value.getClass().getDeclaredField("country"); streetField.setAccessible(true); cityField.setAccessible(true); stateField.setAccessible(true); zipCodeField.setAccessible(true); countryField.setAccessible(true); String street = (String) streetField.get(value); String city = (String) cityField.get(value); String state = (String) stateField.get(value); String zipCode = (String) zipCodeField.get(value); String country = (String) countryField.get(value); // Basic validation if (isEmpty(street) || isEmpty(city) || isEmpty(state) || isEmpty(zipCode) || isEmpty(country)) { return false; } // Country validation if (!supportedCountries.isEmpty() && !supportedCountries.contains(country.toUpperCase())) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("Country not supported: " + country) .addConstraintViolation(); return false; } // Zip code format validation based on country if (!isValidZipCode(zipCode, country)) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("Invalid zip code format for country: " + country) .addConstraintViolation(); return false; } // Optional geolocation validation if (validateGeolocation && !isValidGeolocation(street, city, state, country)) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("Address could not be geolocated") .addConstraintViolation(); return false; } return true; } catch (Exception e) { return false; } } private boolean isEmpty(String value) { return value == null || value.trim().isEmpty(); } private boolean isValidZipCode(String zipCode, String country) { // Implement country-specific zip code validation switch (country.toUpperCase()) { case "US": return zipCode.matches("\\d{5}(-\\d{4})?"); case "CA": return zipCode.matches("[A-Z]\\d[A-Z] \\d[A-Z]\\d"); default: return zipCode.length() >= 3; // Basic validation for other countries } } private boolean isValidGeolocation(String street, String city, String state, String country) { // Implement geolocation validation using external service // This is a simplified example try { // Mock geolocation validation return !street.toLowerCase().contains("invalid"); } catch (Exception e) { return false; } } } Validation Groups
1. Validation Group Definitions
package com.example.validation.groups; public interface ValidationGroups { interface Create {} interface Update {} interface PartialUpdate {} interface AdminOperation {} interface UserOperation {} } 2. Group-Based Constraints
package com.example.validation.constraints; import com.example.validation.validators.AdminAccessValidator; import com.example.validation.groups.ValidationGroups; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = AdminAccessValidator.class) @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface AdminAccess { String message() default "Admin access required for this operation"; Class<?>[] groups() default { ValidationGroups.AdminOperation.class }; Class<? extends Payload>[] payload() default {}; String requiredRole() default "ADMIN"; @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { AdminAccess[] value(); } } Custom Payloads
1. Severity Payload
package com.example.validation.payload; import jakarta.validation.Payload; public class Severity { public static class Info implements Payload {} public static class Warning implements Payload {} public static class Error implements Payload {} public static class Critical implements Payload {} } 2. Business Payload
package com.example.validation.payload; import jakarta.validation.Payload; public class Business { public static class HighPriority implements Payload {} public static class RequiresApproval implements Payload {} public static class AuditRequired implements Payload {} public static class LegalCompliance implements Payload {} } Domain Models with Custom Constraints
1. User Entity with Custom Validations
package com.example.model; import com.example.validation.constraints.*; import com.example.validation.groups.ValidationGroups; import com.example.validation.payload.Severity; import jakarta.persistence.*; import jakarta.validation.constraints.*; import java.time.LocalDate; import java.time.LocalDateTime; @Entity @Table(name = "users") @FieldsValueMatch( field = "password", fieldMatch = "confirmPassword", fieldMessage = "Password must match", fieldMatchMessage = "Confirm password must match", groups = ValidationGroups.Create.class ) @AdminAccess(requiredRole = "SUPER_ADMIN", groups = ValidationGroups.AdminOperation.class) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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") @Column(unique = true, nullable = false) private String username; @NotBlank(message = "Email is required", groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}) @Email(message = "Invalid email format") @UniqueEmail( message = "Email already exists", excludeId = "#{this.id}", groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, payload = {Severity.Error.class} ) @Column(unique = true, nullable = false) private String email; @NotBlank(message = "Password is required", groups = ValidationGroups.Create.class) @StrongPassword( minLength = 10, requireUppercase = true, requireLowercase = true, requireDigits = true, requireSpecialChars = true, groups = ValidationGroups.Create.class, payload = {Severity.Critical.class} ) @Transient // Not persisted private String password; @NotBlank(message = "Confirm password is required", groups = ValidationGroups.Create.class) @Transient // Not persisted private String confirmPassword; @Size(max = 255, message = "First name cannot exceed 255 characters") @Pattern(regexp = "^[a-zA-Z\\s-']+$", message = "First name can only contain letters, spaces, hyphens, and apostrophes") @Column(name = "first_name") private String firstName; @Size(max = 255, message = "Last name cannot exceed 255 characters") @Pattern(regexp = "^[a-zA-Z\\s-']+$", message = "Last name can only contain letters, spaces, hyphens, and apostrophes") @Column(name = "last_name") private String lastName; @ValidPhoneNumber( supportedCountries = {"US", "CA", "GB"}, requireCountryCode = false, payload = {Severity.Warning.class} ) @Column(name = "phone_number") private String phoneNumber; @Past(message = "Birth date must be in the past") @Column(name = "birth_date") private LocalDate birthDate; @FutureAfter( amount = 1, unit = java.time.temporal.ChronoUnit.DAYS, message = "Account expiration must be at least 1 day in the future" ) @Column(name = "account_expires_at") private LocalDateTime accountExpiresAt; @NotNull(message = "Address is required") @ValidAddress( supportedCountries = {"US", "CA"}, validateGeolocation = true, payload = {Business.RequiresApproval.class} ) @Embedded private Address address; @AssertTrue(message = "Must accept terms and conditions", groups = ValidationGroups.Create.class) @Column(name = "terms_accepted") private Boolean termsAccepted; // Constructors public User() {} public User(String username, String email, String password) { this.username = username; this.email = email; this.password = password; this.termsAccepted = false; } // 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 String getConfirmPassword() { return confirmPassword; } public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; } 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; } public String getPhoneNumber() { return phoneNumber; } public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } public LocalDate getBirthDate() { return birthDate; } public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } public LocalDateTime getAccountExpiresAt() { return accountExpiresAt; } public void setAccountExpiresAt(LocalDateTime accountExpiresAt) { this.accountExpiresAt = accountExpiresAt; } public Address getAddress() { return address; } public void setAddress(Address address) { this.address = address; } public Boolean getTermsAccepted() { return termsAccepted; } public void setTermsAccepted(Boolean termsAccepted) { this.termsAccepted = termsAccepted; } } 2. Address Value Object
package com.example.model; import jakarta.persistence.Embeddable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @Embeddable public class Address { @NotBlank(message = "Street is required") @Size(max = 255, message = "Street cannot exceed 255 characters") private String street; @NotBlank(message = "City is required") @Size(max = 100, message = "City cannot exceed 100 characters") private String city; @NotBlank(message = "State is required") @Size(max = 100, message = "State cannot exceed 100 characters") private String state; @NotBlank(message = "Zip code is required") @Size(max = 20, message = "Zip code cannot exceed 20 characters") private String zipCode; @NotBlank(message = "Country is required") @Size(max = 100, message = "Country cannot exceed 100 characters") private String country; // Constructors 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; } // Getters and setters 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; } } Validation Service
1. Custom Validation Service
package com.example.service; import com.example.validation.groups.ValidationGroups; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Set; import java.util.stream.Collectors; @Service public class ValidationService { @Autowired private Validator validator; public <T> ValidationResult validate(T object) { return validate(object, new Class[0]); } public <T> ValidationResult validate(T object, Class<?>... groups) { Set<ConstraintViolation<T>> violations = validator.validate(object, groups); return new ValidationResult(violations); } public <T> ValidationResult validateProperty(T object, String propertyName) { Set<ConstraintViolation<T>> violations = validator.validateProperty(object, propertyName); return new ValidationResult(violations); } public <T> ValidationResult validateValue(Class<T> beanType, String propertyName, Object value) { Set<ConstraintViolation<T>> violations = validator.validateValue(beanType, propertyName, value); return new ValidationResult(violations); } public static class ValidationResult { private final Set<? extends ConstraintViolation<?>> violations; private final boolean valid; public ValidationResult(Set<? extends ConstraintViolation<?>> violations) { this.violations = violations; this.valid = violations.isEmpty(); } public boolean isValid() { return valid; } public Set<String> getErrorMessages() { return violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.toSet()); } public Set<ConstraintViolation<?>> getViolations() { return Set.copyOf(violations); } public void throwIfInvalid() { if (!valid) { throw new ValidationException("Validation failed", violations); } } } public static class ValidationException extends RuntimeException { private final Set<? extends ConstraintViolation<?>> violations; public ValidationException(String message, Set<? extends ConstraintViolation<?>> violations) { super(message); this.violations = violations; } public Set<? extends ConstraintViolation<?>> getViolations() { return violations; } public Set<String> getErrorMessages() { return violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.toSet()); } } } Spring Configuration
1. Validation Configuration
package com.example.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; @Configuration public class ValidationConfig { @Bean public MethodValidationPostProcessor methodValidationPostProcessor() { return new MethodValidationPostProcessor(); } @Bean public jakarta.validation.Validator validator() { return jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator(); } } 2. Application Properties
# application.yml spring: jackson: deserialization: fail-on-unknown-properties: true default-property-inclusion: NON_NULL app: validation: fail-fast: true show-details: true logging: level: org.hibernate.validator: DEBUG
Testing Custom Constraints
1. Constraint Validator Test
package com.example.validation; import com.example.validation.constraints.StrongPassword; import com.example.validation.validators.StrongPasswordValidator; import jakarta.validation.ConstraintValidatorContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class StrongPasswordValidatorTest { @Mock private StrongPassword constraintAnnotation; @Mock private ConstraintValidatorContext context; @Mock private ConstraintValidatorContext.ConstraintViolationBuilder builder; private StrongPasswordValidator validator; @BeforeEach void setUp() { validator = new StrongPasswordValidator(); when(constraintAnnotation.minLength()).thenReturn(8); when(constraintAnnotation.requireUppercase()).thenReturn(true); when(constraintAnnotation.requireLowercase()).thenReturn(true); when(constraintAnnotation.requireDigits()).thenReturn(true); when(constraintAnnotation.requireSpecialChars()).thenReturn(true); validator.initialize(constraintAnnotation); when(context.buildConstraintViolationWithTemplate(anyString())).thenReturn(builder); when(builder.addConstraintViolation()).thenReturn(context); } @Test void testValidPassword() { assertTrue(validator.isValid("StrongPass123!", context)); verify(context, never()).buildConstraintViolationWithTemplate(anyString()); } @Test void testTooShortPassword() { assertFalse(validator.isValid("Short1!", context)); verify(context).buildConstraintViolationWithTemplate(anyString()); } @Test void testNoUppercasePassword() { assertFalse(validator.isValid("lowercase123!", context)); verify(context).buildConstraintViolationWithTemplate(anyString()); } @Test void testNoDigitsPassword() { assertFalse(validator.isValid("NoDigitsHere!", context)); verify(context).buildConstraintViolationWithTemplate(anyString()); } @Test void testNullPassword() { assertTrue(validator.isValid(null, context)); } @Test void testEmptyPassword() { assertTrue(validator.isValid("", context)); } } 2. Integration Test
package com.example.validation; import com.example.model.User; import com.example.validation.groups.ValidationGroups; import jakarta.validation.Validator; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest class UserValidationTest { @Autowired private Validator validator; @Test void testValidUser() { User user = createValidUser(); Set violations = validator.validate(user, ValidationGroups.Create.class); assertTrue(violations.isEmpty()); } @Test void testInvalidEmail() { User user = createValidUser(); user.setEmail("invalid-email"); Set violations = validator.validate(user, ValidationGroups.Create.class); assertFalse(violations.isEmpty()); assertEquals(1, violations.size()); } @Test void testPasswordMismatch() { User user = createValidUser(); user.setConfirmPassword("differentPassword"); Set violations = validator.validate(user, ValidationGroups.Create.class); assertFalse(violations.isEmpty()); } @Test void testWeakPassword() { User user = createValidUser(); user.setPassword("weak"); user.setConfirmPassword("weak"); Set violations = validator.validate(user, ValidationGroups.Create.class); assertFalse(violations.isEmpty()); } private User createValidUser() { User user = new User(); user.setUsername("testuser"); user.setEmail("[email protected]"); user.setPassword("StrongPassword123!"); user.setConfirmPassword("StrongPassword123!"); user.setFirstName("John"); user.setLastName("Doe"); user.setPhoneNumber("+1-555-123-4567"); user.setBirthDate(LocalDate.of(1990, 1, 1)); user.setAccountExpiresAt(LocalDateTime.now().plusDays(30)); user.setTermsAccepted(true); return user; } } Best Practices
1. Performance Optimization
package com.example.validation.optimization; import org.hibernate.validator.HibernateValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; @Configuration public class OptimizedValidationConfig { @Bean public Validator validator() { ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class) .configure() .failFast(true) // Stop on first validation error .buildValidatorFactory(); return validatorFactory.getValidator(); } } 2. Custom Message Interpolation
package com.example.validation.messages; import org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator; import org.hibernate.validator.resourceloading.PlatformResourceBundleLocator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import jakarta.validation.MessageInterpolator; @Configuration public class CustomMessageInterpolationConfig { @Bean public MessageInterpolator messageInterpolator() { return new AbstractMessageInterpolator( new PlatformResourceBundleLocator("ValidationMessages") ) { @Override public String interpolate(String message, Context context) { // Custom message interpolation logic String interpolated = super.interpolate(message, context); return customizeMessage(interpolated, context); } private String customizeMessage(String message, Context context) { // Add custom logic for message customization return message; } }; } } Conclusion
Hibernate Validator custom constraints provide:
Key Benefits
- Domain-specific validation rules tailored to your business needs
- Complex business logic validation beyond basic constraints
- Cross-field validation for validating relationships between fields
- Reusable validation logic across multiple entities
- Integration with Spring and other frameworks
Best Practices
- Keep validators stateless for thread safety
- Use dependency injection carefully in validators
- Implement proper null handling in validators
- Use validation groups for context-specific validation
- Provide meaningful error messages with custom templates
- Test validators thoroughly with various scenarios
Common Use Cases
- Password strength validation
- Email uniqueness checks
- Cross-field matching (password confirmation)
- Business rule validation (age restrictions, date ranges)
- External service integration (address validation, phone verification)
Custom constraints empower you to create robust, maintainable validation logic that accurately reflects your domain model and business requirements.