1. Коли одного bean мало: набір стратегій
Поки сервіс просить один bean, уся розмова крутиться навколо вибору одного кандидата. Але іноді вибір одного — це штучне обмеження. Уявіть, що у вас є кілька способів надіслати сповіщення (SMS, email, консоль), і раптом зʼявляється вимога: «у разі критичної помилки — надіслати в усі канали одразу». Тут вибір одного bean не просто незручний — він суперечить змісту задачі.
У такі моменти дуже важливо не зробити «крок назад» і не почати знову писати if ("sms") new Sms.... Ми вже домовилися, що залежності мають приходити в клас через конструктор, а звʼязування — жити зовні. Тому, коли бізнес-зміст вимагає набору реалізацій, ми маємо навчити контейнер: «дай мені не одного NotificationSender, а всіх відправників».
На рівні Spring це виглядає як зміна типу залежності. І різниця тут контрактна, а не просто синтаксична.
| Що написано в конструкторі | Що це означає для контейнера | Типова реакція Spring |
|---|---|---|
| NotificationSender sender | «Мені потрібен один конкретний обʼєкт» | Потрібна однозначність (або @Primary, @Qualifier) |
| List<NotificationSender> senders | «Мені потрібен повний набір реалізацій» | Spring збирає всі відповідні beans у список |
| Set<NotificationSender> senders | «Мені потрібен набір реалізацій без привʼязки до порядку» | Spring збирає всі відповідні beans у множину |
| Map<String, NotificationSender> senders | «Мені потрібен реєстр: імʼя bean → реалізація» | Spring збирає map за іменами beans |
Якщо в попередніх лекціях неоднозначність була помилкою звʼязування, то тут неоднозначність — це мета: ми спеціально хочемо отримати багато кандидатів.
2. Колекції List<T> і Set<T> в autowiring
Перш ніж писати код, корисно зупинитися на одній простій речі: Spring не «читає думки», він читає тип залежності. І коли ви пишете List<NotificationSender>, ви насправді ставите контейнеру дуже зрозуміле завдання: «знайди всі beans, які підходять за типом NotificationSender, і запакуй їх у колекцію».
Тут відіграють роль generics: List<NotificationSender> — це не те саме, що List<Object>. Spring дивиться на параметризацію і шукає кандидатів саме під NotificationSender. Тому колекційна інʼєкція — це не «магічний режим контейнера», а такий самий нормальний autowiring, тільки з іншим контрактом результату.
Є й приємний практичний нюанс, який новачків часто дивує — у хорошому сенсі. Якщо ви впроваджуєте один bean типу NotificationSender, а кандидатів немає, контейнер падає з помилкою, тому що це обовʼязкова залежність. А ось якщо ви впроваджуєте List<NotificationSender>, і кандидатів немає, Spring, як правило, дає порожню колекцію. Це логічно: «дай мені всіх» цілком може означати «всіх немає».
Тобто колекційна інʼєкція одночасно розвʼязує дві задачі: і «зібрати набір стратегій», і зробити його безпечним за формою, бо колекція є завжди. Але бізнес-зміст порожнього набору — це вже ваша відповідальність: іноді порожній список допустимий, а іноді він має приводити до зрозумілої помилки.
Щоб було простіше тримати картину в голові, можна уявити собі таку схему:
flowchart TD
A["Точка інʼєкції: NotificationSender"] --> B{"Скільки кандидатів за типом?"}
B -->|0| E["Помилка: немає bean"]
B -->|1| C["Інʼєкція цього bean"]
B -->|>1| D["Потрібне правило вибору (@Primary / @Qualifier)"]
A2["Точка інʼєкції: List<NotificationSender>"] --> F["Зібрати всіх кандидатів"]
F --> G["Інʼєкція колекції (може бути порожньою)"]
І ось тепер ми готові до практики: давайте навчимося отримувати всі реалізації NotificationSender і DiscountPolicy у нашому ContextFlow.
3. List<NotificationSender>: broadcast-рівень
Уявімо задачу, яка в реальних проєктах виникає підозріло часто: нам потрібно надіслати одне й те саме повідомлення в усі канали. У домені ContextFlow це може бути, наприклад, «критичне сповіщення» або «діагностичне повідомлення під час запуску сценарію». Ми не робимо з цього окрему архітектурну релігію — просто використовуємо як зрозумілий приклад, де список реалізацій має природний сенс.
Для початку згадаємо — або заведемо, якщо у вас поки було інакше, — наш контракт надсилання сповіщень. Нехай він живе в domain.ports, щоб application-шар залежав від інтерфейсу, а реалізації були в infrastructure.
package com.example.contextflow.domain.ports;
public interface NotificationSender {
// Контракт порту: будь-яка реалізація має вміти надіслати повідомлення
void send(String message);
}
Тепер припустімо, що у нас уже є кілька реалізацій, зареєстрованих через component scanning. Тут я покажу одну, інші виглядають аналогічно.
package com.example.contextflow.infrastructure.notification;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Component;
@Component // Spring сам підхопить цю реалізацію як bean
class ConsoleNotificationSender implements NotificationSender {
public void send(String message) {
// Найпростіша реалізація: просто друкуємо повідомлення в консоль
System.out.println("CONSOLE: " + message); // CONSOLE: Замовлення створено
}
}
А тепер створимо сервіс, який отримує весь список відправників і робить «розсилку всім одразу». Зверніть увагу: ми не просимо «один NotificationSender», ми просимо саме List<NotificationSender> — і цим знімаємо проблему неоднозначності, тому що неоднозначність тепер бажана.
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) {
// Впроваджується список усіх NotificationSender із контексту
this.senders = senders;
}
public void broadcast(String message) {
// «Розсилка всім»: викликаємо send у кожного відправника
senders.forEach(s -> s.send(message));
}
}
З погляду Spring-контейнера тут усе доволі буквально. Він знаходить усі beans, які є NotificationSender, збирає їх у список і впроваджує в конструктор. Жодних @Primary, жодного @Qualifier — тому що ми не просимо вибрати одного переможця.
Щоб побачити, що це справді працює, зручно додати маленький діагностичний виклик у наш ScenarioRunner — або в будь-який інший runner, який ви вже використовуєте як консольну точку входу. Це не «новий шар», а просто демонстрація в межах навчального проєкту.
package com.example.contextflow.application.scenario;
import com.example.contextflow.application.service.NotificationBroadcastService;
import org.springframework.stereotype.Component;
@Component // Демонстраційний компонент: показує, що broadcast справді надсилає в усі канали
class ScenarioRunner {
private final NotificationBroadcastService broadcastService;
ScenarioRunner(NotificationBroadcastService broadcastService) {
// DI через конструктор — без ручного new
this.broadcastService = broadcastService;
}
void run() {
broadcastService.broadcast("Замовлення створено");
// EMAIL: Замовлення створено
// SMS: Замовлення створено
// CONSOLE: Замовлення створено
}
}
Тут важливо вловити правильну «архітектурну мораль». Ми не «обійшли» контейнер. Ми просто задали інший сенс залежності: сервісу потрібен не один відправник, а набір відправників. Це нормальна частина DI-мислення: контракт залежності виражається в типі.
4. Set<DiscountPolicy>: множина без порядку
Тепер давайте подивимося на дуже схожу механіку, але з іншою семантикою: Set<T>. На рівні Spring Set означає майже те саме, що List: контейнер знайде всі кандидати за типом і збере їх у колекцію. Різниця тут радше для нас, людей, а не для контейнера: Set говорить читачеві коду «не думай про порядок, думай про множину».
Для знижок це особливо добре підходить, тому що «набір доступних політик» зазвичай не має сенсу у вигляді впорядкованого списку. Якщо ми почнемо таємно залежати від позиції елемента, вийде комедія: «знижка працює тільки тому, що цей bean випадково опинився першим». Комедія, до речі, буде короткою і трагічною.
Для прикладу візьмемо інтерфейс DiscountPolicy.
package com.example.contextflow.domain.ports;
public interface DiscountPolicy {
// Контракт: на вхід загальна сума, на вихід — сума (або розмір знижки) за політикою
int apply(int total);
}
І заведемо простий діагностичний сервіс, який отримує всі політики знижок як Set<DiscountPolicy> і, наприклад, виводить їхню кількість та імена класів. Це корисно в навчальному проєкті: ми вчимося бачити, що контейнер справді зібрав.
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 підкреслює: порядок нам не важливий, важливий сам набір політик
this.policies = policies;
}
public void printAvailablePolicies() {
// Діагностика: скільки політик зареєстровано в контексті
System.out.println("Політики знижок: " + policies.size()); // Політики знижок: 2
// Виводимо класи для наочності (порядок виведення не гарантується)
policies.forEach(p -> System.out.println(p.getClass().getSimpleName()));
}
}
Якщо в контейнері є NoDiscountPolicy і LoyalCustomerDiscountPolicy, ви побачите щось на кшталт:
Політики знижок: 2
NoDiscountPolicy
LoyalCustomerDiscountPolicy
Зверніть увагу: ми не обіцяємо порядок виведення. І це добре. На цьому курсі ми свідомо не будуємо логіку, яка залежить від порядку колекції, тому що це окрема тема — і окреме джерело «чому у мене на машині працює, а на CI — ні?».
Головне, що потрібно винести з цього розділу: Set<T> — це такий самий «дай мені всіх», як і List<T>, але з чеснішою підказкою читачеві: «порядок неважливий, важливий набір».
5. Інʼєкція Map<String, NotificationSender>: реєстр за іменем bean
Найцікавіший і трохи хитріший варіант колекційної інʼєкції — це Map<String, T>. Він корисний, коли вам потрібен не просто «набір реалізацій», а можливість вибрати потрібну реалізацію за ключем. У Spring цей ключ за замовчуванням — імʼя bean.
Тут важливо одразу зафіксувати, щоб не ловити «магічних» очікувань: автоматичне складання карти працює саме для Map<String, T>. Тобто ключі — рядки, і ці рядки — імена beans. Якщо ви спробуєте попросити Map<NotificationChannel, NotificationSender>, Spring не «вгадає», як це зібрати, тому що NotificationChannel — не bean name. Для такого випадку ви зазвичай створюєте карту самі — наприклад, через @Bean, — але сьогодні ми залишаємося в межах механіки, яку Spring дає «з коробки».
Для початку просто подивімося на механіку Map<String, T> у максимально прямолінійному вигляді. Такий реєстр корисний не як остаточний стиль, а як чиста демонстрація того, що ключами справді стають імена beans.
Давайте спочатку просто подивимося на сам факт: Spring може впровадити Map<String, NotificationSender>, і ключами будуть bean names. Для цього заведемо невеликий сервіс-налагоджувач.
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 — це імена beans (наприклад, "emailNotificationSender")
this.senders = senders;
}
public void printSenderKeys() {
// Зручна перевірка: які саме імена beans потрапили в карту
System.out.println("Відправники: " + senders.keySet());
// Відправники: [emailNotificationSender, smsNotificationSender, consoleNotificationSender]
}
}
Чому ключі саме такі? Тому що за замовчуванням імʼя bean для компонента — це імʼя класу з малої першої літери. Тобто EmailNotificationSender перетворюється на emailNotificationSender. І це відразу робить іменування не «косметикою», а частиною контракту, якщо ви починаєте маршрутизувати за ключем.
Тепер давайте використаємо карту за призначенням: побудуємо сервіс, який вибирає відправника за іменем. Для демонстрації зробімо просте надсилання «за каналом» (NotificationChannel) через угоду про імена.
Спочатку доменна модель каналу: вона вже є в проєкті за ТЗ, але тут покажу мінімальний варіант.
package com.example.contextflow.domain.model;
public enum NotificationChannel {
// Доменні канали, які обирає бізнес-логіка, а не Spring
EMAIL, SMS, CONSOLE
}
А тепер сервіс маршрутизації. Зверніть увагу на ідею: ми беремо NotificationChannel, перетворюємо його на імʼя bean за конвенцією (emailNotificationSender, smsNotificationSender, consoleNotificationSender), беремо реалізацію з map і надсилаємо повідомлення.
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) {
// Впроваджуємо «реєстр відправників»: імʼя bean -> реалізація
this.senders = senders;
}
public void send(NotificationChannel channel, String message) {
// Конвенція: EMAIL -> "emailNotificationSender", SMS -> "smsNotificationSender" тощо
String beanName = channel.name().toLowerCase() + "NotificationSender";
// Дістаємо реалізацію з реєстру за іменем bean
NotificationSender sender = senders.get(beanName);
// У навчальному прикладі припускаємо, що імʼя знайдено (нижче в тексті показано варіант із перевіркою)
sender.send(message); // наприклад: SMS: Замовлення скасовано
}
}
Так, тут є ризик: якщо ви перейменуєте bean або клас, конвенція зламається. Але в межах лекції це хороший навчальний компроміс, тому що він дозволяє наочно звʼязати Map<String, T> з bean names і відчути, що «імʼя bean» — це справді частина контейнерної моделі, а не випадковий напис.
Якщо вам хочеться зробити це трохи безпечніше — і ви вже починаєте думати як інженер, вітаю, це прогрес, — можна додати перевірку на null і кидати зрозумілу помилку. У короткому навчальному коді можна хоча б так:
// Захист від ситуації, коли конвенція не збіглася з реальними іменами bean
NotificationSender sender = senders.get(beanName);
if (sender == null) {
throw new IllegalArgumentException("Unknown channel: " + channel);
}
sender.send(message);
Головна ідея не в тому, щоб зробити суперроутер, а в тому, щоб побачити: Map<String, T> — це «вбудований registry», який контейнер збирає для вас, і це часто набагато краще, ніж тягнути ApplicationContext всередину бізнес-коду й вручну шукати beans.
Це навмисно механічний варіант маршрутизації. Він добре показує звʼязок Map<String, T> з bean names, але повсякденне звʼязування зазвичай виграє, коли вибір каналу тримається більш явно в одному місці, а не обчислюється зі строкової конвенції на льоту.
6. Інкремент ContextFlow: диспетчер сповіщень
Щоб це не залишилося «красивим прикладом на дошці», давайте підключимо наш NotificationDispatchService туди, де він звучить природно: у сценарій обробки замовлення. Тут він уже спеціально грає іншу роль: не «сервіс з одним sender», а навчальний реєстр-роутер. У нас є application-сервіси, які оркеструють use case, і вони не повинні знати, який саме клас відправника сповіщень обрано. Вони повинні знати тільки «канал» і «повідомлення».
Припустімо, що в OrderPlacementService ми хочемо після створення замовлення надіслати сповіщення в канал, який прийшов із команди або вибрався за простим правилом. Зараз нам не важливо, звідки канал узявся; важливо, що вибір реалізації NotificationSender не розмазаний по коду.
Мініфрагмент:
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) {
// Сервіс не знає про конкретні реалізації відправників — тільки про диспетчер
this.notificationDispatchService = notificationDispatchService;
}
public void placeOrder() {
// Бізнес-логіка вибирає канал, а не конкретний клас відправника
notificationDispatchService.send(NotificationChannel.SMS, "Замовлення створено");
// SMS: Замовлення створено
}
}
Тут виходить акуратна картинка: OrderPlacementService не знає про SmsNotificationSender як клас, не створює його через new, не тримає if (channel == ...). Він просто говорить: «надішли сповіщення ось у цей канал». А маршрутизація живе в одному місці — у NotificationDispatchService.
І зверніть увагу, як красиво працює DI-модель. Ми не тягнемо по всій системі купу рядків @Qualifier, якщо вибір залежить від ключа. Ми не влаштовуємо «локальну демократію» в кожному сервісі. Ми просто впровадили map, який контейнер зібрав нам сам, і зробили з цього один маленький «диспетчер».
7. Типові помилки під час колекційної інʼєкції
Колекційна інʼєкція виглядає настільки зручною, що рука сама тягнеться «всюди так зробити». І ось тут починаються класичні помилки, які я бачив багато разів, і кілька разів робив сам — виключно заради науки, звісно.
Помилка №1: впроваджувати колекцію, коли за змістом потрібен один обʼєкт.
Новачок бачить NoUniqueBeanDefinitionException, засмучується і вирішує: «а впроваджу-но я List<NotificationSender> і візьму get(0)». Формально контейнер перестане сваритися, але архітектурно ви сховаєте вибір реалізації туди, де його ніхто не очікує: в індекс списку. Це майже гарантовано призведе до багів, тому що «перший елемент» — це не контракт. Якщо потрібен один стандартний варіант, краще вирішувати це @Primary/@Fallback або явним @Qualifier, а не таємною арифметикою за індексом.
Помилка №2: будувати бізнес-логіку на порядку елементів у List.
Навіть якщо сьогодні у вас список «випадково» приходить у приємному порядку, це не привід робити висновок «значить, так буде завжди». Порядок залежить від реєстрації beans, від способу конфігурації та від низки деталей, які на цьому етапі курсу ми свідомо не перетворюємо на окрему тему. Якщо порядок важливий, це має бути оформлено як явний контракт, а не як надія.
Помилка №3: ставитися до ключів Map<String, T> як до довільних рядків.
У Map<String, NotificationSender> ключі — це bean names. Якщо ви одного разу завʼязалися на ці рядки, то перейменування класу або методу @Bean стає зміною поведінки програми. Це не «погано», але це має бути усвідомлено: імʼя bean перетворюється на публічний контракт між wiring і кодом. Тому імена мають бути читабельними, стабільними і не в стилі «sender1», «sender2», «ну це тимчасово».
Помилка №4: намагатися через @Primary керувати вмістом колекції.
@Primary і @Fallback допомагають, коли контейнер вибирає один bean. Але якщо ви просите List<T> або Map<String, T>, контейнер віддає вам увесь набір. І це правильно: інакше зміст колекційної інʼєкції був би зламаний. Тому не очікуйте, що «primary потрапить у список, а решта зникнуть». Вони не зникнуть — і не повинні зникати.
Помилка №5: перетворювати Map<String, T> на service locator всередині бізнес-методу.
Іноді розробники роблять так: впроваджують Map<String, NotificationSender> у бізнес-сервіс і починають прямо всередині кожного use case діставати потрібний bean за рядком. Це вже пахне тим самим підходом service locator, від якого Spring, по суті, нас рятує. Набагато краще тримати map в одному місці — у маршрутизаторі або диспетчері, — а бізнес-сервіси робити залежними від простого, зрозумілого API: send(channel, message).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ