Skip to main content

Dependency Injection

Ligero ships a dependency container — Beans, in ligero-core, ~170 lines — built around one idea: your wiring is plain code the compiler checks, not annotations resolved by classpath scanning and reflection at startup.

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"))));

Why lambdas instead of @Autowired

Scanning + reflection (Spring style)Beans (lambdas)
Missing dependencyruntime error, sometimes deep in a requestcompile error or fail-fast at start()
Startup costscan classpath, build metadata, reflective injectionplain constructor calls
"Where does this come from?"search annotations across the codebaseCtrl+click the lambda
GraalVM nativeneeds reachability metadata / build-time processorsworks as-is (no reflection)

Measured on a 100-bean chain (fresh JVMs, median of 5 runs): wiring with Beans costs ~65 ms — almost all of it plain class-loading — vs ~400 ms for Guice 7 and ~555 ms for Spring 6.2 with component scanning.

The API

Binding

Beans beans = Beans.builder()
.bind(Repo.class, b -> new JdbcRepo(b.get(DataSource.class))) // lazy singleton
.bindInstance(Config.class, config) // existing object
.start(); // eager: build everything, fail fast
// or .buildLazy() — beans are created on first get()

Each binding is a memoized singleton: the factory runs once, every get() after that returns the same instance.

Fail-fast validation

start() resolves the whole 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

Collections, lifecycle

List<HealthCheck> checks = beans.all(HealthCheck.class); // every bean assignable to the type

beans.close(); // closes AutoCloseable beans in reverse creation order

Stereotypes: metadata, never magic

@Component, @Service, @Repository and @Controller exist so that tools (like the devtools dashboard) can classify your beans. They never trigger scanning or reflective injection — wiring stays 100 % in your lambdas:

@Repository
public class JdbcProductRepository implements ProductRepository { ... }

@Service
public class ProductService { ... }

beans.graph() returns the typed dependency graph — nodes tagged by stereotype, edges captured from the real resolution order — which is exactly what the devtools dashboard draws.

Instrumentation hook

Beans.Builder.instrument(BeanDecorator) lets a tool wrap every bean as it is created. Devtools uses it to spy interface-typed beans in development; in production no decorator exists, so beans are exactly the objects your lambdas returned.

Beans beans = Beans.builder()
.bind(...)
.instrument(devtools.recorder()) // dev only
.start();

A layered application

The pattern we recommend (and the one the CLI generates): one composition root where the whole object graph is declared, layer by layer.

public final class Application {

public static Beans wire(Devtools devtools) {
return Beans.builder()
// infrastructure
.bind(DataSource.class, b -> Db.pooledDataSource())
// repositories (interface -> implementation)
.bind(ProductRepository.class, b -> new JdbcProductRepository(b.get(DataSource.class)))
// services
.bind(ProductService.class, b -> new DefaultProductService(b.get(ProductRepository.class)))
// controllers
.bind(ProductController.class, b -> new ProductController(b.get(ProductService.class)))
.instrument(devtools.recorder())
.start();
}
}

Binding repositories and services as interfaces keeps layers swappable (JDBC ↔ in-memory for tests) and lets devtools trace calls through them.

Modules: split the wiring by feature

One wire(...) method works for a small app, but it grows. Group each feature's bindings and routes into a LigeroModule and let Modules.install assemble them — the wiring leaves the startup class entirely. See the architecture guide for the full picture; in short:

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);
}
}

// startup:
Beans beans = Modules.install(app, devtools.recorder(), new ProductsModule(), new UsersModule());

All modules share one container (cross-module dependencies resolve naturally; duplicate bindings fail fast). The CLI generates modules and auto-registers artifacts into them.

Optional: generate the wiring at compile time

The explicit style is the default because it's the fastest and reflection-free. But if the bind(...) lines feel like boilerplate, an opt-in annotation processor (ligero-processor) writes them for you at compile time — no classpath scanning, no runtime reflection. It reads your stereotype annotations and generates the same explicit bindings.

Turn it on with one dependency:

// build.gradle
annotationProcessor 'com.ligero:ligero-processor:0.2.0-SNAPSHOT'

Then you just annotate — no module, no bind(...):

@Repository class JdbcProductRepository implements ProductRepository {
JdbcProductRepository(DataSource ds) { ... } // the constructor IS the wiring
}
@Service class DefaultProductService implements ProductService {
DefaultProductService(ProductRepository repo) { ... }
}
@Controller class ProductController {
ProductController(ProductService service) { ... }
public void register(Ligero app) { ... }
}

// third-party beans (a DataSource) come from a @Provides static method:
@Provides static DataSource dataSource() { ... }

// startup — the processor generated GeneratedModules for you:
Beans beans = Modules.install(app, devtools.recorder(), GeneratedModules.all());

The processor emits one LigeroModule per package plus a single GeneratedModules.all(). The generated code is byte-for-byte what you'd write by hand, so startup speed and native-image behavior are identical — you're just not typing it.

Explicit (default)With the processor
Wiringyou write bind(...)generated from annotations
Runtime reflectionnonenone
Native imagecleanclean
Startupfastestidentical
Config knobs@Service(as = X.class) to pick the key; @Provides for third-party beans; @Inject to disambiguate a constructor

The two styles mix: pass hand-written modules and GeneratedModules.all() together to Modules.install. Remove the dependency and you're back to explicit wiring. Scaffold either with ligero new --wiring=explicit (default) or --wiring=processor.

Why not reflection-based auto-wiring (like Spring's @Autowired)? Because runtime reflection is exactly what makes those frameworks slow to start and awkward on GraalVM — the things Ligero is built to avoid. The processor gives you the brevity without the cost, by moving the work to compile time.