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:
- Cold-start default. A fresh
RestContextbuilt with no validation-related markers anywhere on the resource never instantiates aValidatorand 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. - Per-parameter granularity.
@Validon one parameter never opts in a sibling parameter, an unrelated handler, or the resource as a whole. The integration uses a per-parameter cachedboolean validateflag computed once at arg-resolver construction time. - No global "on" switch. There is no
RestContext.Builder.validate(true), nojuneau.validation.enabled=truesystem 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. - No silent enablement via classpath presence. Even if
jakarta.validation-apiandhibernate-validatorare on the classpath, validation does not run unless the user has opted in via@Valid(or one of the equivalent FQN markers — see below). - Graceful degradation. If
@Validis present but no Jakarta Validation provider is reachable at runtime, the integration silently skips validation, emits a one-shotWARNINGlog per JVM, and lets the request continue with the bean unchanged. The off-by-default behavior is preserved even in misconfigured deployments. - Tests verify the contract.
RestValidation_OffByDefault_Testexercises the "violating bean, no@Validmarker → reaches handler" and "violating bean,@Validmarker → blocked with 400" pair against the same@Contentparameter, against per-method granularity, and against cascading@Validon 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:
| Marker | Source | Notes |
|---|---|---|
@jakarta.validation.Valid | Jakarta Bean Validation 3.x | Recommended. Standard marker for cascading and root-bean validation. |
@javax.validation.Valid | Bean Validation 2.x (legacy javax.* namespace) | Recognised for migration friendliness. Most modern stacks have moved to jakarta.*. |
@org.springframework.validation.annotation.Validated | Spring Framework | Recognised 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:
-
A user-supplied
jakarta.validation.Validatorbean visible from the resource'sBeanStore. Typically contributed via a@Beanfactory 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. -
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@Restresources opt in. -
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
| Class | Package | Notes |
|---|---|---|
ValidationException | org.apache.juneau.rest.server.validation | BadRequest (400) subclass. Carries List<ValidationViolation>. |
ValidationViolation | org.apache.juneau.rest.server.validation | path, message, constraint, optional invalidValue. |
BeanValidator | org.apache.juneau.rest.server.validation | Static 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
@RestOpreturn values (@Validon the return type) and@Validatedon 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
JsonSchemaGeneratorterritory. - Group support.
@Validated(Group.class)recognition for selective group validation. The marker is detected but the group classes are not yet passed through tovalidator.validate(bean, groups...). Messages.propertiesbridge. Cross-bundle composition between Juneau'sMessagesand Jakarta'sValidationMessages.properties. The two bundles don't compose automatically; document and configure separately.- Per-resource / per-method
validate=trueswitch. A coarse-grained opt-in attribute on@Restand@RestOpthat enables validation for every parameter on the targeted scope without per-parameter@Validmarkers. The v1 integration is@Valid-only on the parameter.
See also
- RFC 7807 / 9457 Problem Details — the problem-details response shape that the validation integration plugs into for the
application/problem+jsonpath. - Bean Validation 3.0 specification — the underlying Jakarta API.
- Hibernate Validator 8.0 reference — the recommended provider.