How do you write a custom constraint with a ConstraintValidator, and how can it use Spring beans?
answer
- @Constraint(validatedBy=...) + message/groups/payload
- ConstraintValidator<A,T>.isValid(value, ctx)
- SpringConstraintValidatorFactory -> @Autowired works
- null usually returns true (compose with @NotNull)
- disableDefault + buildConstraintViolationWithTemplate
basics
~20 sCreate an annotation meta-annotated with @Constraint pointing at a class implementing ConstraintValidator<A, T>. Put the logic in isValid(). Because Spring's LocalValidatorFactoryBean creates validators as Spring beans, you can @Autowired dependencies like a repository into it.
solid answer
~40 sA custom constraint is two artifacts: (1) a constraint **annotation** meta-annotated with jakarta.validation.@Constraint(validatedBy = MyValidator.class) and carrying the required message(), groups(), and payload() members; (2) a class implementing ConstraintValidator<MyAnnotation, TargetType> whose isValid(value, ConstraintValidatorContext) returns the verdict. The provider instantiates the validator; because Spring's LocalValidatorFactoryBean installs a SpringConstraintValidatorFactory, the validator is created as a Spring bean and can @Autowired collaborators (e.g., a UserRepository to check email uniqueness). Best practices: treat null as valid in isValid (let @NotNull handle presence separately); to customize the violation message use context.disableDefaultConstraintViolation() then buildConstraintViolationWithTemplate(...).addConstraintViolation(). You can compose constraints by meta-annotating your annotation with existing ones. Register @Target/@Retention(RUNTIME) properly. This works both for programmatic validation and @Validated method validation.
code
java · 25 linesimport jakarta.validation.*;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import org.springframework.beans.factory.annotation.Autowired;
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "email already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Instantiated as a Spring bean by SpringConstraintValidatorFactory -> DI works
class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository users;
@Autowired UniqueEmailValidator(UserRepository users) { this.users = users; }
@Override public boolean isValid(String email, ConstraintValidatorContext ctx) {
if (email == null) return true; // let @NotNull handle presence
return !users.existsByEmail(email);
}
}go deeper
Know a custom constraint = an @Constraint-annotated annotation plus a ConstraintValidator with isValid().
Add the three mandatory annotation members, null-handling convention, and initialize() for attributes.
Explain SpringConstraintValidatorFactory enabling DI, custom violation messages, and statelessness requirements.
Discuss composition/@ReportAsSingleViolation, race conditions in uniqueness checks vs DB constraints, and validator lifecycle/thread-safety.
**Two pieces to every custom constraint:** **1. The annotation.** It must be meta-annotated with `@jakarta.validation.Constraint(validatedBy = ...)` and declare three mandatory members with these exact signatures: ```java String message() default "{com.acme.ValidSku.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; ``` Add `@Target({FIELD, PARAMETER, ...})` and `@Retention(RUNTIME)`. `message()` supports `{...}` message-template keys resolved via the message interpolator / Spring `MessageSource`, and `{parameter}` placeholders referencing annotation attributes. **2. The validator.** Implement `jakarta.validation.ConstraintValidator<A, T>` where `A` is your annotation and `T` the type(s) it applies to: ```java public boolean isValid(T value, ConstraintValidatorContext context) ``` Return `true` for valid. Optional `initialize(A annotation)` lets you capture annotation attributes. One validator can support multiple target types by registering multiple `@Constraint(validatedBy = {...})` entries, and the provider picks by type. **Null-handling convention:** By spec, most standard constraints consider `null` **valid** (except `@NotNull`/`@NotBlank`). Follow suit: `if (value == null) return true;` — compose presence checks with a separate `@NotNull`. This keeps constraints orthogonal. **Spring dependency injection — the key integration point:** Normally the Jakarta provider instantiates `ConstraintValidator`s with a no-arg default `ConstraintValidatorFactory`, giving them no Spring context. Spring's `LocalValidatorFactoryBean` replaces it with **`SpringConstraintValidatorFactory`**, which obtains the validator from the `AutowireCapableBeanFactory`. Result: your validator can `@Autowired` a repository, service, or config. This is the single biggest reason to rely on the Spring-configured validator rather than `Validation.buildDefaultValidatorFactory()`. (Note validators may be reused; keep them stateless/thread-safe — don't store per-call state in fields.) **Customizing the violation:** By default a failure produces one violation with the annotation's `message` on the annotated property path. To change it: ```java context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("custom {msg}") .addPropertyNode("field") .addConstraintViolation(); ``` **Constraint composition:** Meta-annotate your annotation with existing constraints (e.g., `@NotNull @Size(min=3)`) plus `@ReportAsSingleViolation` to collapse them into one reported error. This builds richer constraints without any validator code. **Gotchas:** (1) Forgetting `@Retention(RUNTIME)` → the provider can't see the annotation. (2) Storing mutable state in the validator (they're shared). (3) Doing heavy I/O in `isValid` on a hot path. (4) Relying on injection while bootstrapping a validator outside Spring — then no beans are available. (5) Uniqueness checks in a validator race with concurrent inserts; still enforce a DB constraint. **When to use:** domain rules not covered by standard annotations (valid SKU format, business-state checks, cross-field rules), and rules needing a bean (uniqueness, feature flags). Works identically under programmatic `validate()` and `@Validated` method validation.
- Why should isValid usually return true for null?To keep constraints orthogonal: standard constraints (except @NotNull/@NotBlank) treat null as valid, so presence is handled by a separate @NotNull. Otherwise a single missing value could produce duplicate/confusing violations.
- How does @Autowired end up working inside a ConstraintValidator?LocalValidatorFactoryBean sets a SpringConstraintValidatorFactory that creates validators through the AutowireCapableBeanFactory, so they're Spring-managed and can inject collaborators — unlike the default Jakarta factory.
saying these in an interview costs you the question
- Omitting message/groups/payload members from the annotation
- Forgetting @Retention(RUNTIME)
- Storing per-request mutable state in the (shared) validator
- Believing a uniqueness validator alone prevents races — still need a DB unique constraint
- Expecting injection when the validator is built outside Spring