On-Demand Beans in Spring Boot: Scalable patterns in Spring


Scalable patterns in Spring

If you need to dynamically select or create a Spring bean at runtime, avoid calling ApplicationContext#getBean() inside business code.
Instead, use one of these clean, scalable patterns:

✅ Map / EnumMap Injection (Strategy + Factory) – default and most idiomatic

✅ ServiceLocatorFactoryBean – proxy-based, zero-boilerplate lookup

✅ @Lookup Method Injection – fresh prototype per call

✅ FactoryBean<T> – encapsulated complex object creation

This article explains when to use each pattern, with full code examples, pros/cons, and real-world scenarios.

WHY YOU SHOULD AVOID ApplicationContext#getBean()

Yes, this works:

applicationContext.getBean(“emailService”);

But it comes with problems:

❌ Tight coupling to Spring container

❌ Hidden dependencies

❌ Harder unit testing

❌ String-based lookup errors

❌ Violates clean architecture boundaries

Your domain logic should not know about the container.

Spring provides better, cleaner patterns.

Let’s explore them.

PATTERN 1: Strategy + Factory Using Map Injection (Most Recommended)

✅ Use When

You have multiple implementations of an interface and need to select one based on a key (string or enum).

This is the simplest, most explicit, and most scalable solution.

Step 1: Define the Interface

public interface NotificationService {
void send(String to, String message);
String key(); // e.g., “EMAIL”, “SMS”
}

Step 2: Implementations

@Service
class EmailNotificationService implements NotificationService {

@Override
public void send(String to, String message) {
System.out.println(“Sending EMAIL to ” + to);
}

@Override
public String key() {
return “EMAIL”;
}
}

@Service
class SmsNotificationService implements NotificationService {

@Override
public void send(String to, String message) {
System.out.println(“Sending SMS to ” + to);
}

@Override
public String key() {
return “SMS”;
}
}

Step 3: Factory (Build Map at Startup)

@Component
public class NotificationServiceFactory {

private final Map<String, NotificationService> byKey;

public NotificationServiceFactory(List<NotificationService> services) {
this.byKey = new HashMap<>();
services.forEach(s -> byKey.put(s.key(), s));
}

public NotificationService get(String key) {
var svc = byKey.get(key);
if (svc == null) {
throw new IllegalArgumentException(“No NotificationService for key: ” + key);
}
return svc;
}

}

Step 4: Usage

@Service
public class NotificationFacade {

private final NotificationServiceFactory factory;

public NotificationFacade(NotificationServiceFactory factory) {
this.factory = factory;
}

public void notify(String channel, String to, String msg) {
factory.get(channel).send(to, msg);
}

}

Pros

✔ Clear and explicit

✔ Easy to unit test

✔ No runtime magic

✔ Open for extension

Cons

✖ Small amount of boilerplate

Enum-Safe Variant (Recommended in Production)

public enum Channel {
EMAIL,
SMS
}

public interface NotificationService {
Channel key();
void send(String to, String message);
}

@Component
public class NotificationServiceFactory {

private final Map<Channel, NotificationService> byKey;

public NotificationServiceFactory(List<NotificationService> services) {
this.byKey = new EnumMap<>(Channel.class);
services.forEach(s -> byKey.put(s.key(), s));
}

public NotificationService get(Channel key) {
var svc = byKey.get(key);
if (svc == null) {
throw new IllegalArgumentException(“No service for key: ” + key);
}
return svc;
}
}

This should be your default pattern.

PATTERN 2: ServiceLocatorFactoryBean (Interface-Driven Dynamic Lookup)

✅ Use When

* You want zero factory boilerplate
* You prefer an interface-based contract
* You’re building plugin-style systems

Step 1: Define Locator Interface

public interface NotificationServiceLocator {
NotificationService getService(String beanName);
}

Step 2: Configuration

@Configuration
public class LocatorConfig {

@Bean
public FactoryBean<?> notificationServiceLocator() {
ServiceLocatorFactoryBean fb = new ServiceLocatorFactoryBean();
fb.setServiceLocatorInterface(NotificationServiceLocator.class);
return fb;
}

}

Step 3: Usage

@Service
public class NotificationFacade {

private final NotificationServiceLocator locator;

public NotificationFacade(NotificationServiceLocator locator) {
this.locator = locator;
}

public void notify(String beanName, String to, String msg) {
locator.getService(beanName).send(to, msg);
}
}

Pros:

* Zero factory code
* Interface-driven
* Easy to mock
* Works with prototype beans

Cons:

* Bean-name strings can cause typos
* Slightly magical for beginners

PATTERN 3: @Lookup Method Injection (Prototype Per Call)

✅ Use When
You need a new prototype instance per method call, but the caller is a singleton.

Prototype Bean

@Component
@Scope(“prototype”)
public class AgentTaskHandler {

public void handle(String input) {
System.out.println(“Handling task: ” + input);
}
}

Singleton Caller

@Service
public class AgentService {

public void runTask(String input) {
AgentTaskHandler handler = createHandler();
handler.handle(input);
}

@Lookup(“agentTaskHandler”)
protected AgentTaskHandler createHandler() {
return null; // Spring overrides this method
}
}

Pros:

* Clean prototype retrieval
* Keeps singletons stateless

Cons:

* Runtime method override
* Can confuse new developers

PATTERN 4: FactoryBean (Encapsulated Complex Creation)

✅ Use When
You need to create:

* SDK clients
* External connectors
* Conditional constructors
* Non-Spring objects

@Component
public class ExternalClientFactory implements FactoryBean<ExternalClient> {

@Override
public ExternalClient getObject() {
return new ExternalClient(“endpoint”, “apiKey”);
}

@Override
public Class<?> getObjectType() {
return ExternalClient.class;
}
}

Now you can inject:

@Autowired
private ExternalClient client;

Spring injects the created object — not the factory.

Pros:

* Encapsulates complex construction
* Clean dependency injection
* Lifecycle managed by Spring

Cons:

* Slightly advanced concept

REAL-WORLD EXAMPLE: Vendor-Based Dispatch

Imagine your notification system supports:

* SMS
* EMAIL
* TWITTER

Based on vendor type, you must dispatch to the correct implementation.

Best Fit:

* EnumMap Injection (compile-time safety)

If adding modules dynamically:

* Consider ServiceLocatorFactoryBean

If handlers are stateful:

* Combine with @Lookup

TESTING TIPS

Map Injection:
Inject test map with mocks

Service Locator:
Mock locator interface

@Lookup:
Override method in test subclass

FactoryBean:
Unit test factory separately

COMMON PITFALLS

* Calling ApplicationContext#getBean() in business logic
* Using strings instead of enums
* Mixing business logic inside factories
* Holding mutable state in singletons
* Not documenting valid keys

WHICH PATTERN SHOULD YOU CHOOSE?

Select implementation by key:
Map / EnumMap Injection

Plugin-style dynamic lookup:
ServiceLocatorFactoryBean

New instance per call:
@Lookup

Complex object creation:
FactoryBean

FINAL THOUGHTS

You do not need to manually create Spring beans in your business logic.

Spring already provides elegant, scalable patterns:

* Map / EnumMap Injection → default and recommended
* ServiceLocatorFactoryBean → interface-driven lookup
* @Lookup → fresh prototype per call
* FactoryBean → encapsulated object creation

Start simple.
Keep dependencies explicit.
Avoid container leakage.

That’s how you build scalable Spring Boot systems.

BONUS: COPY-PASTE TEMPLATE

public enum Channel { EMAIL, SMS }

public interface NotificationService {
Channel key();
void send(String to, String message);
}

@Service
class EmailNotificationService implements NotificationService {
@Override
public Channel key() { return Channel.EMAIL; }
@Override
public void send(String to, String message) { }
}

@Service
class SmsNotificationService implements NotificationService {
@Override
public Channel key() { return Channel.SMS; }
@Override
public void send(String to, String message) { }
}

@Component
public class NotificationServiceFactory {

private final Map<Channel, NotificationService> byKey;

public NotificationServiceFactory(List<NotificationService> services) {
this.byKey = new EnumMap<>(Channel.class);
services.forEach(s -> byKey.put(s.key(), s));
}

public NotificationService get(Channel channel) {
var svc = byKey.get(channel);
if (svc == null) throw new IllegalArgumentException(“No service for ” + channel);
return svc;
}
}

You may also like