Layered architectures have a visibility problem. You draw clean boxes on a whiteboard (controller, service, repository), but at runtime all you have is logs and a debugger. Which beans did this request touch? What went into the service layer, and what came out? How long did each layer take?
Ligero ships a development-time answer: ligero-devtools, a dashboard served at /ligero/dev where you run a request, watch it flow through your beans, and inspect the JSON going in and out of every layer, without a debugger, log statements, or Postman.
Think of it as "Swagger's try-it-out", plus a live dependency graph, plus a per-request tracer, in one self-contained page (no CDN assets) shipped inside your jar.
What you get
| Without devtools | With devtools |
|---|---|
| Fire requests from curl or Postman in another window | Run any route straight from the dashboard |
| Read logs to guess which beans ran | See the exact path light up in the graph |
Add System.out.println to see a layer's args/result |
Click a node: arguments and return value shown as JSON |
| Wonder how long each layer took | Every call carries its timing |
| Keep a mental model of your wiring | The dependency graph is drawn for you, coloured by stereotype |
Setup
// development only
implementation 'com.ligeroframework:ligero-devtools:<version>'
Devtools devtools = Devtools.create();
Beans beans = Beans.builder()
.bind(NameRepo.class, b -> new JdbcNameRepo(b.get(DataSource.class)))
.bind(GreetService.class, b -> new DefaultGreetService(b.get(NameRepo.class)))
.instrument(devtools.recorder()) // spies interface-typed beans
.start();
Ligero app = Ligero.create();
app.beans(beans);
devtools.install(app, beans); // mounts /ligero/dev
Open http://localhost:8080/ligero/dev, pick a route, fill in path params, query and a JSON body, and send it.
How the spying works (no agents, no bytecode)
This is the part I like, because it's built on the design decisions from the previous posts rather than on tricks.
- The container exposes an instrumentation hook:
Beans.Builder.instrument(BeanDecorator)can wrap every bean as it's created. -
devtools.recorder()is such a decorator. Beans bound as interfaces get wrapped in a JDK dynamic proxy: no bytecode generation, no Java agent. - Beans bound as concrete classes can't be proxied by the JDK. They pass through untouched and the dashboard marks them "not traceable". Bind by interface if you want them traced (which is the architecture Ligero promotes anyway).
- A middleware opens one trace per request, recording the matched route, request inputs and response body, and keeps the last 100 traces.
- The dashboard's own endpoints are never traced.
sequenceDiagram
participant B as Browser (dashboard)
participant M as TraceMiddleware
participant C as Controller (proxy)
participant S as Service (proxy)
participant R as Repository (proxy)
B->>M: POST /api/orders (X-Ligero-Dev: trace-id)
M->>C: handle
C->>S: placeOrder(args)
S->>R: save(args)
R-->>S: result + timing
S-->>C: result + timing
C-->>M: response
M-->>B: response + trace (SSE)
Each proxy records arguments, return value and elapsed time. A small, dependency-free reflective serializer (JsonValue) renders any layer's arguments and results as real JSON (records, beans, collections, maps) with depth and size caps and cycle guards.
Request correlation
When the dashboard fires a request it stamps an X-Ligero-Dev header. The trace middleware uses it as the trace id, so the browser can match the trace it receives over the live SSE stream to the request it sent. Outside the dashboard, the middleware reuses the request id when the request-id middleware is on. It's a good example of keeping a feature useful without adding a new protocol.
The graph is the real graph
The dependency graph isn't inferred from annotations or file layout. beans.graph() returns nodes tagged by stereotype and edges captured from the real resolution order at startup. Selecting a request highlights the transitive closure of beans it can reach (its dependency graph), and sending it overlays the actual execution path in the HTTP method's colour. Branches a run didn't take stay visible as dependencies but aren't part of the execution. Clicking a node shows each invocation with arguments, return value and timing.
The commerce-devtools example is a good stress test: an e-commerce backend with three modules (catalog, customers, orders) and around 15 beans with cross-module dependencies. Run it, pick POST /api/orders, and watch about 21 calls light up the path across services and repositories.
Endpoints
| Endpoint | What |
|---|---|
GET /ligero/dev |
the dashboard (self-contained HTML) |
GET /ligero/dev/api/routes |
registered routes |
GET /ligero/dev/api/graph |
bean graph JSON (nodes, edges, unspied list) |
GET /ligero/dev/api/requests |
last 100 traces, most recent first |
GET /ligero/dev/api/stream |
SSE stream of completed traces |
Never in production
Devtools exposes the arguments and return values of your beans. Two switches keep it out of production:
-
Classpath (recommended): keep the dependency out of production builds. Zero overhead, because nothing is there. The CLI can scaffold a project without it (
--devtools false). -
Environment:
LIGERO_DEVTOOLS=falseturnsinstall()into a no-op without a code change.
Why this was cheap to build
Notice what devtools did not need: a classpath scanner, an agent, or annotation processing. Because wiring is explicit (post 3) and layers talk through interfaces (post 1), the framework already had the two things a tracer needs: a place to wrap objects and a graph of who depends on whom. Good architecture makes tooling cheap.
Takeaways
- Instrumentation hooks in the container plus interface-typed beans let you trace every layer with plain JDK proxies.
- Draw the real dependency graph from real resolution order, not from conventions.
- Make dev tooling impossible to ship by accident: keep it off the production classpath.
Next: observability for production, where we trade the dashboard for health checks, route-pattern metrics, trace context and OpenTelemetry.
Top comments (0)