DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

Dependency Injection Without Reflection: Wiring 100 Beans in 65 ms (Building Ligero, Part 3)

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

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

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

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

Application just lists modules, so the startup class never grows:

static LigeroModule[] modules() {
    return new LigeroModule[] { new ProductsModule(), new UsersModule() };
}
Enter fullscreen mode Exit fullscreen mode

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 the bind(...) 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 Context is 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)

Collapse
 
kreout profile image
Andrei Ovcharenko •

The missing-dependency row has a useful distinction: b.get(Mailer.class) still type-checks when no Mailer binding has been registered. The compiler checks the constructor call and its argument types; the registry's completeness and dependency cycles are checked by start().

A small example with one omitted binding would make that boundary concrete. It also shows the practical reason to prefer start() over buildLazy() at an application's entry point: discover the incomplete graph before serving the first request.