In part 1 I described Ligero's goals. This post opens the hood on the two pieces every request touches: the router and the middleware pipeline, plus the secure baseline that runs before your handlers.
The router: a segment trie
Ligero matches routes with a segment trie. Matching cost is proportional to the length of the path, independent of how many routes you register, with a fixed priority: static > parameter > wildcard, with backtracking.
app.get("/users", ctx -> ...);
app.get("/users/{id}", ctx -> ...); // parameter
app.get("/users/me", ctx -> ...); // static: wins for GET /users/me
app.get("/files/*path", ctx -> ...); // wildcard: captures the remainder
The trie for those routes looks like this:
flowchart TD
root((root)) --> users[users]
root --> files[files]
users --> me["me (static)"]
users --> id["{id} (param)"]
files --> wild["*path (wildcard)"]
For GET /users/me the matcher tries the static child first and finds me. For GET /users/42 the static child fails, so it backtracks to the parameter child and binds id=42. Because priority is part of the data structure, the order in which you register routes doesn't change behaviour.
Path parameters and wildcards
app.get("/users/{id}/posts/{postId}", ctx -> {
String id = ctx.pathParam("id");
long postId = ctx.pathParamAsLong("postId"); // automatic 400 if not numeric
});
app.get("/files/*path", ctx -> ctx.text(ctx.pathParam("path")));
// GET /files/css/site.css -> path = "css/site.css"
Route groups
Groups share a prefix and can carry scoped middleware:
app.group("/api/v1", api -> {
api.use(authMiddleware); // only under /api/v1
api.get("/users", ctx -> ...);
api.group("/admin", admin -> admin.get("/stats", ctx -> ...));
});
Semantics you shouldn't have to write
- 404 when nothing matches, with a JSON error body you can customise.
-
405 +
Allowwhen the path exists under a different method. -
OPTIONSanswered automatically with theAllowlist. - Duplicate routes fail at startup, not at runtime.
That last one is the theme of the whole framework: move failures as early as possible.
Middleware: one interface, everything else is composition
Every cross-cutting concern is a middleware, never a core change:
@FunctionalInterface
public interface Middleware {
void handle(Context ctx, Chain chain) throws Exception;
}
Call chain.proceed() to continue; skip it to short-circuit. Middleware runs in registration order and wraps router dispatch: first in, ..., handler, ..., first out.
sequenceDiagram
participant C as Client
participant M1 as RequestId
participant M2 as Logging
participant M3 as RateLimit
participant H as Handler
C->>M1: request
M1->>M2: proceed()
M2->>M3: proceed()
M3->>H: proceed()
H-->>M3: response
M3-->>M2: response
M2-->>M1: response
M1-->>C: response
A timing middleware is four lines:
app.use((ctx, chain) -> {
long start = System.nanoTime();
chain.proceed();
log.info("{} took {} µs", ctx.path(), (System.nanoTime() - start) / 1000);
});
Built-ins cover the usual suspects: request id + W3C traceparent propagation, JSON access logs, security headers, CORS, token-bucket rate limiting (HTTP 429, pluggable store), Basic auth, traversal-safe static files with ETag/304, health and metrics. A realistic stack:
app.use(new RequestIdMiddleware());
app.use(RequestLoggingMiddleware.json());
app.use(SecurityHeadersMiddleware.defaults());
app.use(CorsMiddleware.builder().allowOrigins("https://app.example").build());
app.use(RateLimitMiddleware.of(100, 100)); // burst 100, 100 req/s per client IP
app.use("/api", JwtAuthMiddleware.of(jwt)); // path-scoped
Secure by default
Before any of that runs, every Ligero app gets an OWASP-aligned baseline automatically:
-
Security headers on every response:
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer. -
Request hygiene: paths with null bytes (
%00), control characters or percent-encoded traversal (%2e%2e) get a400before any handler runs.
You can turn it off, explicitly, if an API gateway already handles it:
Ligero app = Ligero.create(LigeroConfig.builder()
.secureDefaults(false)
.build());
// or: LIGERO_SECURE_DEFAULTS=false
The principle: the safe behaviour is the one you get by doing nothing, and loosening it requires a deliberate line of code you can grep for. Other defaults in the same spirit: request body limits (413), traversal-safe static files, and opaque 500s so stack traces never reach clients.
Why not annotations?
Routes are registered with method calls, not annotations, for the same reason wiring is lambdas (next post): you can read the control flow, the compiler checks it, and nothing needs a classpath scan to exist. Annotations appear in Ligero only as metadata (for devtools), never as behaviour.
Takeaways
- A segment trie gives path-length matching with deterministic precedence and automatic 404/405/OPTIONS.
- Middleware is a single functional interface, so every concern composes the same way.
- Secure defaults on, with an explicit and greppable opt-out.
Next: dependency injection with no reflection, and why wiring 100 beans costs about 65 ms.
Top comments (0)