Skip to content
Spring Boot sb validation 3 min read

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:

  1. An annotation meta-annotated with @Constraint, declaring message, groups, and payload.
  2. A ConstraintValidator implementing 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 true for null inside isValid. This keeps “presence” and “format” as separate, composable concerns — combine your constraint with @NotNull when 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.

Last updated June 13, 2026
Was this helpful?