DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

Production-Hardening a Java Framework: Resilience, JWT/JWKS, Native Image and an MCP Server (Building Ligero, Part 7)

A framework core can stay tiny only if the things real services need live somewhere else. In Ligero that "somewhere else" is a set of small, optional, mostly dependency-free modules. This last post of the series tours the production-hardening ones: resilience, scheduling, events, authentication, native image, and a couple of newer experiments (an HTTP QUERY method and an MCP server).

As with the rest of the series: Ligero is pre-1.0. Check the changelog for what's released versus unreleased.

Resilience: ligero-resilience

Outbound calls fail. The module gives you three primitives with no dependencies:

  • Retry with fixed or exponential backoff,
  • Timeout, which runs the call on a virtual thread and abandons overruns,
  • CircuitBreaker with the usual closed, open, half-open states.

The virtual-thread timeout is a nice example of Java 21 paying off: you can run the work on a cheap thread, wait with a deadline, and walk away from a hung call without a bespoke thread-pool strategy.

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: failures exceed threshold
    Open --> HalfOpen: after cool-down
    HalfOpen --> Closed: probe succeeds
    HalfOpen --> Open: probe fails

Scheduling and events

ligero-scheduler is a small scheduler for background work: fixedRate, fixedDelay, once and dailyAt. Timing runs on a small daemon pool, but each task runs on its own virtual thread, so a slow or throwing job never stalls the timer or cancels its own schedule.

In ligero-core, an in-process Events bus provides typed publish/subscribe (with supertype delivery and isolated handler failures), and Ligero.onStart(...) / onStop(...) hooks run when the server starts and stops. There's also a Cache<K,V> SPI with load-through helpers and TTLs, an in-process InMemoryCache, and a distributed RedisCache in ligero-redis.

Authentication and authorization: ligero-auth

JWT support is implemented with the JDK's own crypto (javax.crypto / java.security), so there are no external dependencies. Properties worth calling out:

  • alg=none and algorithm-confusion tokens are rejected;
  • signature comparison is constant-time;
  • exp and nbf are enforced;
  • RS256 and ES256 (sign and verify) sit alongside HS256, and Jwks.parse(json, mapper) turns an OIDC provider's JWKS into JDK public keys by kid, so a service can validate tokens issued by an external identity provider. The verifier pins the algorithm, which defends against alg-confusion.
Jwt jwt = Jwt.hs256(secret, new JacksonBodyMapper());   // secret >= 32 bytes
String token = jwt.sign(Map.of("sub", "ada", "roles", List.of("admin")), Duration.ofHours(1));

app.use("/api", JwtAuthMiddleware.of(jwt));
app.get("/api/admin/stats", ctx -> {
    JwtAuthMiddleware.requireRole(ctx, "admin");        // 403 without the role
    String user = ctx.attribute(JwtAuthMiddleware.USER_ATTRIBUTE);
    ...
});
Enter fullscreen mode Exit fullscreen mode

The module also covers stateless double-submit CSRF (XSRF-TOKEN cookie echoed in X-XSRF-TOKEN), HMAC-signed-cookie sessions with a pluggable SessionStore, and Basic auth. Scale-out is a module away: ligero-redis provides Redis-backed rate-limit and session stores shared across instances.

Data, your way

Three independent modules so you take only what you want: ligero-jdbc (SQL to records, transactions, no ORM, with optional HikariCP pooling via DataSources.pooled(...)), ligero-jpa (a thin Hibernate helper, bring your own provider), and ligero-migrations (Flyway at startup). Request validation is annotation-based (ligero-validation) and turns violations into automatic 400s.

Native image

GraalVM native-image does a closed-world analysis: anything reached by reflection, dynamic proxies or unregistered resources must be declared in metadata. Ligero's earlier design decisions pay off here:

  • Reflection-free core: routing, the middleware pipeline and Beans use no runtime reflection (an architecture test in the repo enforces this), so there is nothing to register.
  • Compile-time DI option: ligero-processor generates the bind(...) wiring at compile time.
  • ServiceLoader over scanning: engines, body mappers and template engines are discovered via META-INF/services, which ligero-core already registers for native.

Modules that are native-ready by construction include ligero-core, ligero-server-jdk, ligero-scheduler, ligero-resilience, ligero-auth and ligero-jdbc. The reflective adapters (Jackson, Hibernate) bring their own metadata needs, and the design note documents those boundaries rather than pretending they don't exist. The CLI itself ships as a native binary.

Two experiments worth watching

HTTP QUERY. app.query(path, handler) registers handlers for the IETF QUERY method: a safe, idempotent method like GET that carries a request body, for queries too large or structured for a URL. You read the body with ctx.body(...) as for POST. It's an early standard, so treat it as an experiment.

MCP servers: ligero-mcp. The Model Context Protocol lets an LLM call tools exposed by your app over JSON-RPC 2.0 (Streamable HTTP). With Ligero (illustrative sketch; see the docs for the exact schema type):

McpServer mcp = McpServer.create("orders", "1.0.0")
    .tool("find_order", "Look up an order by id", schema, args -> lookup(args));

app.use(mcp.http("/mcp"));
Enter fullscreen mode Exit fullscreen mode

It implements the initialize handshake, ping, tools/list and tools/call, surfaces tool failures as MCP error results, and, like the rest of the framework, is dependency-free and reflection-free. If you were going to expose existing service capabilities to an AI agent anyway, doing it in the same process as your authenticated, observable HTTP stack is a pragmatic place to start.

OpenAPI

ligero-openapi generates an OpenAPI 3 document from your routes, with an opt-in Swagger UI. OpenApi.model(Class) generates components/schemas from your record types (scalars, enums, temporals, collections and nested records), using reflection only at document-build time, never on the request path.

The shape of the whole

flowchart TB
    core["ligero-core (router, middleware, Beans, SPIs)"]
    core --> eng["engine: jdk | jetty"]
    core --> obs["observability: otel, micrometer"]
    core --> sec["auth, validation, redis"]
    core --> data["jdbc, jpa, migrations"]
    core --> ops["resilience, scheduler, cache, events"]
    core --> dev["devtools, openapi, mcp"]

Everything hangs off a small core through SPIs and middleware. That's the series in one picture.

What I'd say after building it

  • Small core, sharp edges removed. The things that hurt in production (startup, hidden wiring, missing defaults) are the ones the core addresses directly.
  • Honesty is a feature. Docs state boundaries (what's native-ready, what needs metadata, what the JDK engine can't do), and the benchmark harness is public.
  • It's an open project. If you want to try it, break it, or contribute, start at the repo, the docs and the examples.

Thanks for reading the series. If there's a part you want to see go deeper, tell me in the comments.

Top comments (0)