Articles
Micronaut Interview Questions: Compile-Time Injection, Qualifiers, and Blocking Calls
Practice Micronaut interview questions with compile-time injection, bean qualifiers, event-loop blocking, @ExecuteOn, and a testable invoice API case.
A Micronaut service can start quickly and still fail under load. In an interview case, two InvoiceStore beans exist, one controller expects the SQL implementation, and a synchronous JDBC call runs on a Netty event-loop thread. Explaining that Micronaut generates bean metadata at build time addresses only the first layer. A strong answer must also show which bean is selected at runtime and where the blocking request work executes. The current Micronaut core guide documents compile-time dependency injection, bean qualifiers, and the need to offload blocking work from event-loop execution.
This article follows a synthetic invoice API through compilation, bean resolution, request handling, and load testing. It is original interview practice, not a candidate report about a particular employer. For a related but different container exercise, Explain Spring Dependency Injection and Choose an Injection Style asks what happens when two implementations satisfy one interface; here we answer it with Micronaut's rules and then add the runtime thread boundary.
The case: two stores and one slow endpoint
Assume an InvoiceStore interface with a SQL implementation backed by a blocking JDBC driver and a preview implementation backed by in-memory data. The production endpoint GET /invoices/{id} must use SQL. A developer adds both implementations as beans, injects InvoiceStore without a qualifier, then tries to fix resolution by making SQL @Primary. The endpoint works locally but p99 latency spikes when many requests arrive. A thread dump shows request work waiting on the database while the server's event-loop threads are occupied.
Clarify the contract before proposing an annotation. Is preview active only in tests or in production? Is the SQL client truly synchronous? What is the connection-pool limit? Does this endpoint return a plain object, a future, or a reactive publisher? Which Micronaut version and server thread-selection configuration are in use? The answer can differ with those choices. In this exercise, both beans may exist in production, the driver blocks, and the endpoint returns a reactive Publisher unless explicitly offloaded.
The reader's urgent questions are: what is actually done at compilation; how a qualifier differs from a primary default; why “reactive return type” does not make blocking JDBC non-blocking; where to place @ExecuteOn; and how to prove the selected bean and thread path. The thesis is that wiring time and execution time are separate boundaries. One correct annotation does not repair the other.
Question 1: What does compile-time injection buy, exactly?
The Micronaut guide explains that annotation processing generates bean definitions and injection metadata during compilation, avoiding the broad runtime classpath scanning and reflection often associated with DI containers. This can improve startup and reduce reflection needs. It does not mean the Java compiler validates every production configuration value or predicts database availability. Conditional beans and runtime configuration still affect which definitions are enabled when the application context starts.
In this fixture, the build produces definitions for eligible bean classes. At context startup, Micronaut applies conditions, selects candidates, and constructs the graph. If no eligible InvoiceStore exists or multiple unqualified candidates remain, resolution fails when the relevant bean is created. Do not promise that every ambiguous dependency is necessarily a compile error. The distinction matters: generated metadata is a compile-time fact, whereas environment-specific bean activation is a runtime fact.
Ask the candidate to draw a two-stage timeline: source annotations → generated bean definitions → runtime condition/qualifier resolution → object construction. Then remove the SQL module from a test configuration. What is observed: a compiler error, context-start failure, or a successful context with a different candidate? The answer depends on annotations and the test's active environment. A small context-start test is stronger evidence than a claim about the framework's speed.
The practical payoff is not “never use reflection” as an absolute. It is that the container's ordinary wiring metadata is generated ahead of time. Other libraries, serializers, proxies, or application code may still have their own reflection behavior. Name the specific mechanism the interview asks about rather than attaching every runtime property to compile-time DI.
Question 2: How should two InvoiceStore beans be selected?
Micronaut's bean qualification guidance covers named qualifiers and @Primary when several beans implement one type. A qualifier at the injection point states which implementation this consumer needs. A primary bean supplies a preferred default for an otherwise unqualified injection. Those convey different architectural intent.
If production invoice reads must always use SQL even when a preview implementation is installed, make that requirement explicit. A Java sketch is:
@Singleton
@Named("sql")
final class SqlInvoiceStore implements InvoiceStore { /* JDBC work */ }
@Singleton
@Named("preview")
final class PreviewInvoiceStore implements InvoiceStore { /* demo data */ }
@Singleton
final class InvoiceService {
InvoiceService(@Named("sql") InvoiceStore store) {
this.store = store;
}
private final InvoiceStore store;
}
Imports and method bodies are omitted to focus on selection. Use jakarta.inject.Named/Singleton with the chosen Micronaut version. If SQL is genuinely the application-wide default and only a few consumers opt into preview, marking SQL @Primary can be reasonable. But it is a weaker expression of this service's invariant: a later third bean or changed primary can silently alter an unqualified consumer. A named qualifier makes the required implementation visible at the constructor.
Another choice is to activate preview only under an appropriate environment or property with @Requires, which the core guide documents. That removes an unnecessary candidate in production. Do not use environment conditions as a substitute for an explicit policy if both implementations legitimately coexist there. Test both the production and preview context: assert the selected class or distinctive behavior, not merely that the context starts.
Question 3: Can @Primary hide a bad dependency graph?
Yes. It resolves a default candidate, but it does not explain why two implementations are registered or which consumers are supposed to use each. The diagnostic sequence is: list eligible InvoiceStore beans under the active environment; inspect @Requires conditions and factory methods; inspect qualifier annotations at bean and injection point; then decide whether a default should exist at all. Adding @Primary before this inventory may make the startup error disappear while routing an audit endpoint to preview data.
The distinction resembles a database query choosing an arbitrary row: an answer exists, but it may be the wrong one. If a controller is intentionally polymorphic and needs all stores, inject a collection and select with an explicit request policy. If it needs exactly SQL, select SQL. If preview should never ship, remove or conditionally exclude that bean. Avoid “make one primary” as an automatic fix.
Scope also matters. A @Singleton store can share a connection pool or stateless client, but it must not hold a mutable currentInvoiceId field per request. Compile-time injection does not make an object thread-safe. Pass request data as method arguments or use a suitable request-bound abstraction, then test overlapping requests. This is an inference from Java object sharing and the fixture's concurrency; it is not a special Micronaut guarantee.
Question 4: Why is the event loop blocked by a reactive endpoint?
The Micronaut guide's HTTP discussion says a reactive return type can be subscribed to on the request's Netty event-loop thread. If the pipeline then calls a synchronous JDBC method there, the method waits on I/O while that event-loop thread cannot process other work. Returning a Publisher changes the interface of the computation; it does not turn a blocking library into a non-blocking one.
Trace one request. The server accepts it, routes to the controller, invokes or subscribes to the returned work, enters SqlInvoiceStore.findById, waits for a database connection/query, and finally writes a response. If the wait happens on the event loop, many slow database calls can reduce the server's ability to progress unrelated requests. A thread dump and rising event-loop delay are evidence. A high p99 alone is not enough to locate the blocked thread; it could be a saturated database, pool, DNS, or downstream API.
The server thread-selection API distinguishes automatic, manual, I/O, and blocking choices. Defaults and available executors can vary by version and configuration, so do not state that every Micronaut controller always runs on the event loop. In the stated fixture, the reactive path is observed there. Your fix should be based on that observed path and the current application's configuration.
Question 5: Where should @ExecuteOn go?
The Micronaut core guide presents @ExecuteOn and appropriate executors as ways to shift blocking work away from the event loop. For a simple controller whose request handling is entirely blocking, @ExecuteOn(TaskExecutors.BLOCKING) on the route method or controller can make the thread boundary clear. With a reactive pipeline, another legitimate solution is to offload the precise blocking source using the reactive library's scheduler, then switch back or compose safely as the API requires. Pick one understandable boundary and test it.
Placement is not cosmetic. An annotation on a method that is never invoked through the expected framework path may not protect a blocking call hidden later inside a custom publisher. Conversely, moving every route to a shared worker pool because one dependency blocks can waste threads and conceal a long-running operation. First name the blocking call. Then identify the framework-managed entry or scheduler that encloses it. Finally, measure which thread actually invokes that call under load.
Offloading prevents event-loop blockage; it does not make the database faster or create infinite capacity. If the worker executor has 32 effective threads and the JDBC pool has 8 connections, a burst can queue for connections. Size both according to actual latency and throughput, set timeouts, and watch queue depth and rejection behavior. Do not respond to a slow p99 by simply growing the thread pool without checking the database and pool constraints.
Virtual threads may change the cost of waiting on supported JDK/runtime setups, but they do not erase connection limits or justify blocking on an event-loop carrier. The Micronaut configuration reference lists executor options including virtual-thread settings. State the deployment's JDK and Micronaut configuration before making a universal virtual-thread claim.
A diagnostic exercise with observable answers
Give the candidate a symptom and require evidence before the fix. Start with 20 concurrent requests to /invoices/42, a deliberately slow fake SQL store, and a fast unrelated /health-demo route. In a development test, record current thread names at controller entry and inside findById, active JDBC connections, worker queue depth, event-loop delay, and latency for both routes. The slow fake store is a controlled fixture; do not introduce sleeps into production code.
| Observation | Plausible reading | Next check |
|---|---|---|
Context cannot select InvoiceStore | Two eligible beans, missing qualifier, or changed condition | Enumerate candidates and injection-point qualifier |
| Endpoint returns preview data | Default/primary or environment selection is wrong | Assert bean identity under production configuration |
| JDBC work appears on event-loop thread | Blocking call is on the request execution path | Add a deliberate offload boundary and remeasure thread names |
| Event loop is free but p99 remains high | Worker, connection pool, or database may be saturated | Compare worker queue, pool wait, query time, and timeouts |
| Fast route slows only during invoice burst | Shared execution resource is contended | Compare before/after offload under the same load |
| Context starts in tests but fails in production | Runtime conditions/configuration differ | Start the context with production-like properties |
Run the same load after selecting the SQL bean explicitly and moving the blocking call. A successful result is not just lower invoice p99. It is that the intended bean handles the request, the blocking operation is no longer on the event loop, the unrelated route remains responsive, and queue growth stays bounded. If p99 remains high, that is new information about downstream capacity, not proof that offloading failed.
The answer should include a negative test: remove the sql bean or alter the qualifier and confirm the service fails conspicuously rather than silently using preview data. It should also include a concurrency test that shows a singleton service does not leak an invoice ID between overlapping requests. These tests tie container semantics and runtime behavior to the business result.
How to answer the interview in two minutes
Say: “Micronaut generates bean definitions and wiring metadata at build time, but runtime conditions and qualifiers still determine the active candidate. Because this service specifically requires SQL, I would qualify the injection point or conditionally remove preview; I would not add @Primary just to silence ambiguity. Then I would trace the request thread. A reactive return type does not make JDBC non-blocking, so I would offload that call with a framework-supported executor or a correctly scheduled publisher, and verify the thread path and queue metrics under load.”
That concise answer names both boundaries. Follow it with one falsifiable prediction: after offloading, the unrelated fast route should stay responsive during slow invoice reads, although invoice latency may still reflect database limits. If asked about versions, identify the app's actual Micronaut release and server thread-selection settings before promising a specific default. The point is to make the fix observable, not to memorize annotation names.