1. Інтерфейс як «роль», а не прошарок
Інтерфейс корисний не як обов’язковий прошарок, а як опис ролі. Коли розробник уперше чує про DI-підхід, у нього часто виникає цілком природна думка: «Ага! Отже, тепер треба зробити інтерфейс взагалі для всього!» Це як купити шурупокрут і почати ним закручувати суп. Інтерфейси справді потужно допомагають, але лише якщо ви використовуєте їх як опис ролі, а не як «магічну формальність» із підручника.
Найкорисніший спосіб думати про інтерфейс такий: інтерфейс — це обіцянка поведінки, яка потрібна сервісу, а реалізація — конкретний спосіб виконати цю обіцянку. Тобто сервісу зазвичай не важливо, як саме надіслали сповіщення — у консоль, електронною поштою, SMS чи голубиною поштою, — йому важливо, що сповіщення можна надіслати. Щойно ви починаєте формулювати залежність саме так, DI-код стає гнучким: деталі змінюються, а сценарій лишається на місці.
Погляньмо на мінімальний приклад. Спочатку — «жорстка прив’язка» до класу:
public class ConsoleNotificationSender {
public void send(String text) {
// Інфраструктурна деталь: конкретно "консольний" канал надсилання
System.out.println("[NOTIFY] " + text); // [NOTIFY] Order created: ORD-1
}
}
public class OrderPlacementService {
// Поганий звʼязок: сервіс залежить від конкретного класу, а не від ролі
private final ConsoleNotificationSender sender;
public OrderPlacementService(ConsoleNotificationSender sender) {
// DI наче є (залежність приходить ззовні), але абстракції ролі немає
this.sender = sender;
}
}
DI наче є — залежність приходить ззовні, — але роль усе одно не відокремлена від реалізації. Тепер трохи акуратніше: залежність від ролі.
public interface NotificationSender {
// Контракт ролі: "уміє надсилати сповіщення"
void send(String text);
}
public class OrderPlacementService {
// Добрий звʼязок: сервіс залежить від ролі, а не від конкретної деталі
private final NotificationSender sender;
public OrderPlacementService(NotificationSender sender) {
// Реалізацію виберуть зовні (у composition root)
this.sender = sender;
}
}
Сервіс не «знає» конкретного класу, він знає лише контракт. І це не філософія заради філософії, а практичний ключ до взаємозамінності.
Абстракція в ContextFlow: де потрібна
Одна з найважливіших навичок — і для Junior це просто суперсила — вміти відрізняти «точку варіативності» від «просто класу». Абстракція виправдана там, де ви справді допускаєте різні варіанти поведінки, або там, де хочете ізолювати інфраструктуру від бізнес-логіки. У ContextFlow таких місць досить багато — і це не випадково: проєкт спеціально обрано так, щоб ці точки були природними.
Давайте сформулюємо просту інженерну логіку. Якщо ви можете чесно уявити, що завтра вам знадобиться інший варіант поведінки, інтерфейс — добрий кандидат. Якщо ж інших варіантів немає і клас просто представляє дані або внутрішню логіку без зовнішніх ефектів, інтерфейс частіше буде шумом.
Нижче — невелика «карта ролей» нашого застосунку. Це не список «обов’язкових інтерфейсів за ГОСТом», а підказка, де абстракція справді допомагає:
| Роль (що потрібно сервісу) | Інтерфейс | Приклади реалізацій | Чому може змінюватися |
|---|---|---|---|
| Зберігати замовлення | OrderStore | InMemoryOrderStore, пізніше могли б з’явитися FileOrderStore | Середовище, тести, спосіб зберігання |
| Писати бізнес-аудит | AuditWriter | ConsoleAuditWriter, NoOpAuditWriter | У тестах — мовчати, у демо — писати у файл або в консоль |
| Надсилати сповіщення | NotificationSender | ConsoleNotificationSender, EmailNotificationSender | Канали різняться, але роль одна |
| Обчислювати знижку | DiscountPolicy | NoDiscountPolicy, LoyalCustomerDiscountPolicy | Бізнес-правила змінюються найчастіше |
| Генерувати id | OrderIdGenerator | UuidOrderIdGenerator, DeterministicOrderIdGenerator | Тестам потрібна передбачуваність |
Ключовий момент: усі ці ролі — залежності, без яких сценарій або неможливий, або швидко перетворюється на «комбайн на 400 рядків if-else». Ми не намагаємося «ввести інтерфейси всюди», а додаємо їх там, де є реальні причини: варіативність, тестованість, ізоляція.
2. Порти ContextFlow: OrderStore та інші
Зараз ми зробимо важливий крок: оформимо ці «ролі» в окремі інтерфейси та дамо їм перші прості реалізації. Це схоже на ситуацію, коли ви перестаєте говорити «принеси мені ось цю конкретну кружку» і починаєте говорити «принеси мені кружку» — а вже яка саме це кружка, вирішить той, хто збирає стіл, тобто composition root. Так, звучить трохи побутово. Але хороший DI загалом — про здорову організацію побуту.
Почнімо зі сповіщень. Маленький інтерфейс — це добре. Щоб межа читалася очима, я трохи рознесу код по пакетах: окремо ролі, окремо інфраструктурні реалізації. Це все той самий ContextFlow; тут важливіше побачити саму межу між контрактом і реалізацією, ніж тримати весь код в одному пакеті.
package com.example.contextflow.domain.ports;
public interface NotificationSender {
// Порт домену: бізнес-коду важливо "можна надіслати", а не "куди саме"
void send(String text);
}
Реалізація для консолі — най«навчальніша» та найчесніша:
package com.example.contextflow.infrastructure.notification;
import com.example.contextflow.domain.ports.NotificationSender;
public class ConsoleNotificationSender implements NotificationSender {
public void send(String text) {
// Інфраструктура: виводимо сповіщення у stdout
System.out.println("[NOTIFY] " + text); // [NOTIFY] Order created: ORD-1
}
}
Тепер аудит. Знову та сама ідея: роль одна — «записати бізнес-подію», а куди саме — сервісу байдуже.
package com.example.contextflow.domain.ports;
public interface AuditWriter {
// Порт домену: фіксуємо бізнес-подію (зберігання/виведення — деталь)
void write(String message);
}
Консольний варіант:
package com.example.contextflow.infrastructure.audit;
import com.example.contextflow.domain.ports.AuditWriter;
public class ConsoleAuditWriter implements AuditWriter {
public void write(String message) {
// Інфраструктура: пишемо аудит у консоль
System.out.println("[AUDIT] " + message); // [AUDIT] created ORD-1
}
}
І «тихий» варіант — дуже корисний для тестів і сценаріїв без зайвого шуму:
package com.example.contextflow.infrastructure.audit;
import com.example.contextflow.domain.ports.AuditWriter;
public class NoOpAuditWriter implements AuditWriter {
public void write(String message) {
// Заглушка: навмисно нічого не робимо (зручно для тестів і тихих сценаріїв)
}
}
Сховище замовлень — теж роль. Для початку нам вистачить варіанта в памʼяті:
package com.example.contextflow.domain.ports;
import com.example.contextflow.domain.model.Order;
import java.util.Optional;
public interface OrderStore {
// Зберігаємо замовлення (тут немає деталей: БД, файл або пам'ять — однаково)
void save(Order order);
// Повертаємо Optional, щоб відсутність замовлення була явним випадком
Optional<Order> findById(String orderId);
}
Найпростіша реалізація на Map:
package com.example.contextflow.infrastructure.store;
import com.example.contextflow.domain.model.Order;
import com.example.contextflow.domain.ports.OrderStore;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class InMemoryOrderStore implements OrderStore {
// У навчальному режимі зберігаємо все в памʼяті процесу
private final Map<String, Order> orders = new HashMap<>();
public void save(Order order) {
// Ключ — id замовлення, значення — саме замовлення
orders.put(order.getId(), order);
}
public Optional<Order> findById(String orderId) {
// Загортаємо результат в Optional: null назовні не «витікає»
return Optional.ofNullable(orders.get(orderId));
}
}
Знижка. Тут важливо не захопитися і не побудувати «движок знижок» — це окрема професія і окремий біль. Нам потрібна роль і дві реалізації: «без знижки» та «є знижка лояльним».
package com.example.contextflow.domain.ports;
import com.example.contextflow.domain.model.Customer;
public interface DiscountPolicy {
// Повертаємо суму знижки (в умовних «грошових одиницях»), а не кінцеву ціну
int discountFor(Customer customer, int total);
}
Без знижки:
package com.example.contextflow.application.discount;
import com.example.contextflow.domain.model.Customer;
import com.example.contextflow.domain.ports.DiscountPolicy;
public class NoDiscountPolicy implements DiscountPolicy {
public int discountFor(Customer customer, int total) {
// Політика за замовчуванням: знижок немає
return 0;
}
}
Знижка лояльним:
package com.example.contextflow.application.discount;
import com.example.contextflow.domain.model.Customer;
import com.example.contextflow.domain.ports.DiscountPolicy;
public class LoyalCustomerDiscountPolicy implements DiscountPolicy {
public int discountFor(Customer customer, int total) {
// Приклад бізнес-правила: лояльному клієнту 10% від суми
return customer.isLoyal() ? (total / 10) : 0; // 10%
}
}
І генератор id. Це взагалі класичний приклад того, чому інтерфейс корисний: у «бойовому» режимі ви хочете UUID, а в тестах — детермінованість.
package com.example.contextflow.domain.ports;
public interface OrderIdGenerator {
// Роль: видати наступний ідентифікатор (деталь генерації — не турбота сервісу)
String nextId();
}
Детермінований варіант:
package com.example.contextflow.infrastructure.id;
import com.example.contextflow.domain.ports.OrderIdGenerator;
public class DeterministicOrderIdGenerator implements OrderIdGenerator {
// Лічильник для передбачуваних id — особливо зручно в тестах
private int seq = 1;
public String nextId() {
// Передбачувана послідовність: ORD-1, ORD-2, ...
return "ORD-" + (seq++);
}
}
На цьому етапі ви можете помітити, що інтерфейсів стало більше. Це нормально. Головне — що кожен із них відповідає на запитання «яку роль сервіс очікує отримати?», а не існує «тому що так треба».
3. Перемикання поведінки через composition root
Інтерфейси прекрасні рівно до того моменту, поки ви не спробуєте ними реально скористатися. Тобто поки не заміните одну реалізацію на іншу та не побачите, що бізнес-код не здригнувся ні на рядок. Саме заради цього ми взагалі вводимо абстракції: щоб зміни відбувалися там, де їм і місце — у точці збирання, а не всередині сценаріїв.
Зробімо спрощений OrderPlacementService, який залежить від двох ролей: сповіщення та аудиту. Зверніть увагу: сервіс «розмовляє» з інтерфейсами, а не з конкретними класами.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.domain.ports.NotificationSender;
public class OrderPlacementService {
// Ролі, які потрібні сценарію: сповістити та записати аудит
private final NotificationSender sender;
private final AuditWriter auditWriter;
public OrderPlacementService(NotificationSender sender, AuditWriter auditWriter) {
// Конкретні реалізації приходять ззовні
this.sender = sender;
this.auditWriter = auditWriter;
}
public void place(String orderId) {
// Бізнес-сценарій не знає, ЯК надсилають і КУДИ пишуть аудит — він просто викликає ролі
sender.send("Order created: " + orderId);
auditWriter.write("created " + orderId);
}
}
Тепер композиція «для звичайного запуску»:
package com.example.contextflow;
import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;
public class Main {
public static void main(String[] args) {
// Composition root: тут обираємо конкретні реалізації ролей
OrderPlacementService service = new OrderPlacementService(
new ConsoleNotificationSender(),
new ConsoleAuditWriter()
);
// Запускаємо сценарій
service.place("ORD-1");
// [NOTIFY] Order created: ORD-1
// [AUDIT] created ORD-1
}
}
А тепер — найсмачніше: змінюємо поведінку, не змінюючи сервіс. Наприклад, хочемо «тихий режим» — умовно тестовий сценарій, у якому аудит вимкнено.
package com.example.contextflow;
import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.infrastructure.audit.NoOpAuditWriter;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;
public class Main {
public static void main(String[] args) {
// Змінюємо лише збирання: тепер аудит «мовчить»
OrderPlacementService service = new OrderPlacementService(
new ConsoleNotificationSender(),
new NoOpAuditWriter()
);
service.place("ORD-2");
// [NOTIFY] Order created: ORD-2
}
}
Оце й є справжня «перемога» DI-дизайну. Ми не переписували OrderPlacementService, не додавали туди прапорці boolean auditEnabled, не влаштовували if(mode == TEST). Ми просто сказали: «Цього разу роль AuditWriter виконуватиме інший об’єкт».
Щоб закріпити картину, ось схема графа об’єктів (і так, це все ще звичайна Java, просто ми описали її акуратно):
flowchart LR
Main["Main (точка збирання)"] --> OPS["OrderPlacementService"]
OPS -->|NotificationSender| CNS["ConsoleNotificationSender"]
OPS -->|AuditWriter| CAW["ConsoleAuditWriter / NoOpAuditWriter"]
І саме через такі схеми в реальних проєктах люди люблять контейнери: граф зростає, і вручну керувати всім стає важко. Але поки ми чесно вчимося робити це руками — щоб розуміти, що саме контейнер потім автоматизуватиме.
4. Де інтерфейс зайвий: доменні дані та команди
Після кількох вдалих інтерфейсів є ризик перейти в режим «інтерфейсоманії». Це коли ви робите Order, IOrder, OrderImpl, потім Customer, ICustomer, DefaultCustomer… і раптом застосунком стає простіше керувати через шаманський бубон, ніж через IDE. Щоб так не сталося, важливо заздалегідь провести межу: дані та прості доменні моделі зазвичай не є точкою варіативності.
Замовлення — це дані. Клієнт — це дані. Команда створення замовлення — це вхідні дані. Вони не повинні «змінюватися реалізаціями», вони мають бути зрозумілими та простими. Тому найчастіше це звичайні класи (або record-и, якщо ви їх любите).
Приклад мінімального Order без інтерфейсу:
package com.example.contextflow.domain.model;
public class Order {
// Доменна модель даних: простий, прозорий обʼєкт без варіативності реалізацій
private final String id;
private final int total;
public Order(String id, int total) {
this.id = id;
this.total = total;
}
public String getId() { return id; }
public int getTotal() { return total; }
}
І простий Customer, який потрібен знижкам:
package com.example.contextflow.domain.model;
public class Customer {
// Знову: прості дані, без інтерфейсів і «плагінів»
private final String id;
private final boolean loyal;
public Customer(String id, boolean loyal) {
this.id = id;
this.loyal = loyal;
}
public boolean isLoyal() { return loyal; }
}
Помітьте, як добре це читається: немає «порожніх абстракцій». Так, можна уявити, що колись ви захочете різні реалізації Customer… але зазвичай це означає, що ви моделюєте не Customer, а різні джерела даних або різні подання клієнта — і це вже інша тема. На поточному рівні нам потрібно, щоб доменна модель була прозорою, а абстракції жили там, де вони справді керують збиранням.
5. Ознаки поганого інтерфейсу та швидкі правки
Інтерфейс — штука проста, але поганий інтерфейс швидко робить проєкт неприємним. Причому неприємним не для комп’ютера — комп’ютер усе стерпить, — а для людини, яка читає код. Добра новина в тому, що більшість проблем видно на рівні здорового глузду: якщо ви дивитеся на інтерфейс і не можете пояснити, яку роль він виражає, найімовірніше, він і справді виражає «нічого».
Є два популярні анти-патерни. Перший — інтерфейс заради інтерфейсу: «у кожного класу має бути інтерфейс». Тоді з’являються пари OrderService / OrderServiceImpl, де інтерфейс не дає варіативності, а просто додає шар. Другий — «інтерфейс-комбайн», який намагається включити все, що колись могло знадобитися, і врешті стає маленьким всесвітом із десятком методів.
Приклад інтерфейсу-комбайна — так робити не хочеться:
public interface OrderStore {
void save(Object o);
void delete(Object o);
void clearAll();
void printDebugInfo();
String exportToJson();
}
Проблема тут не в кількості рядків, а в змішуванні ролей. Сховище раптом стало і експортером, і дебагером, і прибиральником. Виправлення часто просте: повернути інтерфейс до ролі. Наприклад, для нашого навчального проєкту достатньо «зберегти» і «знайти»:
import java.util.Optional;
public interface OrderStore {
// Вузька роль: зберігати замовлення (зберегти і знайти)
void save(Order order);
Optional<Order> findById(String orderId);
}
Ще один хороший тест: спробуйте вимовити інтерфейс уголос як речення. «OrderStore зберігає та знаходить замовлення». «AuditWriter пише записи аудиту». «NotificationSender надсилає сповіщення». Якщо виходить нормальна фраза — ви близькі до хорошої абстракції. Якщо виходить «OrderManagerDoEverythingProviderFactory» — можливо, ви випадково почали писати заклинання, а не код.
6. Типові помилки під час введення інтерфейсів
Помилка №1: інтерфейси “про всяк випадок” для кожного класу.
Дуже легко скотитися в шаблон «є клас — значить має бути інтерфейс». У результаті з’являються порожні абстракції, які не дають ні варіативності, ні ясності, але ускладнюють навігацію кодом. Краще вводити інтерфейс там, де є реальна роль і хоча б потенційно кілька реалізацій.
Помилка №2: назви за реалізацією, а не за роллю.
Якщо інтерфейс називається ConsoleAuditWriter, то це вже не інтерфейс ролі — це інтерфейс деталі. Інтерфейс має бути про «що робимо»: AuditWriter, NotificationSender, OrderStore. А конкретика — “console”, “file”, “noop” — живе в назві реалізації.
Помилка №3: бізнес-сервіс усе одно вибирає реалізацію через if.
Іноді роблять NotificationSender інтерфейс… а потім усередині OrderPlacementService пишуть if(channel.equals("console")) sender = new Console.... Це повертає нас до тієї самої болі, від якої ми йшли: клас знову ухвалює рішення про реалізацію. Вибір реалізації має жити в composition root — у точці збирання, — а не всередині сценарію.
Помилка №4: інтерфейс став “богом”, бо так зручніше одному сервісу.
Якщо один сервіс захотів «ще один метод» і ви додали його в спільний інтерфейс, ви змусили всі реалізації підтримувати цей новий обов’язок. Звідси ростуть дивні конструкції на кшталт throw new UnsupportedOperationException() або фейкові заглушки. Часто краще зробити другий маленький інтерфейс під окрему роль, ніж роздувати один.
Помилка №5: інтерфейси “протекли” в доменні дані.
Коли роблять IOrder, ICustomer, IOrderItem, зазвичай це не робить систему гнучкішою — зате робить її менш читабельною. Доменні об’єкти даних майже завжди лишаються звичайними класами, а інтерфейси з’являються навколо зовнішніх ефектів — сповіщення, аудит, зберігання — або навколо варіативних стратегій — знижка, генерація id.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ