Skip to main content

Jakarta Bean Validation

Validation is opt-in and disabled by default. To enable, mark a @Content, @FormData, or @Request-bound parameter with @jakarta.validation.Valid. With no marker anywhere on a resource, validation never runs — no Validator is instantiated, no constraint check is invoked, and no per-request cost is paid, even when jakarta.validation-api and a concrete provider (e.g. Hibernate Validator) are sitting on the classpath.

This is a deliberate departure from Spring MVC, where @Valid is implied for @RequestBody and the framework wires up a Validator at startup whether the application has constraints or not. Juneau's contract is the opposite: zero classpath cost, zero per-request cost, and zero startup cost until a parameter explicitly opts in.

Motivation​

Juneau's REST layer needs to interoperate with the rest of the Jakarta EE / Spring ecosystem, where Jakarta Bean Validation 3.x is the de facto declarative validation standard. The contract surfaces as constraint annotations on bean fields:

public class OrderRequest {
@NotBlank String customerId;
@NotNull @Positive Integer quantity;
@Email String contactEmail;
}

…and an opt-in marker on the receiving parameter:

@RestPost("/orders")
public Order create(@Content @Valid OrderRequest in) {
// If any constraint fails, the handler is never called.
// Response is 400 with the violations.
return orderService.create(in);
}

Out of the box (no @Valid), the bean is bound and the handler runs regardless of whether its fields satisfy the constraints — the annotations on the bean class are inert decoration as far as Juneau is concerned. Adding @Valid flips the dispatch: after binding completes, the integration invokes validator.validate(bean), collects the violations, and short-circuits to a 400 Bad Request response if any are reported.

The off-by-default contract​

The off-by-default contract has six tangible guarantees:

  1. Cold-start default. A fresh RestContext built with no validation-related markers anywhere on the resource never instantiates a Validator and never invokes a constraint check. A request bean with @NotNull / @Size / etc. on its fields passes through the unmodified Juneau pipeline as if those annotations weren't there.
  2. Per-parameter granularity. @Valid on one parameter never opts in a sibling parameter, an unrelated handler, or the resource as a whole. The integration uses a per-parameter cached boolean validate flag computed once at arg-resolver construction time.
  3. No global "on" switch. There is no RestContext.Builder.validate(true), no juneau.validation.enabled=true system property, and no resource-level boolean attribute that enables validation across every operation in a class. Granular per-parameter opt-in is the only opt-in surface.
  4. No silent enablement via classpath presence. Even if jakarta.validation-api and hibernate-validator are on the classpath, validation does not run unless the user has opted in via @Valid (or one of the equivalent FQN markers — see below).
  5. Graceful degradation. If @Valid is present but no Jakarta Validation provider is reachable at runtime, the integration silently skips validation, emits a one-shot WARNING log per JVM, and lets the request continue with the bean unchanged. The off-by-default behavior is preserved even in misconfigured deployments.
  6. Tests verify the contract. RestValidation_OffByDefault_Test exercises the "violating bean, no @Valid marker → reaches handler" and "violating bean, @Valid marker → blocked with 400" pair against the same @Content parameter, against per-method granularity, and against cascading @Valid on a nested property. None of those tests rely on classpath inspection.

Opt-in marker​

The integration recognises three opt-in markers, all detected by fully-qualified name (FQN) so no compile-time dependency on the marker packages is required:

MarkerSourceNotes
@jakarta.validation.ValidJakarta Bean Validation 3.xRecommended. Standard marker for cascading and root-bean validation.
@javax.validation.ValidBean Validation 2.x (legacy javax.* namespace)Recognised for migration friendliness. Most modern stacks have moved to jakarta.*.
@org.springframework.validation.annotation.ValidatedSpring FrameworkRecognised for migration friendliness. Group support is not yet honored — the marker triggers default-group validation only.

The marker can sit on a @Content, @FormData, or @Request-bound parameter:

@RestPost("/orders")
public Order create(@Content @Valid OrderRequest in) { ... }

@RestPost("/signup")
public Result signup(@Request @Valid SignupRequest req) { ... }

@RestPost("/upload")
public Result upload(@FormData(name="*") @Valid UploadMetadata meta) { ... }

Failure response shape​

When validation fails, the integration throws a ValidationException (BadRequest subclass, status 400) carrying an immutable List<ValidationViolation>. The response shape depends on whether RFC 7807 problem details are enabled for the operation (see RFC 7807 / 9457 Problem Details).

Without problemDetails​

Content-Type: application/json:

{
"status": 400,
"errors": [
{ "path": "customerId", "message": "must not be blank", "constraint": "NotBlank" },
{ "path": "quantity", "message": "must be greater than 0", "constraint": "Positive" }
]
}

The top-level shape mirrors Spring MVC's MethodArgumentNotValidException payload for migration friendliness — status and errors[] at the root, one violation per array element.

With @Rest(problemDetails="true")​

Content-Type: application/problem+json:

{
"type": null,
"title": "Bad Request",
"status": 400,
"detail": "1 violation(s) found.",
"errors": [
{ "path": "customerId", "message": "must not be blank", "constraint": "NotBlank" }
]
}

The errors[] array is an extension member (RFC 7807 §3.2) — the canonical members (type, title, status, detail) describe the failure category and the extension carries the per-violation breakdown.

invalidValue privacy​

ValidationViolation carries an optional invalidValue field which is omitted by default for privacy reasons (the offending value may be PII, an API key, a partial credit card number, or otherwise sensitive). Handlers that need to surface the value — e.g. an internal debugging endpoint — can include it explicitly by constructing the violation with the value setter. The renderer honors a non-null value verbatim and omits a null one.

Dependency stance — optional, provided scope​

juneau-rest-server's pom.xml declares the Jakarta Validation API in provided scope:

<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>

Consumers who never write @Valid pay no transitive cost — no Jakarta Validation, no Hibernate Validator, no Jakarta EL pulled in automatically. Consumers who opt in supply the runtime provider on their own classpath:

<!-- in the consumer's pom.xml -->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>8.0.3.Final</version>
</dependency>
<dependency>
<groupId>org.glassfish.expressly</groupId>
<artifactId>expressly</artifactId>
<version>5.0.0</version>
</dependency>

Hibernate Validator is the reference implementation of Bean Validation 3.0 and is the recommended provider. Jakarta Expression Language (Expressly) is required for dynamic message interpolation — without it, Hibernate Validator throws on the first .validate() call with HV000183.

Graceful degradation​

If a parameter carries @Valid but no Jakarta Validation provider is reachable at runtime (the API JAR is on the classpath but the engine isn't), the integration silently skips validation and emits a one-shot WARNING log per JVM:

WARNING: Jakarta Bean Validation was requested via @Valid on a REST argument,
but no jakarta.validation.Validator bean is registered and no runtime provider
(e.g. org.hibernate.validator:hibernate-validator + jakarta.el implementation)
is on the classpath. Validation will be silently skipped for this and
subsequent requests. Add a provider dependency or register a Validator bean
to enable validation.

The request continues through the handler with the bean unmodified — the off-by-default contract is preserved. This matches the FQN-detection precedent established by JSR-330 / Spring-Lite: feature presence is signaled by the consumer (via the marker annotation) but the engine is a runtime resolution that fails open, not a startup-time hard requirement.

Validator resolution order​

The integration consults, in order:

  1. A user-supplied jakarta.validation.Validator bean visible from the resource's BeanStore. Typically contributed via a @Bean factory on the resource class:

    @Rest(path="/orders")
    public class OrderResource {
    @Bean public Validator validator() {
    return Validation.byDefaultProvider()
    .configure()
    .messageInterpolator(new ParameterMessageInterpolator()) // no jakarta.el
    .buildValidatorFactory()
    .getValidator();
    }
    }

    Use this when the deployment needs custom message interpolators, group sequences, a non-default ConstraintValidatorFactory, or a parameter-message interpolator that doesn't require jakarta.el at runtime.

  2. A lazily-built JVM-wide default obtained from Validation.buildDefaultValidatorFactory(). Cached after the first successful build so the factory cost is paid at most once per JVM, regardless of how many @Rest resources opt in.

  3. null — the graceful-degradation path described above.

Custom constraints​

User-defined constraints work transparently — the integration delegates entirely to the underlying jakarta.validation engine for constraint discovery:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
public @interface Sku {
String message() default "must match SKU-NNNN format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

public class SkuValidator implements ConstraintValidator<Sku, String> {
public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value != null && value.matches("SKU-\\d{4}");
}
}

public class OrderItem {
@Sku public String sku;
@Min(1) public int qty;
}

A @Valid OrderItem parameter will surface a Sku-named constraint in errors[].constraint alongside the built-in Min from jakarta.validation.constraints.Min.

Nested / cascading validation​

@Valid on a nested property tells the validator to recurse into that property and re-evaluate its constraints — but only if the parent's @Valid has triggered validation in the first place. The cascade still respects the off-by-default contract: if the parent has no @Valid, the nested @Valid alone does not trigger validation.

public class Address {
@Pattern(regexp="\\d{5}") public String zip;
}

public class Customer {
@NotBlank public String name;
@Valid public List<Address> addresses; // cascades only if Customer has @Valid above
}

@RestPost("/customer")
public String create(@Valid @Content Customer c) {
return "ok:" + c.name;
}

A bad zip surfaces at path addresses[0].zip in the violation list, proving the recursion happened.

API surface​

ClassPackageNotes
ValidationExceptionorg.apache.juneau.rest.server.validationBadRequest (400) subclass. Carries List<ValidationViolation>.
ValidationViolationorg.apache.juneau.rest.server.validationpath, message, constraint, optional invalidValue.
BeanValidatororg.apache.juneau.rest.server.validationStatic dispatcher. isValidationRequested(ParameterInfo), validate(T bean, BeanStore).

The arg-resolver wiring lives in ContentArg, FormDataArg, and RequestBeanArg — each computes its per-parameter boolean validate flag once at construction time and conditionally invokes BeanValidator.validate(...) per request only when the flag is true.

What's out of scope (v1)​

The following are explicit non-goals for the initial integration. Each can be added in a follow-on release without breaking the v1 contract.

  • Method-level validation. Constraints on @RestOp return values (@Valid on the return type) and @Validated on the resource class (which would imply method-parameter validation across every operation in the class). The v1 integration validates parameter beans only.
  • Schema-driven validation. JSON-Schema constraints on the request body — that lives in JsonSchemaGenerator territory.
  • Group support. @Validated(Group.class) recognition for selective group validation. The marker is detected but the group classes are not yet passed through to validator.validate(bean, groups...).
  • Messages.properties bridge. Cross-bundle composition between Juneau's Messages and Jakarta's ValidationMessages.properties. The two bundles don't compose automatically; document and configure separately.
  • Per-resource / per-method validate=true switch. A coarse-grained opt-in attribute on @Rest and @RestOp that enables validation for every parameter on the targeted scope without per-parameter @Valid markers. The v1 integration is @Valid-only on the parameter.

See also​