1. Идея читаемого wiring
Когда начинаешь работать со Spring, легко попасть в ловушку: кажется, что контейнер — это умная коробка, которая сама разрулит любую неоднозначность, а значит можно не думать о читаемости wiring. Но через пару недель (или через пару одногруппников, которые попробуют понять ваш код) выясняется, что «умная коробка» не заменяет архитектурные договорённости: где у нас default? где намеренный выбор? где реально нужен весь набор реализаций?
В ContextFlow это особенно хорошо видно на примере NotificationSender и DiscountPolicy. Пока у нас была одна реализация интерфейса, конструктор сервиса выглядел невинно: «мне нужен NotificationSender». Но как только появляются EmailNotificationSender, SmsNotificationSender, ConsoleNotificationSender, контейнер перестаёт угадывать — и правильно делает. И вот тут начинается взрослая часть: не просто «починить ошибку NoUniqueBeanDefinitionException», а сделать так, чтобы чтение конструктора говорило правду о намерениях класса.
Полезная мысль на сегодня звучит так: wiring — это тоже часть дизайна. Он должен читаться так же легко, как хороший метод calculateTotal(). Если wiring превращается в «магический набор аннотаций, который работает, но непонятно почему» — вы снова на пути к ручному new, только теперь с заклинаниями.
2. Модели зависимости: default, explicit, реестр
В Spring, когда у вас несколько реализаций интерфейса, на самом деле есть всего три здравых «формы запроса» к контейнеру. И эти формы можно буквально увидеть по сигнатуре конструктора. Это очень удобно: открываете класс — и уже по конструктору понимаете, какой контракт зависимости у этого класса, без чтения тела методов и без гадания «а что там контейнер выбрал?».
Давайте зафиксируем эти три модели в табличке, потому что мозгу новичка так проще (а мозгу преподавателя — спокойнее):
| Что хочет класс | Как выглядит зависимость | Чем решаем в Spring | Что это означает по смыслу |
|---|---|---|---|
| «Мне нужен один обычный вариант» | NotificationSender sender | @Primary / @Fallback (или сделать кандидата уникальным) | Класс доверяет контейнеру выбрать «стандарт» |
| «Мне нужен именно вот этот вариант» | @Qualifier("smsNotificationSender") NotificationSender sender | @Qualifier в точке внедрения | Выбор важен и должен быть виден в коде |
| «Мне нужен весь набор, я сам выберу» | Map<String, NotificationSender> senders | Collection injection (List/Set/Map) | Класс — маршрутизатор/каталог/агрегатор стратегий |
И теперь главное: эти модели можно смешивать в одном приложении (и нужно!). У вас вполне может быть один сервис, который пользуется «дефолтным» DiscountPolicy, другой сервис, которому нужен конкретный NoDiscountPolicy для расчёта «чистой цены», и третий, который хранит List<DiscountPolicy> как каталог доступных политик.
Чтобы это не превратилось в кашу, полезно держать в голове простое правило: если выбор повторяется в десяти местах, это уже не выбор — это default. Если выбор важен только в одном месте — делайте его явным через @Qualifier. Если сервис реально работает с несколькими реализациями, не притворяйтесь, что вам «нужен один bean»: просите коллекцию и называйте вещи своими именами.
Ниже мы соберём эту идею в одну «читаемую схему» на примерах ContextFlow.
3. Default-choice: @Primary и @Fallback
Когда классу честно нужен один обычный вариант, постоянные qualifier’ы только шумят. В ContextFlow default-choice закрывает две разные ситуации: у NotificationSender есть привычный канал по умолчанию, а у DiscountPolicy есть запасной NoDiscountPolicy, который не должен мешать более осмысленной политике.
import org.springframework.context.annotation.Fallback;
import org.springframework.stereotype.Component;
@Component("noDiscountPolicy")
@Fallback // Запасной вариант: используется только если нет других DiscountPolicy
public class NoDiscountPolicy implements DiscountPolicy {
// Базовая стратегия: скидки нет, сумма не меняется
public int apply(int total) { return total; }
}
На стороне NotificationSender та же идея выглядит как один @Primary-канал по умолчанию: сервисам не приходится повторять один и тот же выбор снова и снова.
А конструктор сервиса при этом остаётся честно простым:
import org.springframework.stereotype.Service;
@Service
public class OrderPricingService {
private final DiscountPolicy discountPolicy;
public OrderPricingService(DiscountPolicy discountPolicy) {
// Класс просит один DiscountPolicy и доверяет контейнеру обычный выбор
this.discountPolicy = discountPolicy;
}
}
Такой конструктор читается нормально только если у типа есть ясная договорённость: один @Primary, один обычный кандидат поверх fallback-резерва или вообще единственная реализация. Здесь default-choice нужен не ради “магии”, а чтобы не тащить повторяющийся выбор в каждый сервис.
4. Explicit choice: @Qualifier
Как только default перестаёт отражать смысл класса, выбор должен стать явным. Здесь @Qualifier уже не про “починить контейнер”, а про то, чтобы конструктор сам говорил правду.
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class EmergencyNotificationService {
private final NotificationSender sender;
public EmergencyNotificationService(
@Qualifier("smsNotificationSender") NotificationSender sender) {
// Этот сервис по смыслу работает именно с SMS-каналом
this.sender = sender;
}
}
Такой код читается без догадок: сервис не просто “что-то отправляет”, а работает с конкретным каналом. Если же выбор относится не к смыслу сервиса, а к wiring-решению, тот же @Qualifier можно поставить на параметр @Bean-метода и оставить сам класс нейтральным. Главное различие простое: default скрывает рутину, qualifier показывает намеренный выбор.
5. Реестр стратегий: Map<String, T>
До этого NotificationDispatchService был удобен как учебный one-sender пример: на нём легко было увидеть, где контейнер спотыкается о несколько кандидатов. Как только выбор канала становится реальной задачей приложения, такой сервис уже лучше читать как маленький маршрутизатор.
Map<String, NotificationSender> здесь полезен именно как реестр стратегий. В ранней механической демонстрации можно было вычислять имя bean-а по конвенции. Для читаемого wiring лучше держать выбор канала явным в одном месте, а не надеяться, что строковая формула угадывается сама.
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final Map<String, NotificationSender> senders;
public NotificationDispatchService(Map<String, NotificationSender> senders) {
// Spring сам соберёт Map: ключ = имя bean-а, значение = реализация интерфейса
this.senders = senders;
}
public void send(NotificationChannel channel, String message) {
// Выбор канала выражен явно и живёт в одном месте
String beanName = switch (channel) {
case EMAIL -> "emailNotificationSender";
case SMS -> "smsNotificationSender";
case CONSOLE -> "consoleNotificationSender";
};
NotificationSender sender = senders.get(beanName);
if (sender == null) {
throw new IllegalArgumentException("Unknown channel: " + channel);
}
sender.send(message);
}
}
В таком виде NotificationDispatchService и становится нормальным рабочим вариантом для ContextFlow: остальные сервисы знают только про канал и сообщение, а детали выбора sender-а живут в одном месте.
6. Схема ContextFlow: default + explicit + map
Теперь картина не расползается: один и тот же проект использует все три режима wiring, но каждый — на своём месте.
- OrderPricingService просит один DiscountPolicy и получает «основной» вариант, а NoDiscountPolicy остаётся fallback.
- EmergencyNotificationService просит конкретный NotificationSender через @Qualifier, потому что смысл сервиса — SMS.
- NotificationDispatchService просит все NotificationSender, потому что он маршрутизатор.
Если изобразить это как граф, получится очень понятная картинка:
flowchart TD
OPS[OrderPricingService] --> DP[DiscountPolicy]
DP --> LDP[LoyalCustomerDiscountPolicy]
DP --> NDP["NoDiscountPolicy
@Fallback"]
ENS[EmergencyNotificationService] --> SMS["SmsNotificationSender
@Qualifier"]
NDS[NotificationDispatchService] --> MAP["Map<String, NotificationSender>"]
MAP --> CNS["ConsoleNotificationSender
@Primary"]
MAP --> EMS[EmailNotificationSender]
MAP --> SMS
Теперь покажем маленький кусок use-case сервиса, который этим пользуется. Пусть OrderPlacementService не выбирает реализацию sender’а напрямую — он просто вызывает dispatch по каналу.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final NotificationDispatchService notificationDispatchService;
public OrderPlacementService(NotificationDispatchService notificationDispatchService) {
// Сервис не знает, какие реализации sender-ов есть внутри — он делегирует выбор маршрутизатору
this.notificationDispatchService = notificationDispatchService;
}
}
И где-то внутри сценария:
notificationDispatchService.send(NotificationChannel.CONSOLE, "Order created!")
CONSOLE: Order created!
С точки зрения читателя кода это выглядит очень честно. OrderPlacementService не знает про @Primary, не знает про имена bean-ов, не содержит new SmsNotificationSender(). Он просто использует сервис по его назначению. А вся «хитрость» выбора аккуратно живёт там, где ей и место: в wiring/маршрутизации.
Если вы поймаете себя на мысли, что хотите сделать так:
// Анти-паттерн: ручной выбор реализации в бизнес-коде вместо того, чтобы отдать это контейнеру/маршрутизатору
if (channel == NotificationChannel.SMS) {
new SmsNotificationSender().send(message);
} else {
new EmailNotificationSender().send(message);
}
то можно спокойно выдохнуть и сказать: «Ага, мы снова пытаемся откатиться в Дни 1–2». И вернуться к контейнеру.
7. Типичные ошибки в wiring
Ошибка №1: “Пофиксил неоднозначность через new внутри сервиса — и ладно”.
Это выглядит как быстрый ремонт, но на деле вы выносите решение из контейнера обратно в бизнес-код и создаёте скрытые зависимости. Через неделю вы захотите заменить SMS-отправителя на другой, и окажется, что нужно искать по проекту все new SmsNotificationSender(). Это тот же manual wiring, только в профиль.
Ошибка №2: “Давайте внедрим List<T>, а default возьмём как list.get(0)”.
Такой код иногда «случайно работает», пока порядок кандидатов не поменялся, пока не добавили новую реализацию, пока не обновили зависимости. Но это плохой контракт: вы делаете вид, что List — это «один bean», а не набор. Если нужен default, он должен быть объявлен через @Primary, а не через «кто первым прибежал, тот и прав».
Ошибка №3: “Я всё контролирую, поэтому @Qualifier поставлю везде”.
Обычно так начинается “qualifier-spam”: половина конструкторов превращается в повторяющиеся строки "consoleNotificationSender", "consoleNotificationSender", "consoleNotificationSender". Это не контроль, это шум. Если один и тот же выбор повторяется массово, это сигнал сделать default (@Primary) или вынести выбор в один специализированный сервис (например, маршрутизатор).
Ошибка №4: “Bean name — случайность, назову как-нибудь потом”.
Как только вы начинаете использовать Map<String, T>, имя bean-а перестаёт быть декоративной деталью. Оно становится ключом в вашей «таблице стратегий». Если вы называете бины абы как (или оставляете непонятные имена после рефакторинга), вы сами превращаете маршрутизацию в ребус. Имена вроде smsNotificationSender и loyalCustomerDiscountPolicy — это не «много букв», это документация.
Ошибка №5: “Маршрутизатор везде”: внедряем Map<String, NotificationSender> в каждый сервис.
Иногда после знакомства с Map хочется сделать так, чтобы каждый сервис сам выбирал реализацию по ключу. Это снова размазывает выбор по приложению. Гораздо чище, когда Map живёт в одном месте — в NotificationDispatchService — а остальные сервисы просто вызывают его, не зная деталей контейнерной кухни.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ