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