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.
@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
@Primaryon one bean makes it the default whenever the type alone is ambiguous.@Fallbackis 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@Qualifieron 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 oneChoosing 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
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 {
public String charge(String orderId, BigDecimal amount) {
return "card-" + orderId;
}
}
class BankTransferGateway implements PaymentGateway {
public String charge(String orderId, BigDecimal amount) {
return "bank-" + orderId;
}
}
(proxyBeanMethods = false)
class PaymentConfig {
PaymentGateway cardGateway() {
return new CardGateway();
}
PaymentGateway bankTransferGateway() {
return new BankTransferGateway();
}
}
public class InvoiceSettlement {
private final PaymentGateway defaultGateway;
private final PaymentGateway bankGateway;
InvoiceSettlement(PaymentGateway defaultGateway,
("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.
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.
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.
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