1. When one bean is not enough: a set of strategies
While the service asks for one bean, the whole conversation revolves around choosing one candidate. But sometimes choosing just one is an artificial limitation. Imagine that you have several ways to send a notification (SMS, email, console), and suddenly there is a requirement: “in case of a critical error, send it through all channels at once.” Here, choosing one bean is not just inconvenient — it contradicts the meaning of the task.
At such moments, it is very important not to take a “step back” and start writing if ("sms") new Sms... all over again. We already agreed that dependencies should arrive in the class through the constructor, and wiring should live outside. So when the business meaning requires a set of implementations, we must teach the container: “give me not one NotificationSender, but all senders.”
At the Spring level, this looks like a change in the dependency type. And the difference here is contractual, not “purely syntactic”.
| What is written in the constructor | What it means for the container | Typical Spring reaction |
|---|---|---|
| NotificationSender sender | “I need one specific object” | Uniqueness is required (or @Primary, @Qualifier) |
| List<NotificationSender> senders | “I need the full set of implementations” | Spring collects all matching beans into a list |
| Set<NotificationSender> senders | “I need a set of implementations without any idea of order” | Spring collects all matching beans into a set |
| Map<String, NotificationSender> senders | “I need a registry: bean name → implementation” | Spring collects a map by bean names |
If in previous lectures ambiguity was a wiring error, here ambiguity is the goal: we deliberately want to get many candidates.
2. Collections List<T> and Set<T> in autowiring
Before writing code, it is useful to pause on one simple thing: Spring does not “read minds”, it reads the type of the dependency. And when you write List<NotificationSender>, you are actually giving the container a very clear task: “find all beans that match the NotificationSender type, and pack them into a collection”.
Generics matter here: List<NotificationSender> is not the same as List<Object>. Spring looks at the parameterization and searches for candidates specifically for NotificationSender. So collection injection is not a “magic container mode”, but the same normal autowiring, just with a different result contract.
There is also a nice practical nuance that often surprises beginners in a good way. If you inject one bean of type NotificationSender and there are no candidates, the container fails with an error because it is a required dependency. But if you inject List<NotificationSender> and there are no candidates, Spring, as a rule, gives you an empty collection. That makes sense: “give me all of them” can legitimately mean “there are none”.
So collection injection solves two tasks at once: “assemble a set of strategies” and make it shape-safe (there is always a collection). But the business meaning of an empty set is already your responsibility: sometimes an empty list is acceptable, and sometimes it should lead to a clear error.
To make the picture easier to hold in your head, you can imagine a scheme like this:
flowchart TD
A["Injection point: NotificationSender"] --> B{"How many candidates match by type?"}
B -->|0| E["Error: no bean"]
B -->|1| C["Inject this bean"]
B -->|>1| D["Need a selection rule (@Primary / @Qualifier)"]
A2["Injection point: List<NotificationSender>"] --> F["Collect all candidates"]
F --> G["Inject the collection (it can be empty)"]
And now we are ready for practice: let us learn how to get all implementations of NotificationSender and DiscountPolicy in our ContextFlow.
3. List<NotificationSender>: the broadcast layer
Imagine a task that shows up suspiciously often in real projects: we need to send the same message to all channels. In the ContextFlow domain, this might be, for example, a “critical notification” or a “diagnostic message when a scenario starts”. We are not turning this into a separate architectural religion — we are just using it as a clear example where a list of implementations has a natural meaning.
To begin with, let us recall (or create, if you do not have it yet) our notification-sending contract. Let it live in domain.ports, so that the application layer depends on the interface and the implementations live in infrastructure.
package com.example.contextflow.domain.ports;
public interface NotificationSender {
// Port contract: any implementation must be able to send a message
void send(String message);
}
Now suppose we already have several implementations registered through component scanning. Here I will show one; the others look similar.
package com.example.contextflow.infrastructure.notification;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Component;
@Component // Spring will pick up this implementation as a bean automatically
class ConsoleNotificationSender implements NotificationSender {
public void send(String message) {
// Simplest implementation: just print the message to the console
System.out.println("CONSOLE: " + message); // CONSOLE: Order created
}
}
Now let us create a service that receives the entire list of senders and performs a “send to everyone” broadcast. Notice that we are not asking for “one NotificationSender”, we are asking specifically for List<NotificationSender> — and that removes the ambiguity problem, because ambiguity is now desired.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class NotificationBroadcastService {
private final List<NotificationSender> senders;
public NotificationBroadcastService(List<NotificationSender> senders) {
// A list of all NotificationSender instances from the context is injected
this.senders = senders;
}
public void broadcast(String message) {
// “Send to everyone”: call send on each sender
senders.forEach(s -> s.send(message));
}
}
From the Spring container's point of view, this is pretty literal. It finds all beans that are NotificationSender, collects them into a list, and injects them into the constructor. No @Primary, no @Qualifier — because we are not asking it to choose one winner.
To see that it really works, it is convenient to add a small diagnostic call in our ScenarioRunner (or in any other runner you already use as a console entry point). This is not a “new layer”, just a demonstration within the educational project.
package com.example.contextflow.application.scenario;
import com.example.contextflow.application.service.NotificationBroadcastService;
import org.springframework.stereotype.Component;
@Component // Demonstration component: shows that broadcast really sends to all channels
class ScenarioRunner {
private final NotificationBroadcastService broadcastService;
ScenarioRunner(NotificationBroadcastService broadcastService) {
// DI through the constructor — no manual new
this.broadcastService = broadcastService;
}
void run() {
broadcastService.broadcast("Order created");
// EMAIL: Order created
// SMS: Order created
// CONSOLE: Order created
}
}
Here it is important to grasp the correct architectural moral. We did not “work around” the container. We simply assigned a different meaning to the dependency: the service needs not one sender, but a set of senders. This is a normal part of DI thinking: the dependency contract is expressed in the type.
4. Set<DiscountPolicy>: a set without order
Now let us look at a very similar mechanism, but with different semantics: Set<T>. At the Spring level, Set means almost the same thing as List: the container finds all candidates by type and assembles them into a collection. The difference is more for us humans than for the container: Set tells the code reader, “do not think about order, think about a set”.
For discounts, this is especially suitable, because a “set of available policies” usually does not make sense as an ordered list. If we start secretly depending on the element position, it becomes a comedy: “the discount works only because this bean happened to be first.” A comedy, by the way, will be short and tragic.
For the example, let us take the DiscountPolicy interface.
package com.example.contextflow.domain.ports;
public interface DiscountPolicy {
// Contract: input is the total amount, output is the amount (or discount size) according to the policy
int apply(int total);
}
And let us create a simple diagnostic service that receives all discount policies as a Set<DiscountPolicy> and, for example, prints their count and class names. This is useful in an educational project: we learn to see that the container really assembled them.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.DiscountPolicy;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public class PricingDiagnosticsService {
private final Set<DiscountPolicy> policies;
public PricingDiagnosticsService(Set<DiscountPolicy> policies) {
// Set emphasizes: order does not matter to us, the set of policies does
this.policies = policies;
}
public void printAvailablePolicies() {
// Diagnostics: how many policies are registered in the context
System.out.println("Discount policies: " + policies.size()); // Discount policies: 2
// Print classes for clarity (output order is not guaranteed)
policies.forEach(p -> System.out.println(p.getClass().getSimpleName()));
}
}
If the container has NoDiscountPolicy and LoyalCustomerDiscountPolicy, you will see something like:
Discount policies: 2
NoDiscountPolicy
LoyalCustomerDiscountPolicy
Notice that we do not promise an output order. And that is good. In this course, we deliberately do not build logic that depends on collection order, because that is a separate topic and a separate source of “why does it work on my machine, but not on CI?”.
The main thing to take away from this section: Set<T> is the same “give me all of them” as List<T>, but with a more honest hint to the reader: “order does not matter, the set does.”
5. Injecting Map<String, NotificationSender>: a registry by bean name
The tastiest (and slightly trickier) version of collection injection is Map<String, T>. It is useful when you need not just a “set of implementations”, but the ability to choose the right implementation by key. In Spring, that key is, by default, the bean name.
It is important to fix this right away so that we do not fall into “magical” expectations: automatic map assembly works specifically for Map<String, T>. That means the keys are strings, and those strings are bean names. If you try to ask for Map<NotificationChannel, NotificationSender>, Spring will not “guess” how to assemble it, because NotificationChannel is not a bean name. In such a case, you usually create the map yourself (for example, via @Bean), but today we are staying within the mechanics that Spring gives you out of the box.
To start with, let us just look at the Map<String, T> mechanism in the most straightforward form. Such a registry is useful not as a final style, but as a clean demonstration that the keys really do become bean names.
First, let us simply look at the fact itself: Spring can inject Map<String, NotificationSender>, and the keys will be bean names. For that, let us create a small debug service.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class NotificationSendersDebugService {
private final Map<String, NotificationSender> senders;
public NotificationSendersDebugService(Map<String, NotificationSender> senders) {
// Map keys are bean names (for example, "emailNotificationSender")
this.senders = senders;
}
public void printSenderKeys() {
// Convenient check: which bean names ended up in the map
System.out.println("Senders: " + senders.keySet());
// Senders: [emailNotificationSender, smsNotificationSender, consoleNotificationSender]
}
}
Why are the keys exactly like that? Because by default, the bean name for a component is the class name, but with the first letter in lowercase. So EmailNotificationSender becomes emailNotificationSender. And that immediately makes naming not “cosmetic” but part of the contract if you start routing by key.
Now let us use the map for its intended purpose: build a service that chooses a sender by name. For demonstration, let us make a simple “by channel” (NotificationChannel) send through a naming convention.
First, the domain model of the channel (it is already in the project according to the spec, but I will show the minimum).
package com.example.contextflow.domain.model;
public enum NotificationChannel {
// Domain channels chosen by business logic (not by Spring)
EMAIL, SMS, CONSOLE
}
And now the routing service. Notice the idea: we take NotificationChannel, convert it into a bean name by convention (emailNotificationSender, smsNotificationSender, consoleNotificationSender), take the implementation from the map, and send the message.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.model.NotificationChannel;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class NotificationDispatchService {
private final Map<String, NotificationSender> senders;
public NotificationDispatchService(Map<String, NotificationSender> senders) {
// Inject the “sender registry”: bean name -> implementation
this.senders = senders;
}
public void send(NotificationChannel channel, String message) {
// Convention: EMAIL -> "emailNotificationSender", SMS -> "smsNotificationSender", etc.
String beanName = channel.name().toLowerCase() + "NotificationSender";
// Retrieve the implementation from the registry by bean name
NotificationSender sender = senders.get(beanName);
// In the educational example, we assume the name is found (a checked version is shown below)
sender.send(message); // for example: SMS: Order cancelled
}
}
Yes, there is a risk here: if you rename a bean or a class, the convention breaks. But within the lecture, this is a good educational compromise, because it allows us to visually connect Map<String, T> with bean names and feel that “bean name” is really part of the container model, not a random label.
If you want to make it a bit safer — and you are already starting to think like an engineer, congratulations, that is progress — you can add a null check and throw a clear error. In short educational code, at least this way:
// Protection from the situation where the convention does not match the actual bean names
NotificationSender sender = senders.get(beanName);
if (sender == null) {
throw new IllegalArgumentException("Unknown channel: " + channel);
}
sender.send(message);
The main idea is not to build a super router, but to see that Map<String, T> is a built-in registry that the container assembles for you, and that is often much better than pulling ApplicationContext into business code and looking up beans manually.
This is a deliberately mechanical routing variant. It shows the connection between Map<String, T> and bean names well, but everyday wiring usually wins when channel selection is kept more explicit in one place, rather than being computed from a string convention on the fly.
6. ContextFlow increment: notification dispatcher
To make sure this does not remain just a “pretty example on the board”, let us connect our NotificationDispatchService to a place where it feels natural: the order processing scenario. Here it now plays a different role on purpose: not a “service with one sender”, but an educational registry-router. We have application services that orchestrate a use case, and they should not know which specific notification sender class was chosen. They should only know the “channel” and the “message”.
Suppose that in OrderPlacementService we want to send a notification to the channel that came from a command or was selected by a simple rule after the order is created. Right now it does not matter where the channel came from; what matters is that the choice of the NotificationSender implementation is not spread across the code.
Mini-fragment:
package com.example.contextflow.application.service;
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final NotificationDispatchService notificationDispatchService;
public OrderPlacementService(NotificationDispatchService notificationDispatchService) {
// The service does not know about the concrete sender implementations — only about the dispatcher
this.notificationDispatchService = notificationDispatchService;
}
public void placeOrder() {
// Business logic chooses the channel, not the specific sender class
notificationDispatchService.send(NotificationChannel.SMS, "Order created");
// SMS: Order created
}
}
This gives us a neat picture: OrderPlacementService does not know about SmsNotificationSender as a class, does not create it with new, and does not keep if (channel == ...). It simply says: “send a notification to this channel.” And routing lives in one place — in NotificationDispatchService.
And notice how nicely the DI model works. We do not drag a bunch of @Qualifier strings throughout the system if selection depends on a key. We do not build “local democracy” in every service. We simply injected a map that the container assembled for us, and turned it into one small “dispatcher”.
7. Typical mistakes in collection injection
Collection injection looks so convenient that your hand naturally reaches for “let us do this everywhere”. And that is where the classic mistakes begin, which I have seen many times — and a couple of times I made them myself, purely for science, of course.
Mistake No. 1: inject a collection when you actually need one object.
A beginner sees NoUniqueBeanDefinitionException, gets upset, and decides: “I will inject List<NotificationSender> and call get(0).” Formally, the container will stop complaining, but architecturally you will hide the choice of implementation where nobody expects it: in the list index. That is almost guaranteed to cause bugs, because “the first element” is not a contract. If you need one standard option, it is better to solve it with @Primary/@Fallback or an explicit @Qualifier, not with secret arithmetic by index.
Mistake No. 2: build business logic on the order of elements in List.
Even if today your list “happens” to come in a pleasant order, that is not a reason to conclude “so it will always be this way”. The order depends on bean registration, on the configuration style, and on a number of details that, at this stage of the course, we deliberately do not turn into a separate topic. If order matters, it should be expressed as an explicit contract, not as hope.
Mistake No. 3: treat Map<String, T> keys as arbitrary strings.
In Map<String, NotificationSender>, the keys are bean names. If you tie yourself to these strings once, then renaming a class or a @Bean method becomes a program behavior change. That is not “bad”, but it must be conscious: the bean name becomes a public contract between wiring and code. Therefore, names should be readable, stable, and not like “sender1”, “sender2”, “well, this is temporary”.
Mistake No. 4: try to use @Primary to control the contents of a collection.
@Primary and @Fallback help when the container chooses one bean. But if you ask for List<T> or Map<String, T>, the container gives you the whole set. And that is correct: otherwise the meaning of collection injection would be broken. So do not expect that “the primary one will get into the list and the rest will disappear”. They will not disappear — and they should not.
Mistake No. 5: turn Map<String, T> into a service locator inside a business method.
Sometimes developers do this: they inject Map<String, NotificationSender> into a business service and start pulling the needed bean by string right inside each use case. That starts to smell like the very “service locator” approach that Spring is supposed to save us from. It is much better to keep the map in one place (in a router/dispatcher) and keep business services dependent on a simple, clear API: send(channel, message).
GO TO FULL VERSION