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.