Spring BootQualifier Annotation
Spring Boot

Qualifier Annotation

The @Qualifier annotation resolves ambiguity when Spring finds multiple beans of the same type. When you have two implementations of an interface, @Qualifier tells Spring exactly which bean to inject. Combined with @Primary for default selection, @Qualifier gives precise control over which bean goes where.

The Problem @Qualifier Solves

When Spring finds exactly one bean of the required type, injection works automatically. When Spring finds multiple beans of the same type, it throws NoUniqueBeanDefinitionException — it cannot decide which to inject. @Qualifier resolves this ambiguity by specifying the exact bean name to inject. This situation is common in real applications: multiple DataSource beans for read/write splitting, multiple MessageConverter implementations, multiple PaymentService implementations for different providers, multiple cache managers for different caching strategies. Without a disambiguation mechanism, you cannot inject specific implementations — only the default one.

Basic @Qualifier Usage

@Qualifier specifies the bean name to inject when multiple beans of the same type exist. The qualifier value matches the bean name — which defaults to the class name in camelCase when using component scanning.
Java
// Two implementations of the same interface:
@Component
public class StripePaymentService implements PaymentService {
    public PaymentResult charge(String customerId, BigDecimal amount) {
        System.out.println("Charging via Stripe");
        return new PaymentResult("stripe-txn-" + UUID.randomUUID());
    }
}

@Component
public class PaypalPaymentService implements PaymentService {
    public PaymentResult charge(String customerId, BigDecimal amount) {
        System.out.println("Charging via PayPal");
        return new PaymentResult("paypal-txn-" + UUID.randomUUID());
    }
}

// Without @Qualifier — Spring throws NoUniqueBeanDefinitionException:
@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;  // FAILS — two matching beans
}

// With @Qualifier — specify exactly which bean:
@Service
public class OrderService {

    private final PaymentService paymentService;

    // Constructor injection with @Qualifier:
    public OrderService(@Qualifier("stripePaymentService") PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

// Default bean names from component scanning:
// StripePaymentService → "stripePaymentService" (class name, first letter lowercase)
// PaypalPaymentService → "paypalPaymentService"

// Explicitly named beans:
@Component("stripe")
public class StripePaymentService implements PaymentService { }

@Component("paypal")
public class PaypalPaymentService implements PaymentService { }

// Inject by explicit name:
public OrderService(@Qualifier("stripe") PaymentService paymentService) {
    this.paymentService = paymentService;
}

// @Qualifier in @Bean methods:
@Configuration
public class PaymentConfig {

    @Bean("stripeGateway")
    public PaymentService stripePaymentService() {
        return new StripePaymentService(stripeApiKey);
    }

    @Bean("paypalGateway")
    public PaymentService paypalPaymentService() {
        return new PaypalPaymentService(paypalClientId);
    }
}

// Inject specific @Bean:
public OrderService(@Qualifier("stripeGateway") PaymentService paymentService) {
    this.paymentService = paymentService;
}

@Primary — The Default Bean

@Primary marks one bean as the default candidate when multiple beans of the same type exist. When @Qualifier is not specified, Spring injects the @Primary bean. This is cleaner than adding @Qualifier everywhere when one implementation is used in the vast majority of cases.
Java
// @Primarydefault implementation used unless @Qualifier specifies otherwise:
@Component
@Primary   // default PaymentService — used when no @Qualifier is specified
public class StripePaymentService implements PaymentService {
    public PaymentResult charge(String customerId, BigDecimal amount) {
        return stripeClient.charge(customerId, amount);
    }
}

@Component
public class PaypalPaymentService implements PaymentService {
    public PaymentResult charge(String customerId, BigDecimal amount) {
        return paypalClient.charge(customerId, amount);
    }
}

// Injection without @Qualifier — gets the @Primary bean (Stripe):
@Service
public class StandardOrderService {
    private final PaymentService paymentService;  // gets StripePaymentService

    public StandardOrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

// Injection with @Qualifier — gets the specific bean regardless of @Primary:
@Service
public class PaypalOrderService {
    private final PaymentService paymentService;  // gets PaypalPaymentService

    public PaypalOrderService(@Qualifier("paypalPaymentService") PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

// @Primary in @Bean methods — same behavior:
@Configuration
public class DataSourceConfig {

    @Bean
    @Primary   // default DataSource — used for most operations
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://primary-db:3306/mydb")
            .build();
    }

    @Bean("readOnlyDataSource")
    public DataSource readOnlyDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://replica-db:3306/mydb")
            .build();
    }
}

// Read/write split — most services get the primary DataSource:
@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;  // uses primaryDataSource automatically

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

// Read-heavy service gets the replica explicitly:
@Service
public class ReportService {
    private final JdbcTemplate readJdbc;

    public ReportService(@Qualifier("readOnlyDataSource") DataSource dataSource) {
        this.readJdbc = new JdbcTemplate(dataSource);
    }
}

Custom Qualifier Annotations

Custom qualifier annotations are cleaner than string-based @Qualifier. Instead of @Qualifier("stripePaymentService") — a string that can be misspelled — you create a dedicated annotation that is type-safe and self-documenting.
Java
// Define custom qualifier annotations:
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier   // meta-annotated with @Qualifier — makes this a qualifier
public @interface StripePayment { }

@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface PaypalPayment { }

// Apply to bean definitions:
@Component
@StripePayment   // custom qualifier on the bean
public class StripePaymentService implements PaymentService {
    public PaymentResult charge(String customerId, BigDecimal amount) {
        return stripeClient.charge(customerId, amount);
    }
}

@Component
@PaypalPayment   // custom qualifier on the bean
public class PaypalPaymentService implements PaymentService {
    public PaymentResult charge(String customerId, BigDecimal amount) {
        return paypalClient.charge(customerId, amount);
    }
}

// Inject with custom qualifier:
@Service
public class CheckoutService {

    private final PaymentService stripeService;
    private final PaymentService paypalService;

    public CheckoutService(
            @StripePayment PaymentService stripeService,      // type-safe — no strings
            @PaypalPayment PaymentService paypalService) {    // IDE completes this
        this.stripeService = stripeService;
        this.paypalService = paypalService;
    }

    public PaymentResult processPayment(Order order, PaymentMethod method) {
        return switch (method) {
            case STRIPE -> stripeService.charge(order.getCustomerId(), order.getTotal());
            case PAYPAL -> paypalService.charge(order.getCustomerId(), order.getTotal());
        };
    }
}

// Benefits of custom qualifiers over string-based @Qualifier:
// 1. TYPE SAFE — @StripePayment cannot be misspelled the way "stripePaymentService" can
// 2. REFACTORING SAFE — rename the annotation and the IDE updates all usages
// 3. SELF-DOCUMENTING — @StripePayment communicates intent better than a string
// 4. SEARCHABLE — find all usages of @StripePayment with IDE navigation
// 5. COMPOSABLE — can carry additional metadata as annotation attributes

// Custom qualifier with attributes — for parameterized selection:
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface PaymentProvider {
    String value();   // "stripe", "paypal", "braintree"
}

@Component
@PaymentProvider("stripe")
public class StripeService implements PaymentService { }

@Component
@PaymentProvider("paypal")
public class PaypalService implements PaymentService { }

// Inject:
public OrderService(@PaymentProvider("stripe") PaymentService service) { }

@Qualifier with Collections

When injecting a collection of all beans of a type, @Qualifier filters which beans are included. This enables targeted collection injection without getting every implementation.
Java
// Inject ALL beans of a type — unfiltered:
@Service
public class PaymentRouter {

    private final List<PaymentService> allPaymentServices;

    public PaymentRouter(List<PaymentService> allPaymentServices) {
        this.allPaymentServices = allPaymentServices;
        // Contains: StripePaymentService, PaypalPaymentService, BraintreePaymentService
    }

    public PaymentResult route(Order order, String providerName) {
        return allPaymentServices.stream()
            .filter(s -> s.getProviderName().equals(providerName))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentProviderException(providerName))
            .charge(order.getCustomerId(), order.getTotal());
    }
}

// Inject as a Map — bean name is the key:
@Service
public class PaymentDispatcher {

    private final Map<String, PaymentService> serviceMap;

    public PaymentDispatcher(Map<String, PaymentService> serviceMap) {
        this.serviceMap = serviceMap;
        // {"stripePaymentService": ..., "paypalPaymentService": ..., "braintreePaymentService": ...}
    }

    public PaymentResult dispatch(String beanName, Order order) {
        PaymentService service = serviceMap.get(beanName);
        if (service == null) throw new IllegalArgumentException("Unknown service: " + beanName);
        return service.charge(order.getCustomerId(), order.getTotal());
    }
}

// Use @Order to control the order in a List:
@Component
@Order(1)   // first in the list
public class StripePaymentService implements PaymentService { }

@Component
@Order(2)   // second in the list
public class PaypalPaymentService implements PaymentService { }

@Component
@Order(3)   // third in the list
public class BraintreePaymentService implements PaymentService { }

// Filter collection with custom qualifier:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface EuropeanProvider { }

@Component
@EuropeanProvider
@Order(1)
public class StripeEUService implements PaymentService { }

@Component
@EuropeanProvider
@Order(2)
public class AdyenService implements PaymentService { }

@Component   // US-only — not @EuropeanProvider
public class StripeUSService implements PaymentService { }

// Inject only European providers:
@Service
public class EuropeanCheckoutService {

    private final List<PaymentService> europeanServices;

    public EuropeanCheckoutService(
            @EuropeanProvider List<PaymentService> europeanServices) {
        this.europeanServices = europeanServices;
        // Contains only: StripeEUService, AdyenService
        // StripeUSService is excluded
    }
}

@Qualifier in Common Real-World Scenarios

These are the scenarios where @Qualifier appears most frequently in production Spring Boot applications — datasource routing, cache managers, executor services, and message converters.
Java
// ── SCENARIO 1: Read/Write DataSource Routing ─────────────────────
@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource writeDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://primary:3306/mydb")
            .build();
    }

    @Bean("readDataSource")
    public DataSource readDataSource() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://replica:3306/mydb")
            .build();
    }
}

@Repository
public class UserWriteRepository {
    private final JdbcTemplate jdbc;  // gets writeDataSource (primary)

    public UserWriteRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    public User save(User user) { return null; }
}

@Repository
public class UserReadRepository {
    private final JdbcTemplate jdbc;  // gets readDataSource explicitly

    public UserReadRepository(@Qualifier("readDataSource") DataSource ds) {
        this.jdbc = new JdbcTemplate(ds);
    }
    public List<User> findAll() { return null; }
}

// ── SCENARIO 2: Multiple Cache Managers ───────────────────────────
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    @Primary
    public CacheManager redisCacheManager(RedisConnectionFactory factory) {
        return RedisCacheManager.builder(factory).build();
    }

    @Bean("localCacheManager")
    public CacheManager caffeineCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder().maximumSize(1000));
        return manager;
    }
}

@Service
public class ProductService {

    // Most caching uses Redis (primary):
    @Cacheable(value = "products", cacheManager = "redisCacheManager")
    public Product findById(Long id) { return null; }

    // Hot data cached locally for ultra-fast access:
    @Cacheable(value = "hotProducts", cacheManager = "localCacheManager")
    public List<Product> findTopSellers() { return null; }
}

// ── SCENARIO 3: Multiple TaskExecutors ───────────────────────────
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("ioExecutor")
    public Executor ioTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);    // many threads for IO-bound work
        executor.setMaxPoolSize(50);
        executor.setThreadNamePrefix("io-");
        executor.initialize();
        return executor;
    }

    @Bean("cpuExecutor")
    public Executor cpuTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
        executor.setThreadNamePrefix("cpu-");
        executor.initialize();
        return executor;
    }
}

@Service
public class ProcessingService {

    // IO-bound — use the IO executor:
    @Async("ioExecutor")
    public CompletableFuture<String> fetchFromApi(String url) {
        return CompletableFuture.completedFuture(httpClient.get(url));
    }

    // CPU-bound — use the CPU executor:
    @Async("cpuExecutor")
    public CompletableFuture<byte[]> generateReport(ReportRequest request) {
        return CompletableFuture.completedFuture(reportEngine.generate(request));
    }
}

@Qualifier vs @Primary vs Bean Name Matching

Spring has three mechanisms for resolving ambiguity when multiple beans match. Understanding their precedence and appropriate use prevents over-engineering and inconsistent patterns.
Java
// THREE DISAMBIGUATION MECHANISMS — precedence order:

// MECHANISM 1 — Field/parameter name matching (lowest precedence, implicit):
@Service
public class OrderService {
    private final PaymentService stripePaymentService;  // name matches bean name!

    public OrderService(PaymentService stripePaymentService) {
        this.stripePaymentService = stripePaymentService;
        // Spring matches "stripePaymentService" parameter name to the bean name
        // Works — but fragile! Renaming the parameter breaks injection silently
    }
}
// AVOID: implicit name matching is not obvious and breaks on renaming

// MECHANISM 2@Primary (medium precedence, explicit default):
@Component
@Primary
public class StripePaymentService implements PaymentService { }
// Default bean — used when no qualifier is specified
// USE WHEN: one implementation is the standard choice in 80%+ of cases

// MECHANISM 3@Qualifier (highest precedence, explicit override):
@Service
public class SpecialOrderService {
    private final PaymentService paymentService;

    public SpecialOrderService(@Qualifier("paypalPaymentService") PaymentService service) {
        this.paymentService = service;
    }
}
// Explicit override — ignores @Primary, uses exactly the specified bean
// USE WHEN: a specific injection point needs a non-default bean

// DECISION GUIDE:
// One implementation → no disambiguation needed
// Multiple, one is the default@Primary on the default, @Qualifier for others
// Multiple, no clear default@Qualifier everywhere (or custom qualifiers)
// Type-safe qualification → custom @Qualifier annotations

// ANTI-PATTERN — string-based @Qualifier everywhere:
// @Qualifier("stripePaymentService") — brittle string, easy to misspell
// Better: @StripePayment (custom annotation) — type-safe, refactoring-safe

// ANTI-PATTERN — @Primary and @Qualifier everywhere:
// If every injection point has @Qualifier, @Primary is irrelevant
// If no injection point has @Qualifier, only one @Primary is needed
// Design your disambiguation strategy consistently across the codebase