Skip to main content

Hibernate Validator + CDI: When @Inject Actually Works Inside a Validator

The same ConstraintValidator, built two ways: Validation.buildDefaultValidatorFactory() throws a NullPointerException on an @Inject field it never processes, while a real Weld SE CDI container resolves the same field correctly. This post isolates exactly which ValidatorFactory makes @Inject real inside Hibernate Validator.

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-cdi 9.1.3.Final, Weld SE 6.0.4.Final, org.glassfish.expressly 6.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.

Same validator class, two ways to build it Validation.buildDefaultValidatorFactory() plain reflection, no container policy field = null isValid() -> NullPointerException Validator from a running Weld container InjectingConstraintValidatorFactory policy field = real CDI bean isValid() -> correct result

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 the Validator from Validation.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 which ValidatorFactory built 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 ConstraintValidator needs a collaborator, know which ValidatorFactory will actually build it in production. A Spring Boot app with spring-boot-starter-validation wires its own Spring-aware ConstraintValidatorFactory — 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 plain ConstraintValidatorFactory — a defensive null-check turns an opaque ValidationException into a clear “this validator requires CDI” message.
  • hibernate-validator-cdi is a thin CDI portable extension over hibernate-validator itself, not a different validation engine — adding it changes how validators are instantiated, not what Bean Validation itself does.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.