Standard jakarta.validation annotations, compiled once per type into a flat
check table. No configuration: constraints come from the annotations already on
your types, and ValidationException extends HttpException with status 422 so
the default handler renders it without this extension registering anything.
record CreateUser(@NotBlank @Size(max = 80) String name,
@Email String email,
@Min(18) int age) {}
CreateUser dto = validation.body(req, CreateUser.class);
Annotations only — Hibernate Validator's engine is deliberately absent. It
resolves constraints reflectively per call and pulls ~2 MB plus EL, which is
the per-request cost this module exists to avoid. jakarta.validation-api is
~90 KB of annotations.
The passing path allocates nothing. Constraints resolve at first use into an
opcode plus operands cached in a ClassValue, so there is no map lookup and no
lock. Fields are read through MethodHandles adapted to an exact signature —
(Object)Object for references, (Object)long for primitive integrals — so
invokeExact neither boxes nor builds the argument array Field.get and
Method.invoke allocate. Checks are a flat array walked by a tableswitch rather
than a class hierarchy behind a virtual call. @Size reads a length the object
already knows and @Email scans with indexOf, because Pattern.matcher allocates
a matcher and two int arrays per call. Messages are pre-rendered at compile
time. The violation list and the exception exist only once something fails.
@Pattern is the marked exception: its regex compiles once but matcher()
allocates per call.
Constraints are read from declared fields, so records and plain classes take
one code path — a constraint on a record component propagates to its backing
field.
Jakarta null semantics are exact: only @NotNull rejects null.
flash-ext-openapi now mirrors the same annotations into the generated schema —
minLength, maxLength, minItems, minimum, maximum, pattern, format: email and
required — via an optional jakarta.validation dependency detected at boot. A
type declares its rules once and both the validator and the published contract
read them. An explicit @Schema still wins; the bridge only fills keys nobody
set, and without the annotations on the classpath the bridge class is never
loaded.
flash-ext-jackson is optional too: validate(value) works without it, only
body(req, type) needs a codec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.3 KiB
flash-ext-validation
Request validation for Flash. Standard jakarta.validation annotations, compiled once per type
into a flat check table, with zero allocation on the passing path.
What it provides
| Component | Description |
|---|---|
Validation |
The service — body(req, type) parses and verifies, validate(value) verifies |
Validator |
One type's compiled constraints; reusable and thread-safe |
ValidationException |
422 carrying every violation, not just the first |
Dependency
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-validation</artifactId>
<version>${flash.version}</version>
</dependency>
Quick start
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new ValidationExtension())
.scan("dev.example.api");
public record CreateUser(
@NotBlank @Size(max = 80) String name,
@Email String email,
@Min(18) int age) {}
@POST("/api/users")
public final class CreateUserHandler extends RequestHandler {
private Validation validation;
private UserService users;
@Override protected void onInit() {
validation = require(Validation.class);
users = require(UserService.class);
}
@Override public Object handle(Request req, Response res) throws Exception {
CreateUser dto = validation.body(req, CreateUser.class);
return res.status(201).body(users.create(dto));
}
}
There is nothing to configure. Constraints come from the annotations already on your types, and
failures reach the client as 422 on their own — see Error responses.
Supported constraints
@NotNull · @NotBlank · @NotEmpty · @Size · @Min · @Max · @Email · @Pattern
Jakarta null semantics are honoured exactly: only @NotNull rejects null. Every other
constraint passes a null value, so @Email String email means "if present, must look like an
email" — combine with @NotNull when it is mandatory.
@Size applies to CharSequence, Collection, Map and object arrays. @Min/@Max apply to
primitive integrals and to Number subtypes.
An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure.
Records and classes
Constraints are read from declared fields. A constraint on a record component propagates to its backing field, so records and plain classes take the same path with no extra configuration:
record CreateUser(@NotBlank String name) {} // works
class CreateUser { @NotBlank private String name; } // works
Error responses
ValidationException extends Flash's HttpException with status 422, so the default exception
handler renders it. Nothing is registered, and your own onException still wins if you set one.
{"error":"name must not be blank; age must be at least 18","status":422}
Malformed JSON is a different failure and comes back as 400 from the codec, before any
constraint runs.
OpenAPI
Install flash-ext-openapi alongside and the generated schema mirrors the same annotations —
minLength, maxLength, minItems, minimum, maximum, pattern, format: email, and
required. Declared once, enforced and published.
Nothing registers this. flash-ext-openapi carries jakarta.validation-api as an optional
dependency and detects it at boot; without it the bridge class is never loaded.
An explicit @Schema always wins — the bridge only fills keys nobody set.
Without Jackson
flash-ext-jackson is optional. Without it validate(value) still works on values you construct
or parse yourself; only body(req, type) needs a codec and says so if one is missing.
Performance
The passing path is the one that runs on every request, so it allocates nothing:
- Compiled once per type. Constraints resolve to an opcode plus operands at first use, cached
in a
ClassValue— stored beside the class by the JVM, so no map lookup, no lock, and the entry is collected with the class rather than pinning it. - No reflection per request. Fields are read through
MethodHandles adapted to an exact signature:(Object)Objectfor references,(Object)longfor primitive integrals.invokeExactneither boxes nor builds the argument array thatField.getandMethod.invokeallocate. - No megamorphic dispatch. Checks are a flat array walked by a
tableswitchon an opcode, not a class hierarchy behind a virtual call. - No copies.
@Sizereads a length the object already knows;@Emailscans withindexOfrather than a regex, becausePattern.matcherallocates a matcher and two int arrays per call. - Messages pre-rendered at compile time, so even a failure formats nothing.
The list, the violations and the exception exist only once something fails.
@Pattern is the deliberate exception: its regex is compiled once, but matcher() allocates per
call. It is marked in the source. Prefer @Size/@Email on hot routes, or validate the shape
structurally.
Pre-warming
Compilation happens on a type's first request. To pay it at boot instead:
ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class));
Worth it only for a route that must not pay first-call cost. Everything else warms itself.