Most Java dependency injection frameworks do their wiring when the application starts. They scan the classpath, read annotations through reflection, build a model of every bean, create proxies, and only then serve the first request. That work is repeated on every start, it costs memory for the metadata, and it is hostile to GraalVM native image, which needs to know about every reflective access ahead of time.
Micronaut moves that work into the compiler. An annotation processor reads your @Singleton, @Inject and @Controller annotations while javac runs and writes ordinary Java classes that know how to build each bean. At runtime the container loads those generated classes and calls them; it does not scan, and for its own wiring it does not need reflection. This page explains what is generated and why it matters, how to write beans, factories and conditions, how the Netty HTTP server uses threads and how to avoid blocking it, how serialization and Micronaut Data stay reflection-free, what changed in Micronaut 5, and the failures you will meet in practice. If you are choosing between frameworks, read it next to Spring Boot, in depth and Quarkus, in depth, which solve the same problem in different places.
Doing dependency injection in the compiler
A dependency injection container has to answer three questions for every bean: what type is it, what does it need, and when should it exist. Reflection-based containers answer them at startup by inspecting classes. Micronaut answers them during compilation and records the answers as code.
For each class that carries a bean-defining annotation, the processor writes a companion class (the generated names start with a dollar sign, such as one ending in $Definition) that implements Micronaut's BeanDefinition contract. It knows the constructor to call, the arguments to resolve and their qualifiers, the scope, the conditions from @Requires, and any methods to run after construction. The processor also registers these classes so the runtime can find them with service loading rather than a classpath scan. When the BeanContext starts, it reads the registry, evaluates conditions, and instantiates singletons lazily on first use unless you ask for eager initialization.
For classes annotated with @Introspected, the processor writes a BeanIntrospection instead: a generated description of properties, getters, setters and constructor arguments. Validation, serialization and data mapping use introspections to read and write fields without java.lang.reflect. The broader story of why runtime reflection is expensive and what it unlocks is in Java reflection, in depth.
Consequences: startup cost follows the beans actually created, not the classpath size; some wiring errors surface at compile time; and native image needs little reflection configuration for framework code. The cost is that the processor must run on every compile, or beans silently disappear.
Beans, factories and conditions
Beans are ordinary classes. Prefer constructor injection: it makes dependencies explicit, keeps fields final and works without any reflective field access.
import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.context.annotation.Factory;
import io.micronaut.context.annotation.Requires;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import java.net.http.HttpClient;
import java.time.Duration;
@ConfigurationProperties("payments")
public record PaymentsConfig(String baseUrl, Duration timeout) {}
@Factory
class HttpClients {
@Singleton
@Named("payments")
HttpClient paymentsClient(PaymentsConfig cfg) {
return HttpClient.newBuilder().connectTimeout(cfg.timeout()).build();
}
}
public interface FraudCheck { boolean allow(Order o); }
@Singleton
@Requires(property = "fraud.enabled", value = "true")
class RemoteFraudCheck implements FraudCheck {
public boolean allow(Order o) { /* call the fraud service */ return true; }
}
@Singleton
@Requires(missingBeans = RemoteFraudCheck.class)
class AllowAllFraudCheck implements FraudCheck {
public boolean allow(Order o) { return true; }
}
@Singleton
public class OrderService {
private final FraudCheck fraud;
private final HttpClient payments;
OrderService(FraudCheck fraud, @Named("payments") HttpClient payments) {
this.fraud = fraud;
this.payments = payments;
}
}Read the example as data the compiler records. @ConfigurationProperties binds the payments.* keys from application.yml, environment variables or system properties into a record. @Factory produces beans for types you do not own, such as a JDK HttpClient. @Named is a qualifier, so two clients of the same type can coexist. The two FraudCheck implementations show conditional beans: the remote one exists only when a property is set, and the fallback exists only when the remote one does not. Conditions are evaluated when the context starts, which is how Micronaut modules switch themselves on when a library is present and off when it is not.
Also useful early: @Primary picks a default among several matches, @Replaces swaps a bean for your own, and @Prototype gives a new instance per injection point.
The HTTP server and its threads
Micronaut's default HTTP server runs on Netty. A small number of event loop threads, usually tied to the number of cores, accept connections, decode requests and write responses. Event loops are efficient precisely because they never wait: one thread multiplexes thousands of connections. A handler that calls a blocking JDBC driver or sleeps on a remote HTTP call holds that thread, and every other connection on the same loop waits behind it. How Netty's loops and channels work underneath is covered in Java NIO and Netty.
Micronaut decides where your controller method runs through the micronaut.server.thread-selection setting, whose values are defined by the ThreadSelection enum. The 4.x documentation describes four:
| Value | Behaviour (per the 4.x javadoc) | Use it when |
|---|---|---|
| AUTO | Choose by return type and the Blocking or NonBlocking annotations: non-reactive methods go to the blocking executor | You mix reactive and imperative handlers and want the framework to guess |
| MANUAL | Run on the event loop; your code is responsible for moving blocking work elsewhere | Handlers are reactive, or you annotate every blocking one |
| IO | Run every operation on the I/O thread pool, never on the event loop | An imperative application on older settings |
| BLOCKING | Run every operation on the blocking executor, never on the event loop | An imperative application that should use virtual threads |
Do not rely on remembering the default for your version. Either set the property explicitly, or annotate blocking controllers with @ExecuteOn(TaskExecutors.BLOCKING). In the 4.x javadoc the BLOCKING executor uses virtual threads when they are available and otherwise falls back to the IO pool. Micronaut 5 requires Java 25, so if 5.x keeps that 4.x behaviour, the blocking executor will always run on virtual threads; check the documentation for your version. What virtual threads do and where they still pin a carrier thread is explained in Java virtual threads.
@Controller("/orders")
@ExecuteOn(TaskExecutors.BLOCKING) // JDBC below: never run this on the event loop
class OrderController {
private final OrderRepository repo;
OrderController(OrderRepository repo) { this.repo = repo; }
@Get("/{id}")
Optional<OrderView> get(long id) {
return repo.findById(id).map(OrderView::from);
}
@Post
@Status(HttpStatus.CREATED)
OrderView create(@Body @Valid NewOrder req) {
return OrderView.from(repo.save(req.toEntity()));
}
}
@Serdeable
record NewOrder(@NotBlank String sku, @Positive int qty) {
Order toEntity() { return new Order(null, sku, qty); }
}
Serialization and data without reflection
JSON is where reflection usually creeps back in. Micronaut Serialization avoids it: types marked @Serdeable get build-time metadata, and the serializer reads and writes them through generated code instead of reflective field access. The practical rule is simple: every type that crosses the HTTP boundary or goes into a message must be annotated, or serialization fails at runtime with an error saying no serializable introspection is present. Third-party types you cannot annotate can be registered with @SerdeImport on one of your own classes.
Micronaut 5.0 goes further: per the release notes, serializers and deserializers are now generated at build time with SourceGen, and a @SerdeableGenerated annotation declares those generated contracts. The same release moves the optional Micronaut Jackson Databind module to Jackson 3. If you depend on Jackson-specific annotations or modules, test them after upgrading rather than assuming they carry over.
Micronaut Data applies the same idea to persistence. You declare a repository interface and the processor turns each method name into a query at compile time:
@JdbcRepository(dialect = Dialect.POSTGRES)
interface OrderRepository extends CrudRepository<Order, Long> {
List<Order> findBySkuAndQtyGreaterThan(String sku, int qty); // query built by javac
@Query("SELECT * FROM orders WHERE created_at > :since")
List<Order> recent(Instant since);
}A misspelled property in a method name, such as findBySkku, is a compile error rather than a startup failure in production. The generated query is plain SQL, which makes it easy to read in logs and to check against an index, and the JDBC variant does not need an ORM session at runtime.
Worked example: one request end to end
Follow one POST /orders through the service above, with thread-selection left alone and the controller annotated for blocking work:
- A Netty event loop thread reads the bytes, decodes HTTP, and matches the route. Route matching uses tables built from generated metadata, not reflection.
- Because the controller carries
@ExecuteOn(TaskExecutors.BLOCKING), the invocation is submitted to the blocking executor. The event loop is free immediately to serve other connections. - On a blocking (virtual) thread, the body is deserialized into
NewOrderusing its build-time serialization metadata, then validated using the introspection for@NotBlankand@Positive. A validation failure becomes a 400 response without reaching your code. - The method calls
repo.save, which runs a prepared JDBC statement the processor generated. The thread waits on the database; with virtual threads, waiting costs a small heap object instead of an OS thread. - The returned
OrderViewis serialized, and the response is handed back to the event loop, which writes it to the socket.
Now remove the annotation and set thread-selection: MANUAL. Steps 3 to 5 run on the event loop. Under load, each database round trip holds a loop thread and tail latency rises on every endpoint on that loop, health checks included. That single annotation is the most important line in an imperative Micronaut service.
Testing and building
Testing uses the real container. Annotate a JUnit 5 class with @MicronautTest and inject beans or an HTTP client into it; the context starts quickly because nothing is scanned. Use @MockBean or @Replaces to substitute collaborators, and Micronaut Test Resources to start containers such as PostgreSQL during the build.
@MicronautTest
class OrderControllerTest {
@Inject @Client("/") HttpClient client;
@Test
void createsOrder() {
var res = client.toBlocking().exchange(
HttpRequest.POST("/orders", Map.of("sku", "A-1", "qty", 2)), OrderView.class);
assertEquals(HttpStatus.CREATED, res.getStatus());
}
}Builds use the Gradle plugin io.micronaut.application or the Micronaut Maven parent, which wire in the annotation processors, choose the runtime (netty by default) and add a nativeCompile task through the GraalVM build tools. Native image itself, its closed-world analysis and its trade-offs are covered in GraalVM architecture in depth; Micronaut's contribution is that its own wiring needs almost no reflection configuration.
Micronaut 4 and Micronaut 5
Micronaut 5.0.0 was released on 20 May 2026. Its baselines are Java 25, Groovy 5 and Kotlin 2.3, so an application still on Java 17 or 21 must stay on the 4.x line until the JDK moves. The release notes list these changes that matter for operating a service:
- Container work: precomputed bean indexes, compile-time handling of
@Replaces, and support forjakarta.annotation.Priorityfor ordering. - JSpecify nullability: framework APIs adopt
@NullMarked, which tightens contracts for Kotlin callers and static analysis. Expect new warnings where your code passes null. - Serialization generated at build time, and Jackson 3 in the Jackson Databind module.
- HTTP/3 on the Netty stack promoted to stable.
- Programmatic retry and circuit breaker APIs, usable from synchronous, reactive and asynchronous code, alongside the existing annotations.
- Config imports and a
PropertySourceImporterSPI for loading configuration from files, classpath locations, environment variables and config trees. Bootstrap configuration is deprecated and may be removed in Micronaut 6.
Upgrade in two steps: move the JDK under the latest 4.x release first, then move to 5.0, so each failure has one cause.
Failure modes and trade-offs
| Symptom | Likely cause | Fix |
|---|---|---|
| NoSuchBeanException for a class that clearly has @Singleton | Annotation processor did not run: IDE build with processing disabled, or a module missing the processor dependency | Enable annotation processing in the IDE or delegate builds to Gradle or Maven; check the processor is on the module path |
| Lombok fields are null or beans missing | Lombok ran after the Micronaut processor | Put Lombok before micronaut-inject-java in the processor path |
| Latency rises on every endpoint at moderate load | Blocking call on an event loop thread | Annotate with @ExecuteOn(TaskExecutors.BLOCKING) or set thread-selection; take a thread dump to confirm |
| Serialization error naming a type at runtime | Type not annotated @Serdeable | Annotate it, or use @SerdeImport for third-party types |
| Bean exists in tests but not in production | @Requires condition depends on a property or environment that differs | Log active environments at start; test the condition explicitly |
| Native image works in JVM mode, fails natively | A library uses reflection Micronaut does not know about | Add reachability metadata or replace the library; run native tests in CI |
| Stale behaviour after a refactor | Incremental compile left an old generated class | Clean build; keep incremental processing enabled consistently |
The trade-offs against the alternatives are about where work happens. Spring Boot wires at startup, trading time for the largest ecosystem. Quarkus wires in a build step through extensions. Micronaut wires in the compiler: a simple, native-friendly runtime, but mistakes surface in the build and reflective libraries must be configured or avoided.
What to do next
- Generate a project for your JDK: Micronaut 5 needs Java 25; stay on the latest 4.x if you cannot move yet.
- Confirm the annotation processor runs in both the IDE and the build: add one bean, delete the build output, and check its generated definition class appears.
- Decide your threading rule now: either set micronaut.server.thread-selection explicitly or annotate every blocking controller with @ExecuteOn(TaskExecutors.BLOCKING).
- Annotate every request, response and message type with @Serdeable and add a test that round-trips each one.
- Move SQL access to Micronaut Data repositories and read the generated queries against your indexes.
- Write @MicronautTest tests for each controller and run them on every build.
- If you target native image, run the native test task in CI from the first week, not the week before release.
- Before upgrading to 5.0, move the JDK first under 4.x, then read the release notes for nullability and serialization changes.