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:
-
Retrywith fixed or exponential backoff, -
Timeout, which runs the call on a virtual thread and abandons overruns, -
CircuitBreakerwith 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=noneand algorithm-confusion tokens are rejected; - signature comparison is constant-time;
-
expandnbfare 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 bykid, 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);
...
});
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
Beansuse no runtime reflection (an architecture test in the repo enforces this), so there is nothing to register. -
Compile-time DI option:
ligero-processorgenerates thebind(...)wiring at compile time. -
ServiceLoaderover scanning: engines, body mappers and template engines are discovered viaMETA-INF/services, whichligero-corealready 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"));
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)