Spring Framework
Dependency Injection
Java
Runtime Configuration
Spring Boot

How to inject different services at runtime based on a property with Spring without XML

Object-Oriented Design practice on Codemia

Turn requirements into classes, and defend the design, on the problems that come up in OOD rounds.

Practice OOD

Introduction

In Spring, selecting one service implementation based on configuration is a common requirement. The first thing to clarify is whether the choice happens once at application startup or repeatedly while the application is running. Without XML, the usual solutions are Java configuration, @ConditionalOnProperty, or a small factory that chooses among already-registered beans.

Startup-Time Selection with @ConditionalOnProperty

If the property value is fixed for the lifetime of the process, @ConditionalOnProperty is the cleanest solution. Spring creates only the matching bean during startup.

Start with an interface:

java
public interface MessageSender {
    void send(String message);
}

Two implementations:

java
1public class EmailMessageSender implements MessageSender {
2    @Override
3    public void send(String message) {
4        System.out.println("EMAIL: " + message);
5    }
6}
java
1public class SmsMessageSender implements MessageSender {
2    @Override
3    public void send(String message) {
4        System.out.println("SMS: " + message);
5    }
6}

Then wire the selected bean in a configuration class:

java
1import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
2import org.springframework.context.annotation.Bean;
3import org.springframework.context.annotation.Configuration;
4
5@Configuration
6public class MessageSenderConfig {
7
8    @Bean
9    @ConditionalOnProperty(name = "app.sender", havingValue = "email", matchIfMissing = true)
10    public MessageSender emailMessageSender() {
11        return new EmailMessageSender();
12    }
13
14    @Bean
15    @ConditionalOnProperty(name = "app.sender", havingValue = "sms")
16    public MessageSender smsMessageSender() {
17        return new SmsMessageSender();
18    }
19}

In application.yml:

yaml
app:
  sender: sms

Only one MessageSender bean will exist, so normal constructor injection works.

Consume the Selected Bean Normally

The service that depends on the selected implementation stays simple.

java
1import org.springframework.stereotype.Service;
2
3@Service
4public class NotificationService {
5    private final MessageSender messageSender;
6
7    public NotificationService(MessageSender messageSender) {
8        this.messageSender = messageSender;
9    }
10
11    public void notifyUser(String message) {
12        messageSender.send(message);
13    }
14}

This is ideal for deployment-time configuration where the property is set once and stays stable.

True Runtime Selection with a Factory

If the implementation must change while the application is running, startup-only conditional wiring is not enough. In that case, register both implementations and select at call time through a factory or router.

java
1public enum SenderType {
2    EMAIL,
3    SMS
4}
java
1import org.springframework.context.annotation.Bean;
2import org.springframework.context.annotation.Configuration;
3
4@Configuration
5public class SenderBeansConfig {
6    @Bean("emailSenderBean")
7    public MessageSender emailSenderBean() {
8        return new EmailMessageSender();
9    }
10
11    @Bean("smsSenderBean")
12    public MessageSender smsSenderBean() {
13        return new SmsMessageSender();
14    }
15}
java
1import org.springframework.beans.factory.annotation.Qualifier;
2import org.springframework.stereotype.Component;
3
4@Component
5public class MessageSenderFactory {
6    private final MessageSender emailSender;
7    private final MessageSender smsSender;
8
9    public MessageSenderFactory(
10            @Qualifier("emailSenderBean") MessageSender emailSender,
11            @Qualifier("smsSenderBean") MessageSender smsSender) {
12        this.emailSender = emailSender;
13        this.smsSender = smsSender;
14    }
15
16    public MessageSender get(SenderType type) {
17        return switch (type) {
18            case EMAIL -> emailSender;
19            case SMS -> smsSender;
20        };
21    }
22}

Now a caller can select per invocation:

java
1import org.springframework.beans.factory.annotation.Value;
2import org.springframework.stereotype.Service;
3
4@Service
5public class DynamicNotificationService {
6    private final MessageSenderFactory factory;
7
8    @Value("${app.sender:EMAIL}")
9    private String senderType;
10
11    public DynamicNotificationService(MessageSenderFactory factory) {
12        this.factory = factory;
13    }
14
15    public void notifyUser(String message) {
16        SenderType type = SenderType.valueOf(senderType.toUpperCase());
17        factory.get(type).send(message);
18    }
19}

That is runtime selection because the property is consulted during method execution.

When @Primary and @Qualifier Are Enough

Not every injection problem requires property-based routing. Sometimes you simply need one default bean and one special-case bean.

Use:

  • '@Primary when one bean should be the default choice'
  • '@Qualifier when a specific consumer should receive a specific bean'
  • '@ConditionalOnProperty when configuration should decide startup wiring'
  • a factory when the choice must happen during application execution

Using the wrong mechanism usually makes the bean graph harder to understand.

Test the Wiring Explicitly

Property-driven wiring should have an explicit test.

java
1import org.junit.jupiter.api.Test;
2import org.springframework.beans.factory.annotation.Autowired;
3import org.springframework.boot.test.context.SpringBootTest;
4import org.springframework.test.context.TestPropertySource;
5
6@SpringBootTest
7@TestPropertySource(properties = "app.sender=sms")
8class NotificationServiceTest {
9
10    @Autowired
11    private NotificationService notificationService;
12
13    @Test
14    void contextLoads() {
15        notificationService.notifyUser("hello");
16    }
17}

Tests like this catch invalid property names and ambiguous bean registration early.

Common Pitfalls

One common mistake is calling startup-time bean selection "runtime" selection. If the application must switch implementations after boot, @ConditionalOnProperty is not enough.

Another mistake is keeping @Component on multiple implementations while also creating config-driven beans, which can produce duplicate candidates.

Developers also rely on raw string property values without validation. Invalid values should fail clearly.

Finally, some code uses qualifiers everywhere when a small factory would express the routing rule more directly.

Summary

  • Use @ConditionalOnProperty when configuration decides the implementation at startup.
  • Use a factory or router when the choice must happen during execution.
  • Keep bean registration explicit and avoid mixing multiple selection mechanisms without a reason.
  • Remove duplicate bean definitions when moving to config-based wiring.
  • Add tests for property-driven wiring so configuration mistakes fail early.

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Object-Oriented Design practice on Codemia

Turn requirements into classes, and defend the design, on the problems that come up in OOD rounds.

Practice OOD