Choosing Your Fighter: Mastering @Qualifier in Quarkus CDI
How Java developers can tame multiple implementations with type-safe bean injection.
In real-world applications, it’s rare to have just one way of doing something. You might need to send a notification by email today, SMS tomorrow, and maybe push notifications next quarter. Sometimes you want the real service, sometimes a mock for testing. The interface stays the same, but suddenly you have multiple implementations competing for attention.
If you inject the interface directly, CDI has no idea which one to choose, and that’s where things get messy. You don’t want runtime guesswork or fragile string-based lookups that break during refactoring. What you need is a clean, type-safe mechanism to say: this injection point gets email, that one gets SMS.
That’s exactly what qualifiers give you. They’re CDI’s way of putting you in control of bean selection without sacrificing clarity or safety. In this tutorial, you’ll build a simple notification system with two implementations, email and SMS, and see how qualifiers let you choose your fighter at injection time. Along the way, you’ll pick up patterns that scale far beyond messaging, into areas like cloud integrations, test doubles, and modular service architectures.
Prerequisites
Java 17 or 21
Maven 3.8+
Quarkus CLI or Maven archetype
cURL for verification
Bootstrap the project
Using the Quarkus CLI (recommended):
quarkus create app com.example:qualifiers-lab \
-x=rest-jackson,smallrye-openapi \
--java=21
cd qualifiers-lab
Even if there isn’t much code really, I did push this to my Github repository so you can start from there if you like.
Why these extensions:
rest-jackson
for a simple JSON REST endpoint.smallrye-openapi
so you get/q/openapi
for free (optional but handy).
The domain: a simple notification port
Create the interface:
src/main/java/com/example/notify/NotificationService.java
package com.example.notify;
public interface NotificationService {
String send(String message);
}
Keep the contract tiny on purpose. The focus is bean selection, not transport plumbing.
Define custom qualifiers
Two custom qualifiers: one for Email, one for SMS. They are just annotations marked with @Qualifier
.
src/main/java/com/example/notify/EmailQualifier.java
package com.example.notify;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import jakarta.inject.Qualifier;
@Qualifier
@Retention(RUNTIME)
@Target({ TYPE, METHOD, FIELD, PARAMETER })
public @interface EmailQualifier {
}
src/main/java/com/example/notify/SmsQualifier.java
package com.example.notify;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import jakarta.inject.Qualifier;
@Qualifier
@Retention(RUNTIME)
@Target({ TYPE, METHOD, FIELD, PARAMETER })
public @interface SmsQualifier {
}
Why two qualifiers? Because we want compile-time disambiguation at the injection site without relying on bean names or profiles.
Implement the services and qualify them
src/main/java/com/example/notify/EmailNotificationService.java
package com.example.notify;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@EmailQualifier
public class EmailNotificationService implements NotificationService {
@Override
public String send(String message) {
// In real life: hand off to your mailer
return "EMAIL sent: " + message;
}
}
src/main/java/com/example/notify/SmsNotificationService.java
package com.example.notify;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@SmsQualifier
public class SmsNotificationService implements NotificationService {
@Override
public String send(String message) {
// In real life: call your SMS gateway
return "SMS sent: " + message;
}
}
Key point: the same interface, two beans, each tagged with a different qualifier.
Inject exactly what you mean
Create a small JAX-RS resource that injects both variants and exposes two endpoints.
DTO:
src/main/java/com/example/api/MessageRequest.java
package com.example.api;
public class MessageRequest {
public String message;
}
Resource:
src/main/java/com/example/api/NotificationResource.java
package com.example.api;
import com.example.notify.EmailQualifier;
import com.example.notify.NotificationService;
import com.example.notify.SmsQualifier;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/notify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.TEXT_PLAIN)
public class NotificationResource {
@Inject
@EmailQualifier
NotificationService emailService;
@Inject
@SmsQualifier
NotificationService smsService;
@POST
@Path("/email")
public String email(MessageRequest req) {
return emailService.send(req.message);
}
@POST
@Path("/sms")
public String sms(MessageRequest req) {
return smsService.send(req.message);
}
}
At each injection point, the qualifier tells CDI which implementation to wire. No ambiguity. No runtime switches.
Run and verify
Dev mode:
quarkus dev
Send requests:
# Email
curl -s -X POST http://localhost:8080/notify/email \
-H 'Content-Type: application/json' \
-d '{"message":"Hello from qualifiers"}'
# -> EMAIL sent: Hello from qualifiers
# SMS
curl -s -X POST http://localhost:8080/notify/sms \
-H 'Content-Type: application/json' \
-d '{"message":"Hello from qualifiers"}'
# -> SMS sent: Hello from qualifiers
Optional: Use the Swagger UI at http://localhost:8080/q/swagger-ui
.
Testing
A small integration test proves wiring and behavior.
src/test/java/com/example/api/NotificationResourceTest.java
package com.example.api;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class NotificationResourceTest {
@Test
void email_should_use_email_service() {
given()
.contentType("application/json")
.body("{\"message\":\"X\"}")
.when()
.post("/notify/email")
.then()
.statusCode(200)
.body(is("EMAIL sent: X"));
}
@Test
void sms_should_use_sms_service() {
given()
.contentType("application/json")
.body("{\"message\":\"Y\"}")
.when()
.post("/notify/sms")
.then()
.statusCode(200)
.body(is("SMS sent: Y"));
}
}
Run tests:
quarkus test
Production notes, pitfalls, and variations
Ambiguous injection
When you inject an interface that has multiple available implementations, CDI cannot decide which one to use. If you try to inject NotificationService
directly without a qualifier in our example, the build will fail because both the email and SMS beans match. This might feel restrictive at first, but it is a feature, not a limitation. By failing early, Quarkus ensures that you do not end up with unintended wiring at runtime. It forces you to be explicit about which implementation you actually want, and that saves you from subtle bugs later on when more beans are added to the system.
@Default
and @Any
Every CDI bean implicitly carries the @Any
qualifier, which allows you to discover and inject collections of beans when needed. If you want one particular bean to be injected in situations where no qualifier is used, you can annotate it with @Default
. This is useful when there is a “natural” implementation that most code should rely on, and the other beans are special cases. However, relying too much on defaults can make your wiring less explicit. It becomes easier to accidentally inject the wrong implementation simply because it happened to be marked as the default. For long-term maintainability, it is usually better to stay explicit about qualifiers even when a default exists.
Qualifier members
Qualifiers are not limited to just being markers. They can also carry values, which gives you a more fine-grained way of selecting beans. A common example is something like @Channel("alerts")
in reactive messaging, where the qualifier holds a channel name. These values must be compile-time constants to remain compatible with CDI Lite and Quarkus’s build-time optimizations. This restriction ensures that dependency resolution happens early and avoids reflective lookups at runtime. Using members in qualifiers is powerful but should be used with care to avoid overcomplicating your injection points.
Producers
Another important feature in CDI is the ability to define producer methods or fields, which create and expose beans programmatically. Producers can also be qualified, which allows you to generate multiple variations of the same type with different configurations. For example, you might expose a qualified NotificationService
that is backed by a test double for use in your test environment, or a qualified EntityManager
with different persistence units. By combining producers with qualifiers, you can avoid heavy factory logic scattered throughout your code and let CDI take care of wiring different flavors of beans.
Alternatives
Sometimes you need to swap implementations at the deployment level, without changing every injection point. That is where @Alternative
comes in. You can mark a bean as an alternative and then activate it through configuration or your build profile. This is useful for replacing production code with mock implementations in tests, or swapping out a cloud provider integration depending on the environment. The key distinction is that qualifiers are about call-site decisions—where you want to be explicit at each injection point—while alternatives are about deployment decisions—where you want to control the wiring centrally. Both serve different purposes and are complementary.
Avoid stringly-typed names
It is possible to disambiguate beans with @Named("email")
and then inject by name. While this works, it comes with a major drawback: it is not refactor-safe. Renaming the bean string in one place but forgetting to update it in another will only fail at runtime. Custom qualifiers avoid this problem because they are types, not strings, and the compiler enforces consistency across your codebase. For this reason, qualifiers should be the preferred approach in production systems where maintainability and safety matter.
Container build with Podman (optional)
Quarkus can build container images easily. Add the podman container extension to the project:
quarkus extension add container-image-podman
Quarkus’ “docker” builder talks to Podman if your environment routes
docker
to Podman (common on Fedora/RHEL). The Docker extension is and has always been backwards-compatible with Podman because Podman exposes a Docker-compatible API. You can build container images with Podman using the Docker extension (see the Using Podman with Quarkus guide).
Build the image:
./mvnw clean package -Dquarkus.container-image.build=true
Run the image:
podman run --rm -p 8080:8080 localhost/<username>/qualifiers-lab:1.0.0-SNAPSHOT
What-ifs
Runtime selection
Sometimes you need to make the choice of implementation at runtime rather than at compile time. For example, a user might select their preferred notification channel in a request header, or different tenants in a SaaS application might have different requirements. In these cases, you can inject multiple beans using Instance<NotificationService>
with qualifiers and then programmatically select the right one based on context. Another option is to inject both concrete beans directly and branch in your code. While this works, it should be kept to a minimum. The real strength of qualifiers is in compile-time wiring, which ensures clarity and safety. Runtime branching should only be used where it is unavoidable.
Extending to more channels
Adding new communication channels is straightforward with qualifiers. If you need to support push notifications, chat applications like Microsoft Teams, or any other channel, you simply define a new qualifier—such as @PushQualifier
or @TeamsQualifier
—and tag the corresponding bean implementation. This approach scales much better than using a central enum with switch
statements because it keeps modules independent and decoupled. Each new channel is self-contained, and CDI resolves the wiring without requiring modifications to existing code. The result is a system that evolves more cleanly as new requirements emerge.
Testing different beans
Testing becomes simpler with qualifiers because you can target each implementation explicitly. You can write integration tests that verify the behavior of each endpoint and the underlying bean it uses, ensuring correctness in isolation. For more advanced testing scenarios, CDI allows you to override beans within the test scope. You can mark mock implementations with @Alternative
and activate them during tests. This makes it possible to simulate external systems, such as an email provider or SMS gateway, without depending on real services. By leveraging qualifiers and alternatives together, you can create a clean testing strategy that validates both production wiring and isolated component behavior.
Key takeaway
Qualifiers are the type-safe way to select a specific bean implementation at the injection point. They give you compile-time safety, make your wiring explicit, and prevent subtle runtime errors when multiple beans exist. This pattern scales well in real applications, where you often deal with multiple integrations, vendor implementations, or test doubles. By leaning on qualifiers, you reduce ambiguity and keep your dependency injection clean and predictable.
If you want to deepen your understanding, the next step is to explore how qualifiers interact with more advanced CDI concepts. Producer methods and fields let you qualify beans that are created programmatically, which is powerful when you need to inject different configurations of the same type. Alternatives are worth learning for environment-driven replacement of beans, such as swapping production connectors for mock services in tests. You may also want to look into interceptor bindings, which build on the same annotation model as qualifiers to add cross-cutting concerns like logging, security, or metrics.
For Quarkus-specific learning, the following resources are a natural next stop:
The Quarkus Dependency Injection guide explains how Quarkus implements CDI Lite and which features are supported.
The Contexts and Dependency Injection specification is the authoritative source on qualifiers, alternatives, producers, and scopes.
The Quarkus Testing guide shows how to combine CDI features with
@QuarkusTest
and@Alternative
for robust test setups.
Qualifiers are just the start. Once you’re comfortable choosing “your fighter,” you can move on to dynamic injection, modular service architectures, and cross-cutting patterns that build on the same CDI foundation. Mastering these tools will make your Quarkus applications not only cleaner, but also more adaptable in enterprise environments.