google-site-verification: google61fe8ba583a51912.html
Advanced Validation: Creating Custom Constraints with Hibernate Validator in Java

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

  1. Keep validators stateless for thread safety
  2. Use dependency injection carefully in validators
  3. Implement proper null handling in validators
  4. Use validation groups for context-specific validation
  5. Provide meaningful error messages with custom templates
  6. 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.

Leave a Reply

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


Macro Nepal Helper