Spring @Qualifier, @Primary and Conditional Beans

Introduction

This tutorial explains how to tell Spring which bean to inject when several beans have the same type, and how to create a bean only under certain conditions. We will start with the problem: two implementations of one interface, and the startup failure that follows. We will then solve it in several ways - @Primary, @Qualifier, custom qualifier annotations, parameter names, and @Fallback - and see which wins when they are combined. Next we will turn to conditional beans: beans that exist only in certain profiles with @Profile, beans controlled by your own Condition class, and lastly Spring Boot's @ConditionalOnProperty and its relatives, which power Boot's auto-configuration.

(This tutorial builds on Spring dependency injectionClick to read tutorial: Spring Dependency Injection: Constructor vs Setter vs Field and @Component, @Bean and @ConfigurationClick to read tutorial: Spring Beans: @Component, @Bean and @Configuration.)

The problem: more than one candidate

Spring injects by type. As long as exactly one bean matches the type a constructor asks for, there is nothing to decide. The moment two beans implement the same interface, Spring cannot choose, and the context fails to start -
Two PaymentGateway beans make injection ambiguous
//AmbiguousApp.java
package com.javabrahman.spring.choosing.problem;

import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface PaymentGateway {
  String charge(double amount);
}

@Component
class StripeGateway implements PaymentGateway {
  public String charge(double amount) {
    return "Stripe charged " + amount;
  }
}

@Component
class PayPalGateway implements PaymentGateway {
  public String charge(double amount) {
    return "PayPal charged " + amount;
  }
}

@Service
class CheckoutService {
  CheckoutService(PaymentGateway gateway) {
  }
}

@Configuration
@ComponentScan
class AppConfig {
}

public class AmbiguousApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println("Started with " + context.getBeanDefinitionCount() + " beans");
    } catch (Exception e) {
      Throwable cause = e;
      while (!(cause instanceof NoUniqueBeanDefinitionException) && cause.getCause() != null) {
        cause = cause.getCause();
      }
      System.out.println(cause.getMessage());
    }
  }
}
 OUTPUT of the above code
No qualifying bean of type 'com.javabrahman.spring.choosing.problem.PaymentGateway' available: expected single matching bean but found 2: payPalGateway,stripeGateway
The message lists the two candidates. The rest of this tutorial shows the ways to resolve the choice.

@Primary: a default choice

@Primary marks one bean as the default for its type. Whenever an injection point is ambiguous, Spring picks the primary bean. It suits the common case of one main implementation and one or two special ones -
@Primary makes one PaymentGateway the default
//PrimaryApp.java
package com.javabrahman.spring.choosing.primary;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface PaymentGateway {
  String charge(double amount);
}

@Component
@Primary
class StripeGateway implements PaymentGateway {
  public String charge(double amount) {
    return "Stripe charged " + amount;
  }
}

@Component
class PayPalGateway implements PaymentGateway {
  public String charge(double amount) {
    return "PayPal charged " + amount;
  }
}

@Service
class CheckoutService {
  private final PaymentGateway gateway;

  CheckoutService(PaymentGateway gateway) {
    this.gateway = gateway;
  }

  String pay(double amount) {
    return gateway.charge(amount);
  }
}

@Configuration
@ComponentScan
class AppConfig {
}

public class PrimaryApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println(context.getBean(CheckoutService.class).pay(25.0));
      System.out.println("getBean(PaymentGateway.class) returns " + context.getBean(PaymentGateway.class).getClass().getSimpleName());
    }
  }
}
 OUTPUT of the above code
Stripe charged 25.0
getBean(PaymentGateway.class) returns StripeGateway

Explanation of the code

  • @Primary on StripeGateway resolved the ambiguity in the constructor of CheckoutService, and in getBean() by type too.
  • PayPalGateway is still a bean. Code that wants it specifically can ask for it with @Qualifier, as shown next, and a List<PaymentGateway> still receives both.
  • @Primary works on @Bean methods as well. Only one bean per type should be primary; two primary beans of one type are ambiguous again.

@Qualifier: choosing at the injection point

@Primary decides on the bean side, once for everyone. @Qualifier decides on the injection side: each constructor parameter, setter or field can say which bean it wants. The simplest qualifier value is the bean name -
@Qualifier selects a bean by name for each injection point
//QualifierApp.java
package com.javabrahman.spring.choosing.qualifier;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface PaymentGateway {
  String charge(double amount);
}

@Component
@Primary
class StripeGateway implements PaymentGateway {
  public String charge(double amount) {
    return "Stripe charged " + amount;
  }
}

@Component
class PayPalGateway implements PaymentGateway {
  public String charge(double amount) {
    return "PayPal charged " + amount;
  }
}

@Service
class RefundService {
  private final PaymentGateway gateway;

  // @Qualifier wins over @Primary: this service always uses PayPal
  RefundService(@Qualifier("payPalGateway") PaymentGateway gateway) {
    this.gateway = gateway;
  }

  String refund(double amount) {
    return "Refund via " + gateway.getClass().getSimpleName() + ": " + gateway.charge(-amount);
  }
}

@Service
class CheckoutService {
  private final PaymentGateway gateway;

  CheckoutService(PaymentGateway gateway) {   // no qualifier: gets the @Primary bean
    this.gateway = gateway;
  }

  String pay(double amount) {
    return "Checkout via " + gateway.getClass().getSimpleName() + ": " + gateway.charge(amount);
  }
}

@Configuration
@ComponentScan
class AppConfig {
}

public class QualifierApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println(context.getBean(CheckoutService.class).pay(40.0));
      System.out.println(context.getBean(RefundService.class).refund(15.0));
    }
  }
}
 OUTPUT of the above code
Checkout via StripeGateway: Stripe charged 40.0
Refund via PayPalGateway: PayPal charged -15.0

Explanation of the code

  • @Qualifier("payPalGateway") on the constructor parameter selected the bean named payPalGateway - the default name of PayPalGateway.
  • An explicit qualifier wins over @Primary: RefundService got PayPal although Stripe is primary, while CheckoutService, which has no qualifier, got the primary Stripe bean.
  • A qualifier value does not have to be the bean name: putting @Qualifier("refunds") on the PayPalGateway class as well gives it a qualifier label that injection points can use, independent of its name.

Custom qualifier annotations and parameter names

Qualifier strings are easy to mistype, and a typo is only found at startup. A custom qualifier annotation - your own annotation, itself annotated with @Qualifier - is checked by the compiler and documents the meaning. Spring also has a last-resort rule: if nothing else decides, it compares the parameter name with the bean names -
A custom @Instant qualifier annotation, and matching by parameter name
//CustomQualifierApp.java
package com.javabrahman.spring.choosing.custom;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

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

interface Notifier {
  String send(String message);
}

@Component
@Instant
class SmsNotifier implements Notifier {
  public String send(String message) {
    return "SMS: " + message;
  }
}

@Component
class EmailNotifier implements Notifier {
  public String send(String message) {
    return "Email: " + message;
  }
}

@Service
class FraudAlerts {
  private final Notifier notifier;

  FraudAlerts(@Instant Notifier notifier) {   // type-safe: the compiler checks @Instant exists
    this.notifier = notifier;
  }

  String alert() {
    return notifier.send("Suspicious login");
  }
}

@Service
class Newsletter {
  private final Notifier notifier;

  // No qualifier and no @Primary: the parameter name "emailNotifier" matches the bean name
  Newsletter(Notifier emailNotifier) {
    this.notifier = emailNotifier;
  }

  String send() {
    return notifier.send("November issue");
  }
}

@Configuration
@ComponentScan
class AppConfig {
}

public class CustomQualifierApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println(context.getBean(FraudAlerts.class).alert());
      System.out.println(context.getBean(Newsletter.class).send());
    }
  }
}
 OUTPUT of the above code
SMS: Suspicious login
Email: November issue

Explanation of the code

  • @Instant is meta-annotated with @Qualifier, so Spring treats it as a qualifier. Putting it on SmsNotifier and on the FraudAlerts parameter connects the two. It needs RUNTIME retention so that Spring can read it.
  • Newsletter has no qualifier, and neither notifier is primary, yet startup succeeded: Spring matched the parameter name emailNotifier to the bean named emailNotifier.
  • Matching by parameter name only works when the compiler keeps parameter names in the class files - the -parameters option of javac, which these examples use and which Spring Boot's Maven and Gradle plugins switch on. It is also easy to break by renaming a parameter, so treat it as a fallback and prefer an explicit qualifier.

@Fallback: the opposite of @Primary

@Fallback, added in Spring Framework 6.2, marks a bean that should be used only when no other candidate is available. It is ideal for a default implementation that an application - or a test - may replace simply by declaring another bean -
A @Fallback bean steps aside when another bean of the same type exists
//FallbackApp.java
package com.javabrahman.spring.choosing.fallback;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Fallback;
import org.springframework.context.annotation.Import;

interface CurrencyConverter {
  String name();
}

class FixedRateConverter implements CurrencyConverter {
  public String name() {
    return "fixed rates (built-in default)";
  }
}

class LiveRateConverter implements CurrencyConverter {
  public String name() {
    return "live rates";
  }
}

@Configuration
class DefaultsConfig {
  @Bean
  @Fallback
  CurrencyConverter fixedRateConverter() {
    return new FixedRateConverter();
  }
}

@Configuration
class LiveRatesConfig {
  @Bean
  CurrencyConverter liveRateConverter() {
    return new LiveRateConverter();
  }
}

@Configuration
@Import(DefaultsConfig.class)
class DefaultsOnly {
}

@Configuration
@Import({DefaultsConfig.class, LiveRatesConfig.class})
class DefaultsAndLiveRates {
}

public class FallbackApp {
  public static void main(String[] args) {
    for (Class<?> config : new Class<?>[] {DefaultsOnly.class, DefaultsAndLiveRates.class}) {
      try (var context = new AnnotationConfigApplicationContext(config)) {
        System.out.println(config.getSimpleName() + " -> " + context.getBean(CurrencyConverter.class).name());
      }
    }
  }
}
 OUTPUT of the above code
DefaultsOnly -> fixed rates (built-in default)
DefaultsAndLiveRates -> live rates

Explanation of the code

  • With only the defaults, the fallback converter was the single candidate and was used.
  • Once LiveRatesConfig added another converter, the @Fallback bean stepped aside - with no @Primary and no qualifier needed.
  • The order of precedence when Spring chooses among several candidates is: an explicit qualifier (which narrows the candidates first), then the @Primary bean, then the only non-fallback bean, then the bean with the highest @Priority (from jakarta.annotation), then a match on the parameter name. If none of these decides, startup fails with NoUniqueBeanDefinitionException.

Conditional beans with @Profile

Sometimes the question is not which bean to inject, but whether a bean should exist at all. The most common case is environments: a fake mail sender on a developer's machine, the real one in production. @Profile registers a bean only when the named profile is active -
@Profile registers different beans in different environments
//ProfileApp.java
package com.javabrahman.spring.choosing.profile;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

interface MailSender {
  String send(String to);
}

@Component
@Profile("dev")
class ConsoleMailSender implements MailSender {
  public String send(String to) {
    return "[dev] mail to " + to + " printed to the console only";
  }
}

@Component
@Profile("prod")
class SmtpMailSender implements MailSender {
  public String send(String to) {
    return "[prod] mail to " + to + " sent through SMTP";
  }
}

@Component
@Profile("!prod")   // every profile except prod
class SampleDataLoader {
}

@Configuration
@ComponentScan
class AppConfig {
}

public class ProfileApp {
  public static void main(String[] args) {
    for (String profile : new String[] {"dev", "prod"}) {
      try (var context = new AnnotationConfigApplicationContext()) {
        context.getEnvironment().setActiveProfiles(profile);
        context.register(AppConfig.class);
        context.refresh();
        System.out.println(context.getBean(MailSender.class).send("Deborah Sprightly")
            + " | sample data loaded: " + context.containsBean("sampleDataLoader"));
      }
    }
  }
}
 OUTPUT of the above code
[dev] mail to Deborah Sprightly printed to the console only | sample data loaded: true
[prod] mail to Deborah Sprightly sent through SMTP | sample data loaded: false

Explanation of the code

  • Each run activated one profile before registering the configuration and refreshing the context. Only the MailSender for that profile became a bean, so there was never any ambiguity.
  • @Profile("!prod") negates a profile: SampleDataLoader exists in every environment except production. Profile expressions also support & and |, as in @Profile("dev & cloud").
  • Outside code, profiles are usually activated with the spring.profiles.active property - a JVM system property, an environment variable (SPRING_PROFILES_ACTIVE), or a line in Spring Boot's application.properties.

Your own condition with @Conditional

@Profile is itself built on a more general mechanism: @Conditional, which takes a class implementing Condition. Spring calls the condition's matches() method while it reads the configuration, and registers the bean only if it returns true. The condition below enables a feature from a property -
A custom Condition that registers a bean based on a property
//ConditionalApp.java
package com.javabrahman.spring.choosing.conditional;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.type.AnnotatedTypeMetadata;

class RecommendationsEnabled implements Condition {
  @Override
  public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
    return "true".equals(context.getEnvironment().getProperty("features.recommendations"));
  }
}

class RecommendationEngine {
}

@Configuration
class AppConfig {
  @Bean
  @Conditional(RecommendationsEnabled.class)
  RecommendationEngine recommendationEngine() {
    return new RecommendationEngine();
  }
}

public class ConditionalApp {
  public static void main(String[] args) {
    for (String value : new String[] {"false", "true"}) {
      System.setProperty("features.recommendations", value);
      try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
        System.out.println("features.recommendations=" + value
            + " -> RecommendationEngine bean exists: " + context.containsBean("recommendationEngine"));
      }
    }
  }
}
 OUTPUT of the above code
features.recommendations=false -> RecommendationEngine bean exists: false
features.recommendations=true -> RecommendationEngine bean exists: true

Explanation of the code

  • RecommendationsEnabled.matches() read the property through the ConditionContext, which also gives access to the bean factory, the class loader and resources.
  • With the property false, the @Bean method was skipped completely - the bean definition was never registered, so the method was never called.
  • @Conditional can also go on a @Configuration or @Component class, to switch a whole group of beans on or off.

Spring Boot's ready-made conditions

Writing Condition classes for everyday checks would be tedious, so Spring Boot provides a set of conditional annotations. They are what auto-configuration is made of, and you can use them in your own configuration too -
  • @ConditionalOnProperty: a property has a given value, or is present.
  • @ConditionalOnClass / @ConditionalOnMissingClass: a class is, or is not, on the classpath - this is how Boot knows which libraries you added.
  • @ConditionalOnBean / @ConditionalOnMissingBean: another bean exists, or does not. Auto-configuration declares its beans with @ConditionalOnMissingBean, which is why declaring your own bean of the same type replaces Boot's.
  • @ConditionalOnWebApplication, @ConditionalOnResource, @ConditionalOnExpression and more.
Here is the recommendation feature again, in a Spring Boot application with @ConditionalOnProperty instead of a hand-written condition, run once without and once with the property on the command line -
@ConditionalOnProperty in a Spring Boot application
//BootConditionsApp.java
package com.javabrahman.spring.choosing.boot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;

class RecommendationEngine {
}

@SpringBootApplication
public class BootConditionsApp {
  @Bean
  @ConditionalOnProperty(name = "features.recommendations", havingValue = "true")
  RecommendationEngine recommendationEngine() {
    return new RecommendationEngine();
  }

  public static void main(String[] args) {
    try (var context = SpringApplication.run(BootConditionsApp.class, args)) {
      System.out.println("Arguments " + String.join(" ", args)
          + " -> RecommendationEngine bean exists: " + context.containsBean("recommendationEngine"));
    }
  }
}
 OUTPUT of the above code
Arguments --features.recommendations=false -> RecommendationEngine bean exists: false
 OUTPUT of the above code
Arguments --features.recommendations=true -> RecommendationEngine bean exists: true

Explanation of the code

  • @ConditionalOnProperty(name = ..., havingValue = "true") does the same job as our RecommendationsEnabled class, in one line.
  • Spring Boot turns command-line arguments of the form --name=value into properties, so the same jar can switch the feature on or off without a code change. The same property could come from application.properties or an environment variable (FEATURES_RECOMMENDATIONS=true).
  • Boot's conditions are tuned for configuration classes and auto-configuration; for a plain @Component, @Profile or @ConditionalOnProperty on the class works the same way.

Summary

In this tutorial we first saw the startup failure that occurs when two beans match one injection point. We resolved it with @Primary, which sets a default bean, and with @Qualifier, which chooses at the injection point and wins over @Primary. We then wrote a type-safe custom qualifier annotation, saw Spring fall back to matching parameter names, and used @Fallback for a default bean that steps aside, which gave us the full order of precedence. Next we made beans conditional: on the active profile with @Profile, on a property with our own Condition, and lastly with Spring Boot's @ConditionalOnProperty and its related annotations. The last tutorial of this module covers expressions in configuration: SpEL and @ValueClick to read tutorial: Spring Expression Language (SpEL) and @Value Explained.