Files
Flash5/flash-extensions/flash-ext-validation/docs
Zakaria El OrcheandClaude Opus 5 f68e661296 feat(ext-validation): add request validation with compiled constraints
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>
2026-09-09 12:20:33 +00:00
..

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)Object for references, (Object)long for primitive integrals. invokeExact neither boxes nor builds the argument array that Field.get and Method.invoke allocate.
  • No megamorphic dispatch. Checks are a flat array walked by a tableswitch on an opcode, not a class hierarchy behind a virtual call.
  • No copies. @Size reads a length the object already knows; @Email scans with indexOf rather than a regex, because Pattern.matcher allocates 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.