Most Java developers meet dependency injection through annotations: @Autowired, @Component, a classpath scan, and a container that wires everything reflectively at startup. It works, and it's productive. It also hides where objects come from, and moves a whole class of mistakes (a missing bean, a cycle) from compile time to runtime.
Ligero takes the other road. Its container, Beans, is about 170 lines in ligero-core, and it's built on one idea: your wiring is plain code the compiler checks.
The API
Beans beans = Beans.builder()
.bind(DataSource.class, b -> pgDataSource())
.bind(ProductRepository.class, b -> new JdbcProductRepository(b.get(DataSource.class)))
.bind(ProductService.class, b -> new ProductService(b.get(ProductRepository.class)))
.start(); // instantiates and validates the WHOLE graph here
Ligero app = Ligero.create();
app.beans(beans); // every bean available to handlers via ctx.get(...)
app.get("/products/{id}", ctx ->
ctx.json(ctx.get(ProductService.class).find(ctx.pathParamAsInt("id"))));
Each binding is a memoized singleton: the factory lambda runs once and every later get() returns the same instance. bindInstance registers an existing object; buildLazy() defers creation to first use if you prefer.
Fail-fast, with readable errors
start() resolves the entire graph immediately, so a broken wiring can never reach a request:
No binding for interface com.acme.Mailer (needed by com.acme.SignupService)
Dependency cycle: SignupService -> Mailer -> SignupService
Compare where each failure surfaces:
| Scanning + reflection |
Beans (lambdas) |
|
|---|---|---|
| Missing dependency | runtime error, sometimes deep in a request | compile error, or fail-fast at start()
|
| Startup cost | scan classpath, build metadata, reflective injection | plain constructor calls |
| "Where does this come from?" | search annotations across the codebase | Ctrl+click the lambda |
| GraalVM native | needs reachability metadata or build-time processors | works as-is (no reflection) |
What it costs
On the project's benchmark harness, wiring a chain of 100 beans in fresh JVMs (median of 5 runs):
| Container | Median wiring time | Relative |
|---|---|---|
Ligero Beans
|
~65 ms | 1x |
| Guice 7 | ~400 ms | ~6x |
| Spring 6.2 (component scanning) | ~555 ms | ~8.5x |
The honest reading: most of Ligero's ~65 ms is plain class loading; the container itself works in microseconds. That's the whole point. Explicit lambda bindings do no scanning and no reflective injection, so "wiring" is essentially running your constructors. (The usual caveat applies: these are one harness on one machine, and Spring and Guice give you far more than wiring. The comparison is about this specific cost.)
Stereotypes are metadata, never magic
Ligero does provide @Component, @Service, @Repository and @Controller, but they never drive injection. They exist so tools can classify beans:
@Repository
public class JdbcProductRepository implements ProductRepository { ... }
@Service
public class ProductService { ... }
beans.graph() returns the typed dependency graph, with nodes tagged by stereotype and edges captured from the real resolution order. The devtools dashboard (post 5) draws exactly that.
Modules: vertical slices that own their wiring
A large app is a set of features. A LigeroModule groups the controller, service, repository and routes of one feature, and declares its own bindings:
public final class ProductsModule implements LigeroModule {
@Override
public void beans(Beans.Builder builder) {
builder.bind(ProductRepository.class, b -> new JdbcProductRepository(b.get(DataSource.class)));
builder.bind(ProductService.class, b -> new DefaultProductService(b.get(ProductRepository.class)));
builder.bind(ProductController.class, b -> new ProductController(b.get(ProductService.class)));
}
@Override
public void routes(Ligero app, Beans beans) {
beans.get(ProductController.class).register(app);
}
}
Application just lists modules, so the startup class never grows:
static LigeroModule[] modules() {
return new LigeroModule[] { new ProductsModule(), new UsersModule() };
}
All modules contribute to one container, so cross-module dependencies resolve naturally (a shared DataSource from an infrastructure module, say). Binding the same type twice fails at startup.
flowchart LR
subgraph Products
PC[ProductController] --> PS[ProductService] --> PR[ProductRepository]
end
subgraph Orders
OC[OrderController] --> OS[OrderService]
end
OS --> PS
PR --> DS[(DataSource)]
Interfaces between layers
Each layer depends on the next through an interface. Three payoffs: swap implementations without touching callers (in-memory to JDBC), inject fakes in tests, and let tooling wrap interface-typed beans in spy proxies. That last one is the bridge to devtools.
Lifecycle and instrumentation
beans.close() closes AutoCloseable beans in reverse creation order. beans.all(HealthCheck.class) returns every bean assignable to a type. And Beans.Builder.instrument(BeanDecorator) lets a tool wrap every bean as it's created; in production no decorator exists, so beans are exactly the objects your lambdas returned.
Trade-offs, plainly
- You write the bindings. The CLI generates and registers them for you (
ligero generate service Invoice --module Billing), and an optional compile-time annotation processor (ligero-processor) can generate thebind(...)calls, still with no runtime reflection. - No scoped beans (request scope, prototype) out of the box. Singletons cover most service-style apps; if you need request-scoped state, that's what the
Contextis for. - It's deliberately not a Spring replacement. It's the smallest container that gives fail-fast wiring, modules and tracing hooks.
Takeaways
- Wiring as lambdas turns missing beans and cycles into startup failures with readable messages.
- No scanning and no reflective injection means wiring cost is basically your constructors.
- Stereotypes are metadata; modules keep each feature's wiring next to its code.
Next: the benchmark that told me my fastest engine was my slowest, and the one-line reason why.
Top comments (1)
The missing-dependency row has a useful distinction:
b.get(Mailer.class)still type-checks when noMailerbinding has been registered. The compiler checks the constructor call and its argument types; the registry's completeness and dependency cycles are checked bystart().A small example with one omitted binding would make that boundary concrete. It also shows the practical reason to prefer
start()overbuildLazy()at an application's entry point: discover the incomplete graph before serving the first request.