DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

A Router, a Middleware Chain and a Secure Baseline in Plain Java (Building Ligero, Part 2)

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 -> ...));
});
Enter fullscreen mode Exit fullscreen mode

Semantics you shouldn't have to write

  • 404 when nothing matches, with a JSON error body you can customise.
  • 405 + Allow when the path exists under a different method.
  • OPTIONS answered automatically with the Allow list.
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 a 400 before 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
Enter fullscreen mode Exit fullscreen mode

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)