Qualifiers and @Primary

Resolve which bean Spring injects when several share a type, with @Primary for the default, @Qualifier for a specific one and collections for all.

Syntax@Primary @Bean T main() { ... } Service(@Qualifier("beanName") T dep) Service(Map<String, T> all) | Service(List<T> all)

Spring injects by type. When exactly one bean matches, there is nothing to decide. When two or more match, startup fails with NoUniqueBeanDefinitionException and a message listing the candidates. That is common once you have two payment providers, two data sources or two HTTP clients.

Your options

  • @Primary on one bean makes it the default whenever the type alone is ambiguous.
  • @Fallback is the inverse: the marked bean is used only when no other candidate remains, which suits a default implementation that should step aside when anything else is present.
  • @Qualifier("name") on an injection point picks a bean by name (or by a matching @Qualifier on the bean). It always wins over @Primary.
  • Parameter name. Spring also picks a bean whose name matches the parameter name. It works, but renaming a variable then changes which bean you get, so prefer an explicit qualifier.
  • All of them. List<T> receives every bean of the type, ordered by @Order; Map<String, T> receives them keyed by bean name.
@Bean @Primary
PaymentGateway stripeGateway() { ... }

CheckoutService(PaymentGateway gateway)                                  // stripeGateway
RefundService(@Qualifier("bankTransferGateway") PaymentGateway gateway)  // the other one

Choosing between them

Use @Primary when one implementation is right for nearly every caller and the others are special cases. Use @Qualifier at the few places that need the special case. Use a Map when the choice is made at runtime, for example from a provider field in the request. That replaces a switch statement with a lookup and lets you add a provider by adding a bean.

Mistakes and consequences

Two @Primary beans of the same type are as ambiguous as none, and startup fails again. Take care when you add a second bean of a type that Spring Boot's auto-configuration consumes. With two DataSource beans and neither marked @Primary, the JPA auto-configuration, which needs a single candidate, backs off, and the failure surfaces later as a missing EntityManagerFactory. Mark the main one @Primary, or declare the extra one with @Bean(defaultCandidate = false) so it is injected only where it is asked for by qualifier. String qualifiers are typo-prone and are not checked until startup; a custom qualifier annotation such as @Fast is checked by the compiler and reads better.

Example

Example · java

import java.math.BigDecimal;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;

interface PaymentGateway {
    String charge(String orderId, BigDecimal amount);
}

class CardGateway implements PaymentGateway {
    @Override
    public String charge(String orderId, BigDecimal amount) {
        return "card-" + orderId;
    }
}

class BankTransferGateway implements PaymentGateway {
    @Override
    public String charge(String orderId, BigDecimal amount) {
        return "bank-" + orderId;
    }
}

@Configuration(proxyBeanMethods = false)
class PaymentConfig {
    @Bean
    @Primary
    PaymentGateway cardGateway() {
        return new CardGateway();
    }

    @Bean
    PaymentGateway bankTransferGateway() {
        return new BankTransferGateway();
    }
}

@Service
public class InvoiceSettlement {
    private final PaymentGateway defaultGateway;
    private final PaymentGateway bankGateway;

    InvoiceSettlement(PaymentGateway defaultGateway,
                      @Qualifier("bankTransferGateway") PaymentGateway bankGateway) {
        this.defaultGateway = defaultGateway; // cardGateway, because it is @Primary
        this.bankGateway = bankGateway;
    }

    String settle(String invoiceId, BigDecimal amount) {
        PaymentGateway gateway = amount.compareTo(new BigDecimal("10000")) >= 0 ? bankGateway : defaultGateway;
        return gateway.charge(invoiceId, amount);
    }
}

When to use it

  • Use card payments by default but route large invoices through a bank-transfer gateway.
  • Choose email, SMS or push from a channel field in the request without a switch statement.
  • Give one integration a short-timeout HTTP client while everything else uses the default.

More examples

Picking an implementation at runtime with a Map

Every NotificationSender bean arrives in the map, keyed by bean name, so the explicit names in @Component become part of the API. Adding a push channel is one new class and no change to Notifier. Validate the key and fail with a clear message, since it usually comes from a request.

Example · java
import java.util.Map;

import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface NotificationSender {
    void send(String to, String text);
}

@Component("email")
class EmailSender implements NotificationSender {
    @Override
    public void send(String to, String text) {
        System.out.println("email to " + to + ": " + text);
    }
}

@Component("sms")
class SmsSender implements NotificationSender {
    @Override
    public void send(String to, String text) {
        System.out.println("sms to " + to + ": " + text);
    }
}

@Service
public class Notifier {
    private final Map<String, NotificationSender> senders;

    public Notifier(Map<String, NotificationSender> senders) {
        this.senders = Map.copyOf(senders); // keys are the bean names: "email", "sms"
    }

    public void notify(String channel, String to, String text) {
        NotificationSender sender = senders.get(channel);
        if (sender == null) {
            throw new IllegalArgumentException("Unknown channel " + channel + ", expected one of " + senders.keySet());
        }
        sender.send(to, text);
    }
}

A custom qualifier annotation

@Fast is meta-annotated with @Qualifier, so it matches the bean method carrying the same annotation. A misspelt annotation fails to compile, whereas a misspelt string qualifier fails at startup. It also says why this client was chosen, which a bean name rarely does.

Example · java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.net.http.HttpClient;
import java.time.Duration;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;

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

@Configuration(proxyBeanMethods = false)
class ClientConfig {
    @Bean
    @Fast
    HttpClient fastHttpClient() {
        return HttpClient.newBuilder().connectTimeout(Duration.ofMillis(300)).build();
    }

    @Bean
    HttpClient defaultHttpClient() {
        return HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
    }
}

@Service
public class StockLookup {
    private final HttpClient client;

    public StockLookup(@Fast HttpClient client) {
        this.client = client;
    }

    public HttpClient client() {
        return client;
    }
}

A default that steps aside with @Fallback

@Primary says "choose me"; @Fallback says "choose anything but me". With both implementations present, the non-fallback one is injected. Remove HeaderGeoLookup, for example with a profile or a condition, and the fallback takes over without any change to PricingRegion.

Example · java
import org.springframework.context.annotation.Fallback;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface GeoLookup {
    String countryFor(String ipAddress);
}

// Used only when no other GeoLookup bean exists.
@Component
@Fallback
class UnknownGeoLookup implements GeoLookup {
    @Override
    public String countryFor(String ipAddress) {
        return "ZZ";
    }
}

@Component
class HeaderGeoLookup implements GeoLookup {
    @Override
    public String countryFor(String ipAddress) {
        return ipAddress.startsWith("81.") ? "GB" : "ZZ";
    }
}

@Service
public class PricingRegion {
    private final GeoLookup geo;

    public PricingRegion(GeoLookup geo) {
        this.geo = geo; // HeaderGeoLookup: the fallback is skipped while it exists
    }

    public String regionFor(String ipAddress) {
        return "GB".equals(geo.countryFor(ipAddress)) ? "uk" : "rest-of-world";
    }
}

Discussion

  • Be the first to comment on this lesson.