A ConstraintValidator that needs @Inject to reach a real collaborator either works or throws a NullPointerException, and the difference has nothing to do with the annotation — it depends entirely on which ValidatorFactory built the validator. This post is the odd one out in its batch: no Spring, no database, no Hibernate ORM at all, just the identical validator class run two ways — once with Validation.buildDefaultValidatorFactory(), once inside a real Weld SE CDI container — to measure exactly where @Inject stops being decoration and starts being real.
Versions used in this post. Hibernate Validator 9.1.3.Final,hibernate-validator-cdi9.1.3.Final, Weld SE 6.0.4.Final,org.glassfish.expressly6.0.0 (Jakarta EL implementation), Jakarta Bean Validation 3.1, JDK 25. Every code and output block below links to the exact file in the companion repository it came from.
Why @Inject sometimes just doesn’t work in a validator
A custom ConstraintValidator that needs a collaborator — a policy object, a lookup service, anything beyond a hardcoded constant — naturally reaches for @Inject. Whether that actually works has nothing to do with the annotation itself. It depends entirely on which ValidatorFactory built the validator. This post isolates that question with no Spring, no database, and no Hibernate ORM at all — just Jakarta Bean Validation and CDI, run two ways against the identical validator class.
The validator and the bean it depends on
public class PositiveInventoryValidator implements ConstraintValidator<PositiveInventory, Integer> {
@Inject
private InventoryPolicy policy; // no null-guard, on purpose
@Override
public boolean isValid(Integer quantity, ConstraintValidatorContext context) {
return quantity == null || quantity >= policy.minimumThreshold();
}
}
From PositiveInventoryValidator.java. InventoryPolicy is a plain @ApplicationScoped CDI bean with one method, minimumThreshold(), returning 5 — deliberately not a hardcoded constant in the validator itself, so injection either genuinely happens or the validator has nothing to call.
Without CDI: @Inject is never even looked at
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
validator.validate(new StockLevel(3)); // throws
From PlainValidationNoCdiTest.java.
RESULT[cdi-plain-validation-no-injection]: validating StockLevel(3) with Validation.buildDefaultValidatorFactory() (no CDI container running) throws jakarta.validation.ValidationException -> caused by java.lang.NullPointerException
From the full transcript.
Validation.buildDefaultValidatorFactory()‘s default ConstraintValidatorFactory builds a validator with plain reflection — roughly Class.getDeclaredConstructor().newInstance(). It has no idea what CDI or @Inject even are; the annotation is simply never looked at, so policy stays null. The NullPointerException from calling policy.minimumThreshold() is not a validation failure — it’s a validator bug, and Hibernate Validator itself is honest about that: it wraps the unexpected exception in a jakarta.validation.ValidationException rather than letting the raw NPE escape unannounced.
Trap: this only shows up when the constraint actually runs against a non-null value. A validator that only reaches the injected field on certain code paths can pass every test that happens not to exercise those paths, then throw the first time production data takes the other branch.
- Going deeper: the full chapter 20 doc walks through the exact stack trace this test captured.
With CDI: the same code, actually injected
WeldContainer container = new Weld().initialize();
Validator validator = container.select(Validator.class).get();
validator.validate(new StockLevel(3)); // 1 violation
validator.validate(new StockLevel(5)); // 0 violations
validator.validate(new StockLevel(10)); // 0 violations
From CdiValidationTest.java.
RESULT[cdi-validation-injection-works]: validator obtained from a running Weld SE container | StockLevel(3) violations=1 | StockLevel(5) violations=0 | StockLevel(10) violations=0 -- InventoryPolicy.minimumThreshold()=5 was actually injected and actually used, no NullPointerException anywhere.
From the full transcript.
The mechanism, not just the result: hibernate-validator-cdi-9.1.3.Final.jar registers org.hibernate.validator.cdi.ValidationExtension as a jakarta.enterprise.inject.spi.Extension, confirmed by its own META-INF/services/jakarta.enterprise.inject.spi.Extension file. Once Weld SE discovers that extension on the classpath, it contributes CDI beans for Validator/ValidatorFactory whose ConstraintValidatorFactory is org.hibernate.validator.cdi.spi.InjectingConstraintValidatorFactory — a factory that builds each ConstraintValidator instance through the CDI BeanManager instead of plain reflection, resolving @Inject fields the same way any other managed bean’s are resolved.
Trap: a CDI container running elsewhere in the JVM doesn’t fix the first case. Get theValidatorfromValidation.buildDefaultValidatorFactory()even while Weld or another CDI implementation happens to be on the classpath and running, and you’re back to the plain, non-injecting behavior. What matters is whichValidatorFactorybuilt the validator, not merely whether a container exists somewhere in the process.
For the intermediate reader: this repo pins hibernate-validator-cdi, weld-se-core, and org.glassfish.expressly to specific, mutually-verified versions in pom.xml — hibernate-validator-cdi:9.1.3.Final requires jakarta.enterprise.cdi-api:4.1.0 (checked against its own pom.xml), and weld-se-core:6.0.4.Final is the version that actually resolves that exact 4.1.0, confirmed with a throwaway mvn dependency:tree before writing any of this chapter’s code. A bean-discovery-mode=”all” beans.xml is also required — without it Weld never scans this project’s classes for @ApplicationScoped beans at all.
- Going deeper: the full chapter 20 doc lists the disassembled class evidence behind this section and covers
hibernate-validator-cdi‘s method-validation interceptor, out of scope here.
Should you bother with CDI-managed validators?
Only if a validator genuinely needs a collaborator that changes at runtime or needs mocking in tests — a feature flag service, a repository lookup, anything that isn’t a compile-time constant. If every constraint you write can be expressed with static rules and the built-in annotations, skip the container entirely; it’s one more moving part to keep correctly configured, and this post’s first section is exactly what happens when it’s missing.
- If a
ConstraintValidatorneeds a collaborator, know whichValidatorFactorywill actually build it in production. A Spring Boot app withspring-boot-starter-validationwires its own Spring-awareConstraintValidatorFactory— a separate mechanism from the CDI one measured here, not a drop-in equivalent to compare loosely against. - Never let a validator dereference an
@Injected field without at least considering what happens if it’s ever built by a plainConstraintValidatorFactory— a defensive null-check turns an opaqueValidationExceptioninto a clear “this validator requires CDI” message. hibernate-validator-cdiis a thin CDI portable extension overhibernate-validatoritself, not a different validation engine — adding it changes how validators are instantiated, not what Bean Validation itself does.
Further reading
- 📘 Chapter 20 — Hibernate Validator CDI integration, the full companion chapter behind this post
- 📘 HikariCP with Hibernate 7 — another isolated-mechanism measurement from the same batch
- 🔗 Official Hibernate Validator documentation
- 🔗 Jakarta Bean Validation 3.0 specification
No Comments yet!