Validation
View MarkdownStatus: Stable Package:
dev.vertique.validationArtifact:vertique-validationDepends on: core API reference: javadoc.io
vertique-validation is the Hibernate Validator-backed implementation of Jakarta Bean Validation
(JSR 380) for Vertique. The HTTP-agnostic API — BeanValidator, ViolationDetail,
ParameterViolation, BeanValidationException, @ValidateWith, and the character-policy types —
lives in dev.vertique:vertique-core; this artifact supplies the validator factory, the
Dagger-aware ConstraintValidatorFactory, the violation-classification pipeline, and the
@AllowedCharacters constraint family.
Installing ValidationModule does two things: it makes BeanValidator injectable anywhere, and it
turns on automatic method-parameter validation for JAX-RS resource methods and WebSocket
@OnMessage handlers. There is no per-method opt-in and no configuration section.
When To Use It
Section titled “When To Use It”Install ValidationModule whenever an application declares Bean Validation constraints
(@NotNull, @Size, @Pattern, …) on request DTOs, resource-method parameters, or domain objects
it validates programmatically.
| Pairing | Effect |
|---|---|
dev.vertique:vertique-rest-jaxrs |
Resource-method parameters are validated on every invocation; violations become HTTP 400 problem details with per-parameter location context |
dev.vertique:vertique-rest-websocket |
Decoded @OnMessage payloads are validated with the endpoint’s @ValidateWith groups |
| any module | BeanValidator is injectable for service-layer and event-bus validation |
Without this module the framework compiles and runs; BeanValidator is simply absent and REST
parameter validation is silently skipped.
Core Concepts
Section titled “Core Concepts”Two entry points, one engine. BeanValidator is the programmatic API. The REST and WebSocket
layers call the same interface — they do not talk to Jakarta Validator directly — so a constraint
behaves identically whether it fires on a request parameter or on an object you validate by hand.
Violations are structured, never raw. Every violation is a ViolationDetail: property path,
interpolated message, a classified type string, and an args map carrying the constraint’s own
parameters. The invalid value is deliberately excluded so passwords, tokens, and PII cannot leak
into an error response or a log line.
Validation groups come from the call site or the annotation. Programmatic callers pass groups
to validate/check; REST and WebSocket handlers declare them with @ValidateWith on the method.
Absent groups means the Jakarta default group.
Validation is unconditional once installed. With ValidationModule on the component, the JAX-RS
invoker calls BeanValidator.checkParameters(...) before every resource-method invocation,
including methods that carry no constraint annotations at all. Hibernate Validator caches the
executable’s constraint metadata after the first call, so the steady-state per-request cost is a
metadata cache lookup rather than a re-analysis of the method.
@Singletonpublic class OrderService {
private final BeanValidator validator;
@Inject public OrderService(BeanValidator validator) { this.validator = validator; }
public void processOrder(OrderRequest request) { validator.validate(request); // throws BeanValidationException if invalid }
public List<ViolationDetail> preCheck(OrderRequest request) { return validator.check(request); // non-throwing; empty list when valid }}Key Classes
Section titled “Key Classes”BeanValidator
Section titled “BeanValidator”dev.vertique.core.validation.BeanValidator — the programmatic validation API. Injectable as a
@Singleton once ValidationModule is installed.
public interface BeanValidator {
// Throwing — raise BeanValidationException when invalid <T> void validate(T object); <T> void validate(T object, Class<?>... groups);
// Non-throwing — return the violations; empty list when valid <T> List<ViolationDetail> check(T object); <T> List<ViolationDetail> check(T object, Class<?>... groups);
// Method-parameter validation, carrying the parameter index List<ParameterViolation> checkParameters( Object instance, Method method, Object[] args, Class<?>... groups);
void validateParameters( Object instance, Method method, Object[] args, Class<?>... groups);}All six methods reject a null target, method, or args with NullPointerException — a null
object is a programming error, not a validation failure.
Validation groups:
public interface Create {}public interface Update {}
public record UserRequest( @NotNull(groups = Create.class) String name, @NotNull Long id) {}
validator.validate(request, Create.class);ViolationDetail
Section titled “ViolationDetail”dev.vertique.core.validation.ViolationDetail — the HTTP-agnostic violation record.
@JsonInclude(JsonInclude.Include.NON_NULL)public record ViolationDetail( String path, // "name", "address.city" String message, // interpolated constraint message @Nullable String type, // classification, e.g. "required", "size" @Nullable Map<String, Object> args // e.g. {min: 1, max: 100}) { public static ViolationDetail of(String path, String message) { ... }}ParameterViolation
Section titled “ParameterViolation”dev.vertique.core.validation.ParameterViolation pairs a ViolationDetail with the zero-based
index of the offending method parameter, which is what lets the REST layer map a violation onto an
HTTP location.
public record ParameterViolation( int parameterIndex, ViolationDetail detail, @Nullable PropertyPath propertyPath) {}parameterIndex is -1 when the index could not be determined from the violation path.
The two-argument constructor remains and leaves propertyPath null.
checkParameters and validateParameters report detail.path() relative to the parameter value
and with positions: items[1].name, lists[0][2], [0].bark for a list argument. A map entry or
set member renders as [] (byKey[].name, codes[]) and the map key is never part of the path.
A violation of the parameter value itself (a scalar parameter, a class-level constraint) has the
empty path. check(Object) and validate(...) keep Hibernate Validator’s property-path string
unchanged.
propertyPath is an internal framework seam for sibling framework modules, outside the Stable
promise like PropertyPath itself. It holds the same path as structured segments: property names,
list and array positions, and opaque markers for map entries and set members. A property segment
records the runtime class of the object hosting it (a Dog in a List<Animal>), or null when it
cannot be derived: a root list that is not random-access (for example a LinkedList), or a
validator other than Hibernate Validator. The path never holds a map key, a set member or a value.
It is null, with detail.path() joining the property names with dots, only if the path could not
be converted.
BeanValidationException
Section titled “BeanValidationException”dev.vertique.core.validation.BeanValidationException extends
dev.vertique.core.exception.ValidationException, so the REST error pipeline renders it as HTTP 400.
Thrown by validate(...) and validateParameters(...).
public class BeanValidationException extends ValidationException { public BeanValidationException(String message, List<ViolationDetail> violations) { ... } public BeanValidationException(String message, List<ViolationDetail> violations, Throwable cause) { ... } public List<ViolationDetail> violations() { ... } // unmodifiable, never null}The violation list is defensively copied; a null list becomes an empty one.
@ValidateWith
Section titled “@ValidateWith”dev.vertique.core.validation.ValidateWith selects the validation groups for a method. Place it on
a JAX-RS resource method or a WebSocket @OnMessage method; an empty array means the default group.
@POST @Path("/users")@ValidateWith({Create.class})public Future<User> createUser(@Valid CreateUserRequest request) { ... }
@PUT @Path("/users/{id}")@ValidateWith({Update.class})public Future<User> updateUser(@PathParam("id") UUID id, @Valid UpdateUserRequest request) { ... }The JAX-RS scanner also honours @ValidateWith declared on a resource interface method, so a
contract interface can pin the groups for all its implementations.
Invariants & Gotchas
Section titled “Invariants & Gotchas”- A Dagger-managed
ConstraintValidator(extension tier 1, below) is a shared singleton. It must be stateless and must not depend oninitialize()to capture per-annotation state — every use of the annotation sees the same instance. Validators that need annotation parameters (@Sizewith varyingmin/max) belong on the reflection tier. - Two Dagger-contributed
ConstraintValidatorinstances of the same concrete class fail component construction withIllegalStateException: Duplicate ConstraintValidator class: …. ViolationDetail.args()andtype()may benull; both are omitted from JSON.
Character Policy Validation
Section titled “Character Policy Validation”@AllowedCharacters
Section titled “@AllowedCharacters”dev.vertique.validation.constraints.AllowedCharacters validates that a string contains only the
characters a CharacterPolicy permits. It is repeatable (multiple constraints on one element are
AND-ed) and targets types, fields, record components, parameters, type uses, and annotation types.
public record CreateProjectRequest( @AllowedCharacters(policy = IdentifierPolicy.class) String name, @AllowedCharacters(policy = SlugPolicy.class) String slug, @AllowedCharacters(policy = PersonNamePolicy.class) String ownerName) {}null passes; pair with @NotNull or @NotBlank to require a value. Violations classify as
allowed_characters and carry {policy: "<PolicySimpleName>"} in args.
On a field or parameter the constraint validates that one CharSequence. On a type it
traverses the object graph and applies the policy to every reachable String, including strings
inside Collection and Map values and inside nested application DTOs. Traversal is cycle-safe
(identity-based visited set) and skips JDK types, primitives, boxed primitives, enums, and arrays.
Object-level traversal skips an element that carries its own @AllowedCharacters (it validates
itself at field level) or dev.vertique.core.validation.SkipAllowedCharacters. Static fields are
never traversed. Meta-annotations composed from either annotation are recognised.
The InputValueContext handed to a policy from this constraint is synthetic: location() is
always BODY and ownerType() is always Object.class (object traversal does supply a real
path). Bean Validation has no accurate provenance for a value, so a policy must not branch on
location() — see the vertique-core reference for the full contract.
@AllowedCharacters(policy = UnicodeCommonTextPolicy.class)public record Article( String title, String body, @SkipAllowedCharacters String rawHtml, // exempt @AllowedCharacters(policy = SlugPolicy.class) String slug) {} // own policy winsCharacterPolicy
Section titled “CharacterPolicy”dev.vertique.core.validation.CharacterPolicy is the policy SPI.
public interface CharacterPolicy { CharacterPolicyResult validate(String value, InputValueContext context);}Implementations must be stateless, thread-safe, deterministic, and must treat null as valid.
Return CharacterPolicyResult.passed() or
CharacterPolicyResult.failed(index, codePoint, reason); the record’s accessors are valid(),
invalidIndex(), invalidCodePoint(), and reason().
InputValueContext (dev.vertique.core.sanitization) is
(InputLocation location, String path, String logicalName, Class<?> ownerType). Both built-in
validators pass InputLocation.BODY; the object-level validator sets path/logicalName to the
traversed field path, the field-level validator passes empty strings.
Built-in policies
Section titled “Built-in policies”All live in dev.vertique.validation.constraints and have a public no-arg constructor.
| Policy | Accepts |
|---|---|
IdentifierPolicy |
Unicode letters, Unicode digits, _, -, . |
SlugPolicy |
ASCII a–z, 0–9, - |
PersonNamePolicy |
Unicode letters (any script), space, ', -, ., , |
AddressLinePolicy |
Unicode letters and digits, space, and . , - ' / # ( ) & |
UnicodeCommonTextPolicy |
Unicode categories L*, N*, Z*, P* plus an ASCII punctuation allowlist; rejects ISO control characters and emoji |
UnicodePrintableTextPolicy |
Everything except ISO control characters |
UnicodePrintableNoEmojiTextPolicy |
Everything except ISO control characters and emoji |
“Emoji” here means a code point in the So (other symbol) category at or above U+2600, or any
code point in U+1F000–U+1FFFF.
Composed annotations
Section titled “Composed annotations”| Annotation | Composes | Classified as |
|---|---|---|
@PersonName |
@AllowedCharacters(policy = PersonNamePolicy.class) |
allowed_characters |
@AddressLine |
@AllowedCharacters(policy = AddressLinePolicy.class) |
allowed_characters |
@UnicodePrintableNoEmojiText |
@AllowedCharacters(policy = UnicodePrintableNoEmojiTextPolicy.class) |
allowed_characters |
@DigitsOnly |
@Pattern(regexp = "[0-9]+") |
pattern |
@AlphaNumeric |
@Pattern(regexp = "[a-zA-Z0-9]+") |
pattern |
@DigitsOnly and @AlphaNumeric are not character-policy constraints — they are regex
constraints, so they reject the empty string (+ requires at least one character) and their
violations carry {regexp: …} rather than {policy: …}. All five accept null.
public record CreateUserRequest( @PersonName String firstName, @PersonName String lastName, @AddressLine String streetAddress, @UnicodePrintableNoEmojiText String bio) {}Policies with injected dependencies
Section titled “Policies with injected dependencies”A policy with a public no-arg constructor needs no registration. A policy that needs Dagger
dependencies must be contributed as a CharacterPolicyBinding, keyed by its concrete class:
@Singletonpublic class ForbiddenWordsPolicy implements CharacterPolicy {
private final ForbiddenWordRepository repo;
@Inject public ForbiddenWordsPolicy(ForbiddenWordRepository repo) { this.repo = repo; }
@Override public CharacterPolicyResult validate(String value, InputValueContext context) { int idx = repo.firstForbiddenIndex(value); return idx < 0 ? CharacterPolicyResult.passed() : CharacterPolicyResult.failed(idx, value.codePointAt(idx), "forbidden word"); }}
// In a Dagger module:@Provides @IntoSetstatic CharacterPolicyBinding forbiddenWordsPolicy(ForbiddenWordsPolicy p) { return new CharacterPolicyBinding(ForbiddenWordsPolicy.class, p);}ValidationModule wires a CharacterPolicyResolver over that multibinding and hands it to the
@AllowedCharacters validators. Resolution is Dagger binding first, reflective no-arg constructor
second; when neither works the validator throws
jakarta.validation.ValidationException: Cannot instantiate CharacterPolicy: ….
Outside a Dagger context — a bare Validation.buildDefaultValidatorFactory(), for example — only
the reflective tier is available, so policies with injected dependencies will not resolve.
Violation Classification
Section titled “Violation Classification”type is resolved by three steps: an exact ViolationTypeMapping lookup, then each contributed
ViolationTypeMapper in turn, then the constraint annotation’s simple name.
Built-in type mappings:
| Annotation | type |
|---|---|
@NotNull, @NotBlank, @NotEmpty |
required |
@Null |
null |
@Size |
size |
@Min, @DecimalMin, @Positive, @PositiveOrZero |
min |
@Max, @DecimalMax, @Negative, @NegativeOrZero |
max |
@Pattern |
pattern |
@Email |
email |
@Digits |
digits |
@Future, @FutureOrPresent, @Past, @PastOrPresent |
date |
@AssertTrue |
assert_true |
@AssertFalse |
assert_false |
@Length, @CodePointLength (Hibernate) |
length |
@Range (Hibernate) |
range |
@UniqueElements (Hibernate) |
unique_elements |
@URL (Hibernate) |
url |
@AllowedCharacters |
allowed_characters |
A contributed ViolationTypeMapping for an annotation already in the table replaces the built-in
one (last-wins merge).
args is resolved by the first contributed ViolationArgsInspector whose supports(...) returns
true, otherwise by the built-in inspector.
Built-in args extraction:
| Annotation | args |
|---|---|
@Size |
{min, max} |
@Min / @Max |
{value} |
@DecimalMin / @DecimalMax |
{value, inclusive} |
@Positive |
{min: 0, inclusive: false} |
@PositiveOrZero |
{min: 0, inclusive: true} |
@Negative |
{max: 0, inclusive: false} |
@NegativeOrZero |
{max: 0, inclusive: true} |
@Digits |
{integer, fraction} |
@Pattern |
{regexp} |
@Email |
{regexp}, omitted when the regexp is the default .* |
@Length |
{min, max} |
@CodePointLength |
{min, max}, plus normalizationStrategy when it is not NONE |
@Range |
{min, max} |
@AllowedCharacters |
{policy} |
| anything else | null |
JAX-RS Integration
Section titled “JAX-RS Integration”vertique-rest-jaxrs declares @BindsOptionalOf BeanValidator. ValidationModule provides that
binding, so simply listing it on the component activates method-parameter validation:
@Singleton@Component(modules = { VertxModule.class, RestModule.class, ValidationModule.class, // BeanValidator + automatic REST method validation AppModule.class, ResourceModule.class})interface AppComponent { HttpVerticle httpVerticle();}Per request, the resource-method invoker extracts the arguments, calls
BeanValidator.checkParameters(...) with the method’s @ValidateWith groups, and — when the list
is non-empty — throws RestValidationException. Each ParameterViolation is enriched with an HTTP
location taken from the parameter’s JAX-RS source (body, query, path, header, cookie,
form); for a @BeanParam, the violated field’s own JAX-RS annotation supplies both the location
and the reported parameter name. A violation on a JSON request body is reported in pointer, an RFC
6901 JSON Pointer over the wire names the route’s body mapper reads; any other request location is
reported in parameter. The REST module’s document lists when a body violation carries neither member.
The error pipeline renders the exception as HTTP 400 with a ValidationProblemDetail body:
{ "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "Validation failed", "errors": [ { "pointer": "/name", "detail": "must not be blank", "location": "body", "type": "required" }, { "parameter": "page", "detail": "must be greater than 0", "location": "query", "type": "min" } ]}A BeanValidationException thrown from your own code — validator.validate(dto) inside a resource
method or service — also renders as 400 with a ValidationProblemDetail, but its entries carry no
location, because a programmatic validation has no parameter to attribute.
For WebSocket endpoints, vertique-rest-websocket validates the decoded @OnMessage payload with
BeanValidator.validate(decoded, groups) when the method declares @ValidateWith.
Extension Points
Section titled “Extension Points”All six extension points are Dagger Set<…> multibindings declared by ValidationModule.
Set<ConstraintValidator<?, ?>>
Section titled “Set<ConstraintValidator<?, ?>>”Individual Dagger-managed validators, resolved by concrete class. Must be stateless — the same instance is shared across every use of the annotation.
@Singletonpublic class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository users;
@Inject public UniqueEmailValidator(UserRepository users) { this.users = users; }
@Override public boolean isValid(String email, ConstraintValidatorContext context) { return email == null || !users.existsByEmail(email); }}
@Provides @IntoSetstatic ConstraintValidator<?, ?> uniqueEmailValidator(UniqueEmailValidator v) { return v;}Set<ConstraintValidatorFactory>
Section titled “Set<ConstraintValidatorFactory>”Additional factories, consulted in turn after the Dagger-managed set. The first factory returning a
non-null instance wins; a factory that throws is skipped. Use this to register a batch of
third-party library validators.
@Provides @IntoSetstatic ConstraintValidatorFactory libraryValidators() { return new SomeLibraryConstraintValidatorFactory();}Standard Hibernate Validator built-ins (@NotNull, @Size, @Min, @Pattern, …) need no
registration at all — they resolve through the final reflective tier, which creates a fresh instance
per getInstance(...) call from the validator class’s no-arg constructor so Hibernate Validator can
call initialize() per annotation descriptor.
Set<ViolationTypeMapping>
Section titled “Set<ViolationTypeMapping>”Annotation-to-type-string bindings, merged over the built-ins.
@Provides @IntoSetstatic ViolationTypeMapping uniqueEmailMapping() { return new ViolationTypeMapping(UniqueEmail.class, "unique_email");}Set<ViolationTypeMapper>
Section titled “Set<ViolationTypeMapper>”Programmatic classification when a per-annotation binding is impractical. Return null to pass the
annotation on to the next mapper.
public interface ViolationTypeMapper { @Nullable String typeFor(Class<? extends Annotation> constraintAnnotation);}
@Singletonpublic class AcmeViolationTypeMapper implements ViolationTypeMapper {
@Inject public AcmeViolationTypeMapper() {}
@Override public String typeFor(Class<? extends Annotation> constraintAnnotation) { return constraintAnnotation.getPackageName().startsWith("com.acme.validation.constraints") ? "acme_" + constraintAnnotation.getSimpleName().toLowerCase(Locale.ROOT) : null; }}
@Provides @IntoSetstatic ViolationTypeMapper acmeMapper(AcmeViolationTypeMapper mapper) { return mapper;}Set<ViolationArgsInspector>
Section titled “Set<ViolationArgsInspector>”Custom args extraction. The first inspector whose supports(...) returns true is used — and its
extract(...) result is final, even when it returns null.
public interface ViolationArgsInspector { boolean supports(Class<? extends Annotation> constraintAnnotation); @Nullable Map<String, Object> extract(ConstraintViolation<?> violation);}
@Singletonpublic class AllowedValuesArgsInspector implements ViolationArgsInspector {
@Inject public AllowedValuesArgsInspector() {}
@Override public boolean supports(Class<? extends Annotation> constraintAnnotation) { return constraintAnnotation == AllowedValues.class; }
@Override public Map<String, Object> extract(ConstraintViolation<?> violation) { AllowedValues ann = (AllowedValues) violation.getConstraintDescriptor().getAnnotation(); return Map.of("allowed", Arrays.asList(ann.value())); }}
@Provides @IntoSetstatic ViolationArgsInspector allowedValuesInspector(AllowedValuesArgsInspector inspector) { return inspector;}Set<CharacterPolicyBinding>
Section titled “Set<CharacterPolicyBinding>”Dagger-managed CharacterPolicy instances — see
Policies with injected dependencies.
Module Dagger Bindings
Section titled “Module Dagger Bindings”@Modulepublic abstract class ValidationModule { @Multibinds abstract Set<ConstraintValidator<?, ?>> constraintValidators(); @Multibinds abstract Set<ConstraintValidatorFactory> constraintValidatorFactories(); @Multibinds abstract Set<ViolationTypeMapping> violationTypeMappings(); @Multibinds abstract Set<ViolationTypeMapper> violationTypeMappers(); @Multibinds abstract Set<ViolationArgsInspector> violationArgsInspectors(); @Multibinds abstract Set<CharacterPolicyBinding> characterPolicyBindings();}| Binding | Scope | Notes |
|---|---|---|
ValidatorFactory |
@Singleton |
Built from the default provider, configured with the Dagger-aware ConstraintValidatorFactory |
Validator |
@Singleton |
ValidatorFactory.getValidator() |
BeanValidator |
@Singleton |
Backed by DefaultBeanValidator |
CharacterPolicyResolver |
@Singleton |
Backed by Set<CharacterPolicyBinding> |
Set<ViolationTypeMapping> |
multibinding | Seeded with every built-in mapping in the table above |
Set<ConstraintValidatorFactory> |
multibinding | Seeded with the @AllowedCharacters factory that injects CharacterPolicyResolver |
Set<ViolationArgsInspector> |
multibinding | Seeded with the @AllowedCharacters policy-name inspector |
The module takes no constructor arguments and reads no configuration section.
Failures, Constraints, and Common Mistakes
Section titled “Failures, Constraints, and Common Mistakes”| Symptom | Cause |
|---|---|
IllegalStateException: Duplicate ConstraintValidator class: … at startup |
Two @IntoSet ConstraintValidator contributions of the same concrete class |
jakarta.validation.ValidationException: Cannot instantiate ConstraintValidator: … |
The validator class reached the reflective tier but has no accessible no-arg constructor, and no contributed factory handled it |
jakarta.validation.ValidationException: Cannot instantiate CharacterPolicy: … |
The @AllowedCharacters policy has no no-arg constructor and no CharacterPolicyBinding was contributed |
| Constraints silently never fire on resource methods | ValidationModule is not on the Dagger component, so the optional BeanValidator binding is empty |
| A DI-backed validator sees stale annotation parameters | It is a tier-1 singleton relying on initialize(); move it to the reflective tier or make it stateless |
Empty string rejected by @DigitsOnly / @AlphaNumeric |
They compose @Pattern with a + quantifier, so "" fails; use a character policy if the empty string must pass |
Object-level @AllowedCharacters skips a field |
The field carries its own @AllowedCharacters or @SkipAllowedCharacters, or is static |
Dependencies
Section titled “Dependencies”| Dependency | Why |
|---|---|
dev.vertique:vertique-core |
BeanValidator, ViolationDetail, ParameterViolation, BeanValidationException, @ValidateWith, CharacterPolicy and friends, InputValueContext |
jakarta.validation:jakarta.validation-api |
The Bean Validation API this module implements against |
org.hibernate.validator:hibernate-validator |
The validation engine backing ValidatorFactory |
org.glassfish.expressly:expressly |
EL implementation Hibernate Validator requires for message interpolation |
com.google.dagger:dagger |
ValidationModule and the multibinding extension points |
jakarta.inject:jakarta.inject-api |
@Inject / @Singleton |
jakarta.annotation:jakarta.annotation-api |
@Nullable on SPI signatures |
org.slf4j:slf4j-api |
Diagnostic logging in the validator factory |
org.projectlombok:lombok |
Compile-time only |