Mastering Dependency Injection with Quarkus: A Java Developer’s Guide
Everything you need to know about CDI scopes, injection points, and producers. Optimized for cloud-native Java.
Dependency injection is one of those fundamental concepts that every Java developer eventually encounters. Whether you arrived through Spring Boot, Java EE, or just stumbled across @Inject
, you probably know the benefits: cleaner code, easier testing, better separation of concerns. But when performance and container efficiency start to matter, not all dependency injection frameworks are created equal.
Quarkus is a modern Java framework built for a container-first, Kubernetes-native world. At its core is a build-time optimized implementation of Jakarta Contexts and Dependency Injection (CDI). It gives you all the flexibility of CDI without the runtime cost.
In this article, we’ll go hands-on with Quarkus and CDI 4.1, walking through the fundamentals of dependency injection, scopes, qualifiers, producers, and lifecycle events. To make things fun, we’ll build a small but practical coffee shop example along the way.
What Is CDI and Why Quarkus Uses It
CDI (Contexts and Dependency Injection) is the standard for dependency injection in the Jakarta EE world. It allows you to create loosely coupled components with well-defined lifecycles. Unlike frameworks that rely heavily on reflection or dynamic proxies at runtime, Quarkus processes CDI metadata at build time, which results in fast startup and low memory usage.
If you're familiar with Spring, think of CDI as the Jakarta EE equivalent of Spring's @Component
model, but backed by a formal specification and an ecosystem of compatible runtimes.
In Quarkus, CDI is enabled by default. You get a full-featured DI container out of the box without any special setup.
Setting the Stage: A Simple Coffee Shop
Let’s imagine you’re building a REST API for a coffee shop. You need a machine to brew coffee and a REST endpoint to serve it.
Create a new Quarkus project (or use your existing setup):
quarkus create app com.coffee:coffee-shop
cd coffee-shop
This gives you a simple Quarkus application with REST and CDI support.
Writing Your First Bean
In CDI, any class can become a bean by annotating it with a scope. The most common scope is @ApplicationScoped
, which makes the bean a singleton for the life of the application.
package com.coffee;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CoffeeMachine {
public String brew() {
return "Brewing a fresh espresso...";
}
}
This class is now a managed bean. It can be injected into other beans using @Inject
.
package com.coffee;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/coffee")
public class CoffeeResource {
@Inject
CoffeeMachine coffeeMachine;
@GET
public String getCoffee() {
return coffeeMachine.brew();
}
}
Start the application with:
quarkus dev
Open http://localhost:8080/coffee and you’ll see:
Brewing a fresh espresso...
Congratulations—you’ve injected your first bean.
Understanding CDI Scopes
Scopes define how long an instance of a bean lives and who gets access to it. Quarkus supports all standard CDI scopes.
@ApplicationScoped
The bean is created once and shared across the entire application. Ideal for services that do not hold per-user or per-request state.
@RequestScoped
A new bean instance is created for every HTTP request. This is great for short-lived context like user session metadata.
import jakarta.enterprise.context.RequestScoped;
import java.util.UUID;
@RequestScoped
public class OrderContext {
private final UUID orderId = UUID.randomUUID();
public UUID getOrderId() {
return orderId;
}
}
@Dependent
This is the default scope. The bean has no lifecycle of its own and is created every time it is injected. It is useful for lightweight, stateless classes.
public class ReceiptPrinter {
public void print(String message) {
System.out.println("Receipt: " + message);
}
}
@Singleton
vs @ApplicationScoped
Do not use @Singleton
from jakarta.inject
. Use @ApplicationScoped
instead. It integrates better with CDI lifecycle management.
Constructor Injection vs Field Injection
CDI supports multiple ways to inject beans. Quarkus supports them all, but constructor injection is generally recommended.
Field Injection
@Inject
InventoryService inventory;
Quick and convenient but harder to test.
Constructor Injection
private final InventoryService inventory;
@Inject
public CoffeeResource(InventoryService inventory) {
this.inventory = inventory;
}
Clean, testable, and compatible with final
fields.
Using Qualifiers for Multiple Implementations
What if you have more than one Coffee
implementation? Use qualifiers to differentiate them.
First, define the qualifier annotations:
import jakarta.inject.Qualifier;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface Strong {}
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface Mild {}
Now create multiple beans:
@ApplicationScoped
@Strong
public class Espresso implements Coffee {
public String brew() {
return "Espresso: strong and short.";
}
}
@ApplicationScoped
@Mild
public class Latte implements Coffee {
public String brew() {
return "Latte: smooth and creamy.";
}
}
Inject the one you want using the qualifier:
@Inject
@Mild
Coffee coffee;
Qualifiers are especially useful when mocking, A/B testing, or implementing pluggable strategies.
Producer Methods and Fields
Not all beans can be constructed with new
. Sometimes, you need to provide a factory method—this is where producers come in.
@ApplicationScoped
public class FrotherProducer {
@Produces
public MilkFrother produceFrother() {
String type = System.getProperty("frother.type", "steam");
return switch (type) {
case "electric" -> new ElectricFrother();
default -> new SteamFrother();
};
}
}
Now inject it anywhere:
@Inject
MilkFrother frother;
You can also use producer fields or static methods. Quarkus processes them at build time just like regular beans.
Alternatives and Conditional Beans
CDI supports the concept of @Alternative
beans, which you can enable selectively. In Quarkus, this works best with profiles or conditional annotations.
@Alternative
@Priority(1)
@ApplicationScoped
public class MockCoffeeMachine implements CoffeeMachine {
public String brew() {
return "Mocking the coffee machine for tests.";
}
}
Or, better yet, use the Quarkus-specific @IfBuildProfile
:
@IfBuildProfile("test")
@ApplicationScoped
public class TestCoffeeMachine implements CoffeeMachine {
public String brew() {
return "Test coffee only.";
}
}
Start your application with:
quarkus.profile=test
Now only the TestCoffeeMachine
is active.
Interceptors: Behavior Without Inheritance
Need to log, monitor, or secure a method? Use interceptors to apply logic without touching business code.
First, define a binding:
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({METHOD, TYPE})
public @interface Logged {}
Then create the interceptor:
@Logged
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class LoggingInterceptor {
@AroundInvoke
public Object log(InvocationContext ctx) throws Exception {
System.out.println(">> Calling: " + ctx.getMethod().getName());
return ctx.proceed();
}
}
Apply it to your bean:
@ApplicationScoped
@Logged
public class CoffeeMachine {
public String brew() {
return "Interceptor-enhanced brew.";
}
}
Interceptors keep your logic clean and encapsulated.
Observing Application Lifecycle Events
CDI lets you react to lifecycle events like startup and shutdown using @Observes
.
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.context.Initialized;
import jakarta.enterprise.context.Destroyed;
public class AppLifecycle {
void onStart(@Observes @Initialized(ApplicationScoped.class) Object init) {
System.out.println("Application started.");
}
void onStop(@Observes @Destroyed(ApplicationScoped.class) Object shutdown) {
System.out.println("Application stopping.");
}
}
You can also observe custom events:
public record OrderPlaced(String orderId) {}
@Inject
Event<OrderPlaced> orderEvent;
// Somewhere in your code:
orderEvent.fire(new OrderPlaced("12345"));
And observe it elsewhere:
public void onOrder(@Observes OrderPlaced event) {
System.out.println("New order: " + event.orderId());
}
Wrapping Up
Quarkus takes the powerful, standard-based model of Jakarta CDI and supercharges it with build-time processing. You get all the benefits of dependency injection—clean code, loose coupling, better testability—without paying the performance cost at runtime.
We covered:
Basic injection and scopes
Qualifiers for multi-bean injection
Constructor, field, and method injection
Producer methods and conditional beans
Interceptors and lifecycle events
You can build cloud-native applications that start in milliseconds and are fully compatible with Jakarta standards. Whether you are migrating from Java EE or just want a faster, simpler DI framework than Spring, Quarkus delivers.
Further Learning
If you're ready to explore more about dependency injection in Quarkus and Jakarta EE, here are some high-quality resources:
Quarkus - Introduction to CDI
A beginner-friendly guide explaining how CDI works in Quarkus, with practical examples.Quarkus - CDI Reference Guide
A deeper dive into the Quarkus CDI implementation, build-time behavior, limitations, and advanced features.Jakarta Contexts and Dependency Injection Specification 4.1
The official CDI specification detailing scopes, qualifiers, injection behavior, lifecycle events, and more.Jakarta CDI Tutorial (Eclipse Projects)
Helpful for understanding CDI in a vendor-neutral way, with tutorials and guides.Quarkus YouTube Channel
Includes quick tips, conference sessions, and deep dives into Quarkus core features, including CDI.