Files
hibernate-demo/docs/20-hibernate-validator-cdi.md
T

7.6 KiB

20 — Hibernate Validator CDI integration

← Previous: 19 — HikariCP connection pooling | Back to README →

Backs the rewrite of ankurm.com post 4887 (Hibernate Validator CDI integration).

This is the odd chapter out in the batch: no Spring, no database, no Hibernate ORM at all. The question the original article raised is purely about Jakarta Bean Validation and CDI -- @Inject inside a ConstraintValidator, with and without a CDI container actually running -- so this chapter measures that in isolation, the same way chapter 17 isolated raw JPA bootstrap from Spring.

The claim, and the two things it depends on

A ConstraintValidator that needs a collaborator -- a policy object, a lookup service, anything that isn't a static constant -- naturally reaches for @Inject. Whether that actually works depends entirely on how the ValidatorFactory was built, not on the annotation itself:

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();
    }
}

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. InventoryPolicy.java

Without CDI: @Inject is not processed at all, and the failure is a plain NPE

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
validator.validate(new StockLevel(3)); // throws

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

(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, which is exactly what the transcript shows.

Trap: this failure 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 NPE the first time production data takes the other branch.

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

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.

(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 and 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. Get the Validator from Validation.buildDefaultValidatorFactory() even while a CDI container happens to be running elsewhere in the same JVM, and you're back to the first, broken case -- what matters is which ValidatorFactory built the validator, not merely whether a container exists somewhere.

Trap: a project can have Weld or another CDI implementation on its classpath and still get the plain, non-injecting behavior everywhere it calls Validation.buildDefaultValidatorFactory() directly instead of obtaining the Validator/ValidatorFactory as a CDI-managed bean.
  • Going deeper: 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.
  • Going deeper: hibernate-validator-cdi also ships a method-validation interceptor (ValidationInterceptor, visible in the jar's contents) for validating @Valid parameters on CDI-managed bean methods -- out of scope for this chapter, which is about constructor/field injection into the validator itself, not method interception.

Production checklist

  • If a ConstraintValidator needs a collaborator, know which ValidatorFactory will actually build it in production. In a full Jakarta EE server or a Spring Boot app with spring-boot-starter-validation (which wires its own Spring-aware ConstraintValidatorFactory, a separate mechanism from the CDI one measured here), injection works by a different, already container-managed path -- this chapter's contrast is specifically about the CDI portable extension versus the plain, no-container default.
  • 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.

← Previous: 19 — HikariCP connection pooling | Back to README →