Custom Validators
The built-in constraints cover common cases, but real applications have domain rules: “username must be unique,” “end date must follow start date,” “this code must match a checksum.” Jakarta Bean Validation lets you write your own constraint annotations backed by a ConstraintValidator, which then behave exactly like @NotNull or @Email. This page builds a field-level validator and a class-level cross-field validator.
Anatomy of a custom constraint
A custom constraint has two parts:
- An annotation meta-annotated with
@Constraint, declaringmessage,groups, andpayload. - A
ConstraintValidatorimplementing the actual check.
Step 1 — the annotation
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = StrongPasswordValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface StrongPassword {
String message() default "password is not strong enough";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int minLength() default 8; // custom attribute
}
The message, groups, and payload members are required by the spec — every constraint must declare them. Additional attributes like minLength are yours to define and read inside the validator.
Step 2 — the validator
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class StrongPasswordValidator
implements ConstraintValidator<StrongPassword, String> {
private int minLength;
@Override
public void initialize(StrongPassword annotation) {
this.minLength = annotation.minLength();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // let @NotNull handle null-ness
}
return value.length() >= minLength
&& value.chars().anyMatch(Character::isDigit)
&& value.chars().anyMatch(Character::isUpperCase);
}
}
ConstraintValidator<A, T> is parameterized by the annotation type and the value type it validates. initialize reads the annotation’s attributes; isValid returns true/false.
Note: Follow convention and return
truefornullinsideisValid. This keeps “presence” and “format” as separate, composable concerns — combine your constraint with@NotNullwhen the field is also required.
Using it
public record ChangePasswordRequest(
@NotBlank
@StrongPassword(minLength = 10)
String newPassword) {}
Injecting Spring beans into a validator
Because Spring manages ConstraintValidator instances, you can inject beans — useful for checks that hit a repository or service.
@RequiredArgsConstructor
public class UniqueEmailValidator
implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository; // injected by Spring
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return email == null || !userRepository.existsByEmail(email);
}
}
Warning: Avoid heavy database calls in validators that run on every request, and never use a uniqueness validator as your only guard against duplicates — it has a race condition. Back it with a unique database constraint as the source of truth.
Class-level (cross-field) validation
Some rules span multiple fields — comparing two values. Target the type instead of a field, and validate the whole object.
@Constraint(validatedBy = DateRangeValidator.class)
@Target(ElementType.TYPE) // annotate the class/record
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDateRange {
String message() default "end date must be after start date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, BookingRequest> {
@Override
public boolean isValid(BookingRequest req, ConstraintValidatorContext context) {
if (req.start() == null || req.end() == null) {
return true;
}
return req.end().isAfter(req.start());
}
}
@ValidDateRange
public record BookingRequest(
@NotNull LocalDate start,
@NotNull LocalDate end) {}
Custom messages via the context
By default a class-level violation has no field path, so the error attaches to the whole object. To point the message at a specific field, disable the default message and build a node-scoped violation through the ConstraintValidatorContext:
@Override
public boolean isValid(BookingRequest req, ConstraintValidatorContext context) {
if (req.start() == null || req.end() == null || req.end().isAfter(req.start())) {
return true;
}
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("must be after start")
.addPropertyNode("end") // attach error to the "end" field
.addConstraintViolation();
return false;
}
Now the failure reports against end rather than the object root, which makes for a much cleaner field-error response. You can also interpolate the annotation’s attributes using {...} templates in the message string.
Composing existing constraints
For combinations of built-in rules, you don’t even need a validator class — meta-annotate to build a composed constraint:
@NotBlank
@Size(min = 3, max = 20)
@Pattern(regexp = "^[a-z0-9_]+$")
@Constraint(validatedBy = {}) // no dedicated validator
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Username {
String message() default "invalid username";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Applying @Username now enforces all three underlying constraints at once.