StackTips
 18 minutes

Choosing from Multiple Bean Implementations in Spring Boot

By Nilanchala @nilan, On Mar 09, 2024 Spring Boot 686 Views

If you have multiple implementations of the same interface, Spring needs to know which bean to inject into a class.

In this article we will explore what are the different approaches to inject beans conditionally when you have multiple implantation of an component or service.

Let's us explore this example, we have 2 mail sender implementation, one using SES and other using default mail sender. For sake of simplicity, we have skipped the sending the actual mail, instead it logs which sender service is invoked.

public interface MailSender {

    void sendMail(String from, String to, String subject);
}

@Service
public class DefaultMailSender implements MailSender {  
    private final Logger logger = LoggerFactory.getLogger(DefaultMailSender.class);

    @Override
    public void sendMail(String from, String to, String subject) {
        logger.info("Sending email using DefaultMailSender");
    }
}

@Service
public class SesMailSender implements MailSender {  
    private final Logger logger = LoggerFactory.getLogger(HttpMailSender.class);

    @Override  
    public void sendMail(String from, String to, String subject) {
        logger.info("Sending email using SesMailSender");
    }
}

And in our MainApplication class, we have injected bean of MailSender class using constructor injection.

@SpringBootApplication
public class MyApplication implements CommandLineRunner {

    private final MailSender mailSender;

    public MyApplication(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Override
    public void run(String... args) throws Exception {
        mailSender.sendMail("[email protected]", "[email protected]", "Test mail"); 
    }

    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

When you run the application, it will return the following error;

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.stacktips.app.MyApplication required a single bean, but 2 were found:
    - defaultMailSender: defined in file [/Users/nilanchala/Documents/spring-boot-conditional-bean/build/classes/java/main/com/stacktips/app/service/DefaultMailSender.class]
    - sesMailSender: defined in file [/Users/nilanchala/Documents/spring-boot-conditional-bean/build/classes/java/main/com/stacktips/app/service/SesMailSender.class]

This may be due to missing parameter name information

Action:

Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

It is because, as we have two implementations of the EmailSender, spring cannot determine which implementation to inject for sendMail() method invocation.

We have different options available to let spring know which implementation to choose. Let's explore.

Option-1: Using @Primary Bean Annotation

When there are several bean implementations available, we can choose the primary bean candidate using the @Primary annotation.

For example;

@Primary  
@Service
public class DefaultMailSender implements MailSender {  

    private final Logger logger = LoggerFactory.getLogger(DefaultMailSender.class);  

    @Override  
    public void sendMail(String from, String to, String subject) {  
        logger.info("Sending email using DefaultMailSender");  
    }  
}

With this, Spring will inject the bean of DefaultMailSender.

Option-2: Autowiring using @Qualifier

The @Primary annotation provides limited control over the bean type selection. When you need more control over the selection process, we can use the @Qualifier annotation.

You can associate qualifier values with specific arguments, narrowing the set of type matches so that a specific bean is chosen for each argument.

For example;

@SpringBootApplication  
public class MyApplication implements CommandLineRunner {  

    private final MailSender mailSender;  

    public MyApplication(@Qualifier("sesMailSender") MailSender mailSender) {  
        this.mailSender = mailSender;  
    }  

    @Override  
    public void run(String... args) throws Exception {  
        mailSender.sendMail("[email protected]", "[email protected]", "Test mail");  
    }  

    public static void main(String[] args) {  
        SpringApplication.run(MyApplication.class, args);  
    }  
}

As we are specifying @Qualifier("sesMailSender"), Spring will now inject the sesMailSender bean.

Option-3: Using ApplicationContext to Dynamically Select Beans

Both approaches defined above uses static approach to inject bean types. To achieve dynamic bean selection based on certain conditions, we can use @Autowired in combination with ApplicationContext.

Let us create a factory class EmailService:

@Component  
public class MailService {  

    private final ApplicationContext context;  

    public MailService(ApplicationContext context) {  
        this.context = context;  
    }  

    public MailSender getMailSender(String type) {  
        if ("ses".equals(type)) {  
            return context.getBean("sesMailSender", SesMailSender.class);  
        }  

        return context.getBean("defaultMailSender", DefaultMailSender.class);  
    }  

    public void sendMail(String from, String to, String subject) {  
        getMailSender("ses").sendMail(from, to, subject);  
    }  
}

Now in our MainApplication class, instead of injecting MailSender bean, we can use MailService type instead.

@SpringBootApplication  
public class MyApplication implements CommandLineRunner {  

    private final MailService mailService;  

    public MyApplication(MailService mailService) {  
        this.mailService = mailService;  
    }  

    @Override  
    public void run(String... args) throws Exception {  
        mailService.sendMail("[email protected]", "[email protected]", "Test mail");  
    }  

    public static void main(String[] args) {  
        SpringApplication.run(MyApplication.class, args);  
    }  
}

Option-4: Custom Annotation and Bean Factory

Another approach is to create a custom annotation and a factory that uses this annotation to decide which bean to inject dynamically based on certain conditions.

This is similar to Option-4, but using the custom annotation. This approach offers greater flexibility and keeps your code clean.

Let us first define a custom annotation:

import java.lang.annotation.ElementType;  
import java.lang.annotation.Retention;  
import java.lang.annotation.RetentionPolicy;  
import java.lang.annotation.Target;  

@Target(ElementType.TYPE)  
@Retention(RetentionPolicy.RUNTIME)  
public @interface MailSenderSelector {  

    String value();  
}

Now use the annotation in your MailSender services

@Service  
@MailSenderSelector("default")  
public class DefaultMailSender implements MailSender {  

    private final Logger logger = LoggerFactory.getLogger(DefaultMailSender.class);  

    @Override  
    public void sendMail(String from, String to, String subject) {  
        logger.info("Sending email using DefaultMailSender");  
    }  
}

@Service  
@MailSenderSelector("ses")  
public class SesMailSender implements MailSender {  

    private final Logger logger = LoggerFactory.getLogger(SesMailSender.class);  

    @Override  
    public void sendMail(String from, String to, String subject) {  
        logger.info("Sending email using SesMailSender");  
    }  
}

Now we will implement a bean factory that checks this condition at runtime and selects the appropriate bean types based on the argument passed to the annotation.

@Component  
public class MailService {  

    private final ApplicationContext context;  

    public MailService(ApplicationContext context) {  
        this.context = context;  
    }  

    public MailSender getMailSender(String type) {  
        Map<String, Object> beansWithAnnotation = context.getBeansWithAnnotation(MailSenderSelector.class);  
        Optional<Object> matchingBean = beansWithAnnotation.values().stream()  
                .filter(bean -> bean.getClass().getAnnotation(MailSenderSelector.class).value().equals(type))  
                .findFirst();  

        if (matchingBean.isEmpty()) {  
            throw new IllegalArgumentException("No bean found for type: " + type);  
        }  

        return (MailSender) matchingBean.get();  
    }  

    public void sendMail(String from, String to, String subject) {  
        getMailSender("ses").sendMail(from, to, subject);  
    }  
}

Option-5: Bean Selection Using Custom Conditions

Another approach is to define custom conditions to match the bean types to inject. This method determines whether a condition is met based on the application context and metadata.

import org.springframework.context.annotation.Condition;  
import org.springframework.context.annotation.ConditionContext;  
import org.springframework.core.type.AnnotatedTypeMetadata;  
import org.springframework.stereotype.Component;

@Component  
public class DefaultMailSenderCondition implements Condition {  

    @Override  
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { 
        String mailSender = context.getEnvironment().getProperty("email.mail-sender"); 
        return null== mailSender || mailSender.trim().equals("default");  
    }  
}


@Component  
public class SesMailSenderCondition implements Condition {  

    @Override  
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { 
        String mailSender = context.getEnvironment().getProperty("email.mail-sender"); 
        return null != mailSender && mailSender.trim().equals("ses");  
    }  
}

The two conditions above uses the email.mail-sender application property value to determine the bean type to inject.

application.properties

#default or ses  
email.mail-sender=ses

In this case, the condition for SesMailSender will be true, and thus, SesMailSender will be the active implementation of MailSender in the application context.

Now from mail sender implementations, we can use @Conditional annotation by passing the corresponding condition that dictates which bean should be registered.

public interface MailSender {  
    void sendMail(String from, String to, String subject);  
}

@Primary  
@Component  
@Conditional(DefaultMailSenderCondition.class)  
public class DefaultMailSender implements MailSender {  

    private final Logger logger = LoggerFactory.getLogger(DefaultMailSender.class);  

    @Override  
    public void sendMail(String from, String to, String subject) {  
        logger.info("Sending email using DefaultMailSender");  
    }  
}

@Service  
@Conditional(SesMailSenderCondition.class)  
public class SesMailSender implements MailSender {  

    private final Logger logger = LoggerFactory.getLogger(SesMailSender.class);  

    @Override  
    public void sendMail(String from, String to, String subject) {  
        logger.info("Sending email using SesMailSender");  
    }  
}

There is no change in the main application class, we inject the MailSender instance using the constructor based injection.

@SpringBootApplication  
public class MyApplication implements CommandLineRunner {  

    private final MailSender mailSender;  

    public MyApplication(MailSender mailSender) {  
        this.mailSender = mailSender;  
    }  

    @Override  
    public void run(String... args) throws Exception {  
        mailSender.sendMail("[email protected]", "[email protected]", "Test mail");  
    }  

    public static void main(String[] args) {  
        SpringApplication.run(MyApplication.class, args);  
    }  
}

For the complete project source code check out the download link.

If you have any questions, write down in the comment section below. Will be happy to respond, soon as I can.

nilan avtar

Nilanchala

I'm a blogger, educator and a full stack developer. Mainly focused on Java, Spring and Micro-service architecture. I love to learn, code, make and break things.