JavaRush /Курси /Spring Core /Кілька реалізацій інтерфейсу в DI

Кілька реалізацій інтерфейсу в DI

Spring Core
Рівень 8 , Лекція 0
Відкрита

1. Варіативність поведінки та кілька реалізацій

Якщо ви звикли писати невеликі консольні застосунки, то здається логічним: «Є інтерфейс — отже, має бути одна реалізація, інакше щось пішло не так». Але це відчуття швидко минає, коли застосунок починає робити бодай щось схоже на реальну роботу. Варіативність зʼявляється буквально сама: в одному середовищі ви хочете виводити сповіщення в консоль, в іншому — надсилати електронну пошту, у третьому — імітувати SMS, а в тестах — узагалі нічого не надсилати, щоб не засипати спамом самих себе.

У ContextFlow такі точки варіативності закладено свідомо: NotificationSender і DiscountPolicy — це не випадкові інтерфейси «заради ООП», а ролі в застосунку. Сповіщення можна надіслати різними способами, а знижку можна рахувати за різними правилами. І тут з’являється важлива думка дня: один інтерфейс описує роль, а реалізацій цієї ролі може бути кілька. Це не помилка — це мова, якою застосунок каже: «У мене є різні стратегії поведінки».

Уявіть, що інтерфейс — це слово «доставка», а реалізації — «курʼєр», «самовивіз», «дрон» (так, ми живемо в майбутньому і в нас Java 25). Якщо в коді ви описали лише слово «доставка», але в системі реально є три способи доставки, то інтерфейс якраз і потрібен, щоб не тягнути «курʼєра» прямо в бізнес-логіку. Ми хочемо залежати від ролі, а не від конкретної фізики доставки.

2. Інтерфейс у DI як роль

Саме тут DI-friendly дизайн починає працювати по-справжньому. Коли сервіс залежить від інтерфейсу, він ніби говорить: «Мені потрібна здатність надсилати сповіщення», а не «Мені потрібен саме EmailNotificationSender». Це важливо, тому що сервіс зазвичай не повинен знати, як саме виконується надсилання. Інакше бізнес-рівень перетворюється на комбайн, який уміє все — і знижки, і сповіщення, і аудит, і ще трішки ремонтувати принтер.

Ось як виглядає порт (контракт ролі) у нашому проєкті:

/**
 * Порт: бізнес-рівню потрібна "здатність надсилати сповіщення",
 * а не конкретний канал (email/SMS/консоль).
 */
public interface NotificationSender {

    /**
     * Надсилання повідомлення через конкретний канал.
     */
    void send(String message);
}

У цей момент початківець часто робить логічний стрибок: «Раз є інтерфейс, то треба терміново вибрати “правильну” реалізацію і залишити лише її». Але сенс DI якраз у тому, щоб не обирати всередині бізнес-класу. Вибір — це частина складання застосунку (wiring), а не частина поведінки домену.

І тут корисно згадати одну здорову звичку: інтерфейси в такому проєкті не мають зʼявлятися «всюди». Доменні сутності на кшталт Order або Customer не зобовʼязані бути інтерфейсами. А ось «порти» на межі застосунку — дуже навіть. Сповіщення, аудит, знижки, генерація ідентифікаторів — це природні місця, де світ змінюється, а бізнес-сценарій хочеться залишити максимально стабільним і читабельним.

Отже, кілька реалізацій інтерфейсу — це не «ми не змогли домовитися в команді», а «ми чесно визнали, що в ролі є варіанти».

3. Кандидати та точки впровадження в Spring

Тепер подивімося на ситуацію очима контейнера. Spring — не телепат. Він не читає ваші думки, не розуміє, що «SMS важливіші за email» (і слава богу), і не аналізує зміст класів за їхніми назвами. Він працює за дуже конкретною моделлю: є точка впровадження (injection point) і є набір кандидатів (candidates), які підходять за типом.

Точка впровадження — це місце, куди Spring має підставити залежність. У нашому курсі це майже завжди параметр конструктора.

Для початку візьмемо навмисно простий навчальний варіант NotificationDispatchService: він просить один NotificationSender, тому що на такому контракті найкраще видно, де народжується неоднозначність.

import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {
    // Залежність виражено через інтерфейс (роль), а не через конкретну реалізацію.
    private final NotificationSender sender;

    public NotificationDispatchService(NotificationSender sender) {
        // Конструктор — точка впровадження (injection point).
        this.sender = sender;
    }
}

Якщо в контейнері зареєстровано один bean типу NotificationSender, усе зрозуміло: його і впроваджуємо. Але якщо beanʼів кілька, то всі вони стають кандидатами. Кандидат — це не «обраний» bean. Це просто «той, що підходить за типом». Тобто контейнер мислить приблизно так: «О, NotificationSender… у мене є кілька обʼєктів, які вміють це робити».

Наочно це можна уявити як маленьку схему вибору:

flowchart TD
    A["Точка впровадження: NotificationSender"] --> B["Шукаю beans за типом NotificationSender"]
    B --> C{"Скільки знайдено?"}
    C -->|0| D["Помилка: немає кандидата"]
    C -->|1| E["Гаразд: впроваджую єдиний bean"]
    C -->|>1| F["Помилка: неоднозначність (потрібне правило вибору)"]

Найважливіше: неоднозначність зʼявляється саме в момент впровадження, коли конкретний конструктор просить один обʼєкт. Поки у вас просто зареєстровані класи, це не проблема. Проблема виникає тоді, коли хтось каже: «Дай мені один NotificationSender» — і контейнеру треба виконати цей запит однозначно.

4. Неоднозначність як корисна помилка

На цьому місці часто хочеться обуритися: «Ну обери будь-який! Яка тобі різниця, Spring?» І тут у контейнера позиція, за яку його варто поважати: випадковий вибір — це прихований баг. Сьогодні контейнер вибрав «перший-ліпший» і все працює. Завтра ви додали новий bean, змінився порядок сканування чи реєстрації, і раптом усе теж «працює», але вже інакше. Це один із найнеприємніших класів проблем: поведінка змінилася, а компілятор мовчить, тести цього не покривають, і ви потім розслідуєте все як детектив.

Тому Spring робить іноді неприємну, але правильну річ — падає на старті й говорить: «Я знайшов кілька кандидатів, а ти просиш один. Визначтеся».

Давайте створимо дві реалізації — як у реальному житті: для електронної пошти та SMS. Вони обидві будуть звичайними компонентами:

import org.springframework.stereotype.Component;

@Component("emailNotificationSender") // Явно фіксуємо імʼя beanʼа (воно потім може стати ключем у Map<String, ...>).
public class EmailNotificationSender implements NotificationSender {
    public void send(String message) {
        // Демонстраційна реалізація: тут міг би бути реальний клієнт електронної пошти.
        System.out.println("ЕЛ. ПОШТА: " + message); // ЕЛ. ПОШТА: ...
    }
}
import org.springframework.stereotype.Component;

@Component("smsNotificationSender") // Окремий bean: інший канал сповіщень.
public class SmsNotificationSender implements NotificationSender {
    public void send(String message) {
        // Демонстраційна реалізація: тут міг би бути реальний SMS-провайдер.
        System.out.println("СМС: " + message); // СМС: ...
    }
}

Тепер у нас у контейнері два кандидати типу NotificationSender. А NotificationDispatchService і далі просить один:

import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {
    public NotificationDispatchService(NotificationSender sender) {
        // Точка впровадження за типом NotificationSender (а кандидатів тепер два).
        // ...
    }
}

Під час старту контексту ви побачите помилку приблизно такого змісту (текст може трохи відрізнятися, але ідея буде та сама):

NoUniqueBeanDefinitionException: expected single matching bean but found 2:
emailNotificationSender, smsNotificationSender

І це не «Spring вередує». Це контейнер захищає вас від ситуації, коли поведінка системи визначається не вашим рішенням, а удачею і порядком завантаження класів. Spring змушує зробити wiring явним.

При цьому важливо правильно сформулювати діагноз: неоднозначність — це не «поганий інтерфейс» і не «погана ідея мати дві реалізації». Це недомовленість у wiring: ми створили кілька реалізацій ролі, але не сказали контейнеру, яка з них має використовуватися там, де потрібен один обʼєкт.

5. Кілька beans: @Component і @Bean

Іноді здається: «Ну це все через component scanning. Якби ми все робили через @Bean, було б акуратніше». На жаль або на щастя — контейнеру байдуже, звідки прийшов bean. Щойно обʼєкт зареєстровано, він стає учасником однієї й тієї самої моделі кандидатів.

Ось приклад зі знижками: припустімо, ми хочемо мати «звичайну» політику без знижки і політику для лояльного клієнта. Це ідеальна точка варіативності для ContextFlow.

Інтерфейс:

/**
 * Порт для бізнес-правила розрахунку знижки.
 * Різні реалізації = різні стратегії.
 */
public interface DiscountPolicy {
    int apply(int total);
}

І конфігурація через @Bean:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class PricingConfig {

    @Bean
    public DiscountPolicy noDiscountPolicy() {
        // Стратегія "без знижки": повертаємо суму як є.
        return total -> total;
    }
}

Додамо другу реалізацію тим самим способом:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LoyaltyPricingConfig {

    @Bean
    public DiscountPolicy loyalCustomerDiscountPolicy() {
        // Демонстраційна стратегія: фіксована знижка.
        return total -> total - 10;
    }
}

Тепер у контейнера знову два кандидати DiscountPolicy. І якщо сервіс попросить один:

import org.springframework.stereotype.Service;

@Service
public class OrderPricingService {
    public OrderPricingService(DiscountPolicy discountPolicy) {
        // discountPolicy — точка впровадження; за двох beans буде неоднозначність.
        // ...
    }
}

то результат буде тим самим — неоднозначність. Отже, «винен» не scanning і не @Bean. Винний сам факт: для одного типу є кілька beans, а точка впровадження очікує один.

І це добра новина: модель єдина й передбачувана. Щойно ви її зрозуміли, ви більше не плутаєтеся, чому «тут помилка», а «там чомусь нормально».

Саме звідси далі й розходяться три чесні варіанти wiring: можна вибрати одного звичайного кандидата, можна явно вказати потрібний або можна визнати, що сервісу потрібен не один обʼєкт, а весь набір реалізацій.

6. Контракт на колекцію реалізацій

На цьому місці корисно побачити важливу межу: помилка є лише тоді, коли injection point просить один обʼєкт, а кандидатів кілька. Якщо ж сервісу за змістом потрібен не один переможець, а набір стратегій, контракт має чесно це сказати — через List<T>, Set<T> або Map<String, T>.

Тоді контейнер розвʼязує вже інше завдання: не вибрати одного, а зібрати всіх підхожих кандидатів в одну структуру. Тут поки достатньо зафіксувати сам принцип: кілька реалізацій інтерфейсу не завжди означають конфлікт. Іноді конфлікт зникає просто тому, що ви поставили контейнеру правильне запитання.

7. ContextFlow: нові канали та неоднозначність

Зараз зробимо маленький, але дуже показовий крок у ContextFlow. До цього моменту у вас могла бути одна реалізація NotificationSender — наприклад, консольна. Ви додаєте другий канал — і раптом застосунок перестає стартувати. Це чудовий навчальний момент: контейнер показує межу між «у мене є варіативність» і «я домовився з wiring».

Почнемо з порту в domain.ports:

/**
 * Порт на межі домену: "як надсилати сповіщення" можна змінювати.
 */
public interface NotificationSender {
    void send(String message);
}

Додамо дві реалізації в infrastructure.notification (назви beans задамо явно, щоб не було сюрпризів):

import org.springframework.stereotype.Component;

@Component("consoleNotificationSender") // Явно задаємо імʼя, щоб воно було передбачуваним.
public class ConsoleNotificationSender implements NotificationSender {
    public void send(String message) {
        // Канал "консоль": зручний для розроблення або локального запуску.
        System.out.println("КОНСОЛЬ: " + message); // КОНСОЛЬ: ...
    }
}
import org.springframework.stereotype.Component;

@Component("emailNotificationSender") // Інший канал: електронна пошта.
public class EmailNotificationSender implements NotificationSender {
    public void send(String message) {
        // Тут могла б бути інтеграція з SMTP або провайдером, але для курсу достатньо виведення.
        System.out.println("ЕЛ. ПОШТА: " + message); // ЕЛ. ПОШТА: ...
    }
}

А тепер знову візьмемо той самий навмисно навчальний варіант із одним NotificationSender. Він потрібен не як фінальна форма сервісу, а як чесна точка, у якій видно неоднозначність:

import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {
    private final NotificationSender sender;

    public NotificationDispatchService(NotificationSender sender) {
        // Контейнер має вибрати ОДНУ реалізацію NotificationSender.
        this.sender = sender;
    }
}

І тут контейнер чесно скаже: «Яким sender користуватися?» У нього тепер два кандидати. І це логічно: сервіс просить один обʼєкт, а кандидатів два.

Важливо не зробити неправильний висновок. Неправильний висновок звучить так: «Ага, отже інтерфейси — зло, повернімося до конкретного класу». Ось приклад поганого рішення, яке виглядає як «швидко полагодив», але по суті ламає дизайн:

import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {
    public NotificationDispatchService(EmailNotificationSender sender) {
        // Антипатерн: "полагодили" неоднозначність ціною втрати варіативності.
        // ...
    }
}

Так, тепер неоднозначності немає, тому що тип став конкретним класом. Але ви одночасно втратили сенс порту NotificationSender: сервіс тепер жорстко залежить від електронної пошти, і ніякого «вибору реалізації через контейнер» уже не вийде. Це майже те саме, що написати new EmailNotificationSender() у методі — просто трохи культурніше оформлено.

А ось коректний висновок із ситуації такий: контейнер нам говорить не «інтерфейси погані», а «у wiring треба описати правило вибору, якщо потрібен один обʼєкт». Сьогодні ми поки фіксуємо саме це: неоднозначність зʼявляється не через помилку, а через недомовленість.

А якщо сценарій справді вимагає надіслати повідомлення в усі канали, сервіс має сказати про це контрактом залежності, а не маскувати вибір усередині методу. Тут поки важливо зафіксувати лише межу: NotificationSender і List<NotificationSender> — це два різні запити до контейнера, і в них різна логіка wiring.

Де потрібна варіативність у ContextFlow

Дуже легко надихнутися «стратегіями» і почати плодити реалізації, ніби ви пишете плагінну платформу. Але ми робимо навчальний застосунок, і нам потрібна варіативність лише там, де вона підсилює ясність, а не створює хаос. У ContextFlow варіативність має жити в портах на кшталт NotificationSender і DiscountPolicy, тому що вони відображають різні способи спілкування із зовнішнім світом або різні бізнес-правила, які можна замінити.

А ось доменні сутності й команди в такі ігри зазвичай не грають. Order, Customer, CreateOrderCommand — це просто дані та інваріанти. У них немає сенсу мати «кілька реалізацій» так, як у відправника сповіщень. Коли домен починають перетворювати на набір «beanʼів зі стратегіями», контейнер розростається, логіка ховається, а проєкт починає виглядати як лабораторія з вирощування анотацій.

Варіативність варто тримати ближче до меж застосунку: інфраструктура (як надсилаємо, куди пишемо), політики (як рахуємо знижку), форматування (як виводимо звіт). Тоді контейнер допомагає нам збирати різні варіанти застосунку, а бізнес-сценарії залишаються читабельними й не перетворюються на набір «якщо канал SMS — роби так, якщо email — інакше».

І головне: кілька реалізацій — це нормально, коли ви можете відповісти на просте запитання: що саме виражає кожна реалізація? Якщо відповіді немає, значить, ви створюєте різноманітність заради різноманітності, а це майже завжди біль, а не архітектура.

8. Типові помилки за наявності кількох реалізацій

Помилка № 1: сприймати NoUniqueBeanDefinitionException як «Spring зламався».
Коли новачок бачить велику помилку під час старту, перша емоція — «усе пропало». Насправді контейнер просто повідомив: ви попросили один обʼєкт, а кандидатів кілька. Це не «зламався Spring», це «зламався ваш опис wiring». Контейнер у цьому місці робить корисну річ: змушує систему бути визначеною, а не випадковою.

Помилка № 2: «лагодити» неоднозначність переходом на конкретний клас у конструкторі.
Це виглядає як швидке рішення, але ви тим самим обнуляєте сенс інтерфейсу й порту. Якщо сервіс залежить від конкретного класу, то варіативність перетворюється на декорацію, а не на робочу можливість. У підсумку ви отримуєте код, який складніше змінювати, складніше тестувати й неприємніше читати.

Помилка № 3: намагатися змусити Spring «вибрати будь-кого», покладаючись на порядок.
Іноді розробники починають підглядати, як Spring перелічує beans, і писати логіку на кшталт «беремо першого». Це слизька доріжка: порядок не має бути вашою бізнес-логікою. Якщо потрібен один обраний варіант — має існувати явний спосіб вибору. Якщо потрібен набір — він має бути набором, а не «списком, з якого беремо першого як default».

Помилка № 4: використовувати колекцію як універсальну заглушку від неоднозначності.
List<T> справді прибирає помилку, тому що запит став «дай усіх». Але якщо сервісу за змістом потрібен один відправник сповіщень, то колекція — це хибний контракт. Такий сервіс потім починає «сам вибирати» всередині методу, зʼявляються if/else, і ви непомітно повертаєтеся до ручного wiring, тільки тепер він замаскований під DI.

Помилка № 5: робити варіативність там, де вона не потрібна, і не робити там, де вона потрібна.
Інтерфейс заради інтерфейсу — погана практика, але й відсутність інтерфейсу на реальній межі застосунку — теж погана практика. Гарний орієнтир простий: якщо роль потенційно змінюється (канал сповіщень, правило знижки, формат виводу) — інтерфейс доречний. Якщо обʼєкт — просто дані та інваріанти (замовлення, клієнт) — не треба робити з нього «bean із кількома реалізаціями», інакше ви почнете вивчати Spring замість того, щоб писати зрозумілий код.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ