Advanced CDI in Quarkus: Native Images, Reactive DI, and Custom Scopes
A practical guide to extending Quarkus dependency injection for native performance, reactive flows, and fine-grained lifecycle control.
If you’ve already mastered the basics of CDI in Quarkus, which are scopes, producers, qualifiers, and injection, you’re off to a solid start. But real-world projects often demand a bit more. Maybe you need fine-grained lifecycle control. Maybe your app runs as a native executable where reflection is off the table. Or maybe you’re going fully reactive and wonder how CDI fits in.
This article explores three advanced topics that every serious Quarkus developer should understand:
Defining custom CDI scopes
Running CDI in GraalVM native images
Using dependency injection in reactive Quarkus applications
As always, we’ll keep things hands-on and practical. Let’s jump in.
Build Compatible Extensions: Custom CDI Behavior in Quarkus
In traditional CDI, defining custom scopes often involves implementing a full context and registering it with a portable extension. But Quarkus only supports CDI Lite, which excludes portable extensions and custom context registration the classic way.
Instead, Quarkus supports Build Compatible Extensions introduced in CDI 4.0.
When and Why to Use BCEs
Use Build Compatible Extensions when you need to:
Register synthetic beans or observers
Customize bean discovery
Dynamically control injection behavior
Mimic custom scopes using programmatic lookups or proxies
You do not implement scopes per se, but you can simulate their behavior using BCEs and application-controlled lifecycles.
Example: Context-Aware Injection via BCE
Let’s say you want to inject a TenantContext
that behaves like a request-specific bean but without using @RequestScoped
.
You can simulate this using:
A manually set ThreadLocal context
A synthetic bean registered via BCE
public class TenantContext {
private final String tenantId;
public TenantContext(String tenantId) {
this.tenantId = tenantId;
}
public String tenantId() {
return tenantId;
}
}
Now register a synthetic bean using a Build Compatible Extension:
@BuildCompatibleExtension
public class TenantContextExtension {
private static final ThreadLocal<TenantContext> current = new ThreadLocal<>();
public void defineBeans(BuildCompatibleExtension.BeanRegistrar registrar) {
registrar.register(TenantContext.class, b -> b.createWith(
ctx -> current.get()
));
}
public static void setCurrentTenant(String tenantId) {
current.set(new TenantContext(tenantId));
}
public static void clear() {
current.remove();
}
}
Now you can inject TenantContext
anywhere:
@Inject
TenantContext tenantContext;
public void doSomething() {
log.info("Current tenant: " + tenantContext.tenantId());
}
This achieves the same goal as a custom scope without violating Quarkus's build-time design.
Observing Application Lifecycle: Use @Startup
and @Shutdown
Previously, you might have seen examples like this:
void onStart(@Observes @Initialized(ApplicationScoped.class) Object init) { ... }
However, this pattern is misleading in Quarkus, especially in native images, where @Initialized(ApplicationScoped.class)
may fire during the image build phase—not runtime.
Since CDI 4.0, you can observe Startup
and Shutdown
directly, which work consistently across JVM and native modes.
Recommended Way in CDI 4+
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.Startup;
import jakarta.enterprise.inject.Shutdown;
public class AppLifecycle {
void onStart(@Observes Startup event) {
System.out.println("App started!");
}
void onStop(@Observes Shutdown event) {
System.out.println("App shutting down!");
}
}
Alternatively, in Quarkus, you can use the @Startup
annotation on beans:
@ApplicationScoped
@io.quarkus.runtime.Startup
public class BootstrapBean {
@PostConstruct
void init() {
System.out.println("Startup logic runs here");
}
}
This is simpler and gives you more control without relying on CDI events.
CDI in GraalVM Native Images
When compiling Quarkus apps to native executables using GraalVM, a common concern is: will CDI still work?
The short answer is yes, but with some caveats.
How Quarkus Makes It Work
Quarkus performs CDI bean discovery and injection wiring at build time. This means:
No reflection is needed at runtime
The DI graph is baked into the binary
Startup time is extremely fast
Memory usage is minimal
That said, here are a few tips to keep in mind.
Tip 1: Avoid Reflection
In native mode, reflection is disabled by default. Quarkus will generate bytecode to wire up your beans, but if you use things like:
Dynamically loaded classes
Class.forName()
Reflection-based injection
You may run into issues. Use the @RegisterForReflection
annotation if you really need it, or better, redesign to avoid reflection altogether.
Tip 2: Use @Inject
Rather Than Manual Lookups
Stick to @Inject
, constructor injection, and scopes. Avoid calling CDI.current().select(...)
or manually looking up beans unless you have registered those beans for reflection.
Tip 3: Producer Methods Are Supported
Quarkus processes producer methods at build time and includes them in the native image, as long as they do not rely on dynamic runtime configuration or untracked types.
Reactive Programming with CDI in Quarkus
Quarkus has first-class support for reactive programming via Vert.x, Mutiny, and RESTEasy Reactive. But how does CDI work in a reactive, non-blocking environment?
Good news: it works exactly the way you expect it to.
Injecting Beans in Reactive Resources
Suppose you have a service that returns a Uni
from Mutiny:
@ApplicationScoped
public class CoffeeService {
public Uni<String> fetchCoffee() {
return Uni.createFrom().item("Fresh reactive espresso");
}
}
Inject it in your reactive REST endpoint:
@Path("/reactive-coffee")
public class ReactiveCoffeeResource {
@Inject
CoffeeService coffeeService;
@GET
public Uni<String> getCoffee() {
return coffeeService.fetchCoffee();
}
}
It’s that simple. All injection is handled at startup, and you’re free to compose Uni
or Multi
chains.
Scoped Beans in Reactive Chains
Be careful when using @RequestScoped
beans inside a Uni
or Multi
. Since reactive operations may execute on different threads, the request context might not be active.
To solve this, either:
Stick to
@ApplicationScoped
and stateless servicesUse
@RestClient
to access scoped services from a separate boundaryOr manually activate the context using
RequestContextController
Example:
@Inject
RequestContextController requestContextController;
Then activate/deactivate manually:
requestContextController.activate();
Uni<String> result = service.getData()
.eventually(() -> requestContextController.deactivate());
But in most cases you should not need this as Quarkus does it for you.
Quarkus makes dependency injection not just fast but flexible
Whether you need custom lifecycles, native-image compatibility, or full-blown reactive integration, the CDI model scales with you.
Let’s recap what you learned:
How to define and register custom scopes with CDI
What works and what to avoid in native-image builds
How to safely use dependency injection in reactive endpoints and services
Quarkus handles the hard parts so you can keep writing clean, modern Java code.