1. Вступ
Зараз зберемо в одну картину ролі, constructor injection і ручний composition root, а потім отримаємо цілісну plain Java-версію ContextFlow — без Spring, але вже без прихованих залежностей. Сервіси тут отримують залежності ззовні, а весь граф об’єктів нарешті видно цілком.
У результаті в нас вийде версія, яку вже приємно читати: відкрили OrderPlacementService — побачили сценарій, а не зоопарк із new. Відкрили Main — побачили, як саме застосунок зібрано і якими реалізаціями він «дихає».
Щоб не розпорошуватися на структуру пакетів, у цій лекції всі класи поки що живуть в одному пакеті com.example.contextflow. Так, архітектори зараз трохи здригнуться, але нам важливіше побачити саму збірку застосунку, ніж сперечатися про розкладку файлів.
2. Дані сценарію та залежності сервісу
Щоб у цій версії не було плутанини, тримаємо одне правило: у конструктор ідуть лише інструменти сервісу, а дані конкретного замовлення надходять у метод. Тому OrderStore, AuditWriter, NotificationSender, DiscountPolicy і OrderIdGenerator живуть як залежності, а Customer, CreateOrderCommand і сам Order описують один конкретний запуск сценарію.
Гроші тут навмисно спрощені до int: зараз нам важливо зібрати прозорий робочий потік plain Java-застосунку, а не переходити до окремої теми про грошову модель.
Доменні об’єкти
package com.example.contextflow;
// Рівень лояльності клієнта впливає на знижку (доменні дані, а не залежність)
public enum LoyaltyLevel {
STANDARD,
GOLD
}
package com.example.contextflow;
// Клієнт — це вхідні дані сценарію (створюємо на початку сценарію, не інжектуємо як сервіс)
public record Customer(String id, String name, LoyaltyLevel loyaltyLevel) {
}
package com.example.contextflow;
// Команда (DTO) для сценарію створення замовлення: "інгредієнти" конкретного запуску
public record CreateOrderCommand(Customer customer, int totalAmount) {
}
package com.example.contextflow;
// Статус замовлення — частина доменної моделі
public enum OrderStatus {
NEW,
CANCELLED
}
package com.example.contextflow;
// Замовлення — доменний об’єкт; створювати його всередині сценарію цілком нормально
public record Order(String id, Customer customer, int totalAmount, OrderStatus status) {
}
Зверніть увагу на важливу думку: Customer, CreateOrderCommand і Order — це не залежність, а дані. Вони з’являються лише на один сценарій і зазвичай створюються там, де ми його запускаємо, — у ранері або в Main, — а не живуть як «глобальні сервіси».
3. Контракти залежностей: інтерфейси як ролі застосунку
Тепер зафіксуємо ролі, які потрібні цій версії проєкту. Тут інтерфейси працюють як чесні контракти: через них сервіс отримує зберігання, знижки, сповіщення, аудит і генерацію id.
Невелика таблиця-карта:
| Роль (що потрібно бізнесу) | Інтерфейс | Приклад реалізації (поки що) |
|---|---|---|
| зберігати замовлення | OrderStore | InMemoryOrderStore |
| рахувати знижку | DiscountPolicy | NoDiscountPolicy |
| надсилати сповіщення | NotificationSender | ConsoleNotificationSender |
| писати аудит | AuditWriter | ConsoleAuditWriter |
| генерувати id | OrderIdGenerator | DeterministicOrderIdGenerator |
Тепер самі інтерфейси. Спробуйте читати їх як «словник можливостей», а не як «шматок коду».
package com.example.contextflow;
import java.util.Optional;
// Роль: сховище замовлень (як саме зберігати — бізнес-сервісу неважливо)
public interface OrderStore {
void save(Order order);
Optional<Order> findById(String orderId);
}
package com.example.contextflow;
// Роль: політика знижок (алгоритм можна змінювати без переписування сценарію)
public interface DiscountPolicy {
int discountFor(Customer customer, int totalAmount);
}
package com.example.contextflow;
// Роль: надсилання сповіщень (консоль, SMS, пошта — неважливо)
public interface NotificationSender {
void sendOrderCreated(Order order);
}
package com.example.contextflow;
// Роль: аудит — запис технічних і бізнес-подій (у консоль, файл, шину тощо)
public interface AuditWriter {
void write(String message);
}
package com.example.contextflow;
// Роль: генерація ідентифікаторів замовлення (детермінована, UUID тощо)
public interface OrderIdGenerator {
String nextId();
}
Назви тут говорять про роль, а не про технологію: OrderStore, AuditWriter, NotificationSender. Конкретика на кшталт InMemory, Console або NoOp живе вже в реалізаціях.
4. Реалізації залежностей
Тепер потрібні перші реалізації, щоб проєкт справді запускався. Вони спеціально максимально прості: пам’ять, консоль і передбачувані правила. Наша мета не «продакшен», а показати, що кожну роль уже можна замінити без правки сценарію.
Покажу кілька реалізацій. Вони навмисно «прості як табуретка»: наша мета — архітектурна збірка, а не промисловий транспортний літак.
Сховище: InMemoryOrderStore
package com.example.contextflow;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
// Найпростіша реалізація сховища: тримаємо все в пам’яті процесу
public class InMemoryOrderStore implements OrderStore {
// "База даних" на HashMap — годиться для демо і тестів
private final Map<String, Order> orders = new HashMap<>();
@Override
public void save(Order order) {
// Ключ — id замовлення, значення — саме замовлення
orders.put(order.id(), order);
}
@Override
public Optional<Order> findById(String orderId) {
// Optional явно показує, що замовлення може не знайтися
return Optional.ofNullable(orders.get(orderId));
}
}
Так, методи тут майже в один рядок. У реальному проєкті ви б форматували акуратніше. Зараз я просто економлю місце і тримаю фокус на ідеї: є роль зберігання, є реалізація, і бізнес-код від конкретної реалізації не залежить.
Сповіщення: ConsoleNotificationSender
package com.example.contextflow;
// Сповіщення в консоль — дешева інфраструктура, яку легко замінити на реальну
public class ConsoleNotificationSender implements NotificationSender {
@Override
public void sendOrderCreated(Order order) {
// У реальному світі тут був би email, SMS, push-повідомлення тощо
System.out.println("[NOTIFY] Замовлення створено: " + order.id()); // [NOTIFY] Замовлення створено: ORD-1
}
}
Аудит: ConsoleAuditWriter і NoOpAuditWriter
package com.example.contextflow;
// Аудит у консоль — як логування, тільки в навчальному вигляді
public class ConsoleAuditWriter implements AuditWriter {
@Override
public void write(String message) {
// У реальному світі це міг би бути логер або сервіс аудиту
System.out.println("[AUDIT] " + message); // [AUDIT] створено ORD-1, finalTotal=900
}
}
package com.example.contextflow;
// "Порожня" реалізація: аудит як концепція є, але тут ми нічого не робимо
public class NoOpAuditWriter implements AuditWriter {
@Override
public void write(String message) {
// Мовчки ігноруємо — зручно для тихого режиму та майбутніх тестів
// Важливо: бізнес-сервіс усе одно викликає аудит, але інфраструктура обрана "нульова"
}
}
NoOp-реалізація — це не «обман». Це цілком чесна стратегія: інколи бізнесу потрібен «аудит як концепція», але в конкретному запуску ми не хочемо виводити нічого — наприклад, у навчальному демо, де вивід і так шумний.
Знижки: NoDiscountPolicy і LoyalCustomerDiscountPolicy
package com.example.contextflow;
// Політика "жодних знижок" — корисна як дефолт і для тестів
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public int discountFor(Customer customer, int totalAmount) {
// Знижка відсутня незалежно від суми та клієнта
return 0;
}
}
package com.example.contextflow;
// Політика: GOLD отримує знижку, решта — ні
public class LoyalCustomerDiscountPolicy implements DiscountPolicy {
@Override
public int discountFor(Customer customer, int totalAmount) {
// Умова за рівнем лояльності — частина бізнес-правила (винесена в окрему роль)
// Знижка: 10% від суми, але не більше 200
return customer.loyaltyLevel() == LoyaltyLevel.GOLD ? Math.min(200, totalAmount / 10) : 0;
}
}
Тут ми навмисно тримаємо логіку простою: золотому клієнтові знижка 10 %, але не більше 200. Це навчальний приклад, але він уже показує важливе: знижка — окрема роль, і її можна змінювати, не переписуючи сервіс.
Генерація id: детермінована та UUID
package com.example.contextflow;
// Детермінований генератор: зручно для повторюваних запусків і зрозумілих логів
public class DeterministicOrderIdGenerator implements OrderIdGenerator {
// Лічильник усередині процесу (у проді так робити небезпечно, але для демо — ідеально)
private long seq = 0;
@Override
public String nextId() {
seq++;
// Читабельний id для виводу: ORD-1, ORD-2, ...
return "ORD-" + seq;
}
}
package com.example.contextflow;
import java.util.UUID;
// UUID-генерація — більш "схожа на життя", коли не хочеться колізій між запусками
public class UuidOrderIdGenerator implements OrderIdGenerator {
@Override
public String nextId() {
// UUID генерується бібліотекою, бізнес-сервісу байдуже, як саме
return UUID.randomUUID().toString();
}
}
Дві реалізації генератора тут потрібні не заради колекції генераторів, а щоб було видно: сервісу байдуже, звідки приходить id, поки в ролі є nextId().
5. OrderPlacementService: сценарій без прихованих new
Ось тут — центральна точка, заради якої ми все це й затівали. Ми хочемо відкрити клас бізнес-сервісу й побачити сценарій: «взяти вхідні дані → порахувати знижку → зібрати замовлення → зберегти → надіслати сповіщення → записати аудит». Ми не хочемо бачити «створити Map, створити writer, створити sender…» — це не сценарій, а будівельний майданчик.
Конструктор сервісу — це контракт: що потрібно, щоб цей сервіс узагалі міг працювати. Сама бізнес-логіка — у методі. І зверніть увагу на важливу річ: всередині методу ми можемо створювати доменні об’єкти (Order) — це нормально. Але ми не створюємо залежності (OrderStore, AuditWriter тощо).
package com.example.contextflow;
// Бізнес-сервіс: містить сценарій, але не містить wiring (жодних new залежностей усередині)
public class OrderPlacementService {
private final OrderStore store;
private final DiscountPolicy discountPolicy;
private final NotificationSender notificationSender;
private final AuditWriter auditWriter;
private final OrderIdGenerator idGenerator;
public OrderPlacementService(OrderStore store,
DiscountPolicy discountPolicy,
NotificationSender notificationSender,
AuditWriter auditWriter,
OrderIdGenerator idGenerator) {
// DI: залежності приходять ззовні, їх легко підміняти
this.store = store;
this.discountPolicy = discountPolicy;
this.notificationSender = notificationSender;
this.auditWriter = auditWriter;
this.idGenerator = idGenerator;
}
public Order place(CreateOrderCommand command) {
// 1) Генеруємо id — це роль, а не "UUID прямо тут"
String id = idGenerator.nextId();
// 2) Рахуємо знижку через політику (винесене бізнес-правило)
int discount = discountPolicy.discountFor(command.customer(), command.totalAmount());
int finalTotal = command.totalAmount() - discount;
// 3) Створюємо доменний об’єкт (це нормально створювати всередині сценарію)
Order order = new Order(id, command.customer(), finalTotal, OrderStatus.NEW);
// 4) Побічні ефекти сценарію робимо через ролі-залежності
store.save(order);
notificationSender.sendOrderCreated(order);
auditWriter.write("створено " + id + ", finalTotal=" + finalTotal);
// 5) Повертаємо результат сценарію
return order;
}
}
Якщо зараз порівняти цей сервіс із версією з першого дня, де всередині методу жили new, ефект видно одразу: сервіс став про замовлення, а не про те, як зібрати собі інструменти.
6. Composition root: збирання застосунку
Тепер просто зберемо всі ролі в одну точку. Wiring живе в одному місці, а сервіси залишаються чистими.
Нехай цей код буде нудним і навіть трохи довгим — саме в цьому його користь. Одразу видно, які реалізації використовуються і де застосунок зібрано.
Схематично наш граф зараз виглядає так:
flowchart LR
Main["Main (точка збирання)"] --> OPS["OrderPlacementService"]
OPS --> Store["Сховище замовлень"]
OPS --> Disc["Політика знижок"]
OPS --> Notif["Надсилання сповіщень"]
OPS --> Audit["Запис аудиту"]
OPS --> IdGen["Генератор ідентифікаторів"]
А тепер — сам Main. Я додам ще ScenarioRunner, щоб Main не перетворювався водночас і на точку збирання, і на запуск сценарію. Main нехай збирає, ранер — запускає.
package com.example.contextflow;
// Composition root: єдина точка, де ми вибираємо конкретні реалізації та збираємо граф
public class Main {
public static void main(String[] args) {
// Вибір реалізацій (інфраструктура)
OrderStore store = new InMemoryOrderStore();
DiscountPolicy discountPolicy = new LoyalCustomerDiscountPolicy();
NotificationSender sender = new ConsoleNotificationSender();
AuditWriter auditWriter = new ConsoleAuditWriter();
OrderIdGenerator idGenerator = new DeterministicOrderIdGenerator();
// Збирання бізнес-сервісу з ролей
OrderPlacementService orderPlacementService =
new OrderPlacementService(store, discountPolicy, sender, auditWriter, idGenerator);
// Запуск сценарію винесено в окремий клас, щоб Main не став "другим бізнес-рівнем"
ScenarioRunner runner = new ScenarioRunner(orderPlacementService);
runner.run();
}
}
Саме тут особливо чесно видно ціну ручного збирання: Main швидко зростає. Але це все одно краще, ніж new усередині сервісу, тому що весь wiring залишається в одній точці.
Щоб відчути замінюваність реалізацій, спробуйте прямо тут змінити один рядок:
AuditWriter auditWriter = new NoOpAuditWriter();
І ви одразу побачите: бізнес-сервіс не змінився ані на символ, а поведінка застосунку — змінилася.
7. ScenarioRunner: запуск сценарію без знання про збирання
Щоб Main не перетворювався на другий бізнес-рівень, сам запуск сценарію винесемо в маленький ScenarioRunner. Він створює вхідні дані і викликає вже зібраний сервіс, але не знає деталей wiring.
package com.example.contextflow;
// Ранер: створює вхідні дані сценарію та викликає бізнес-сервіс
public class ScenarioRunner {
private final OrderPlacementService orderPlacementService;
public ScenarioRunner(OrderPlacementService orderPlacementService) {
// У ранер ми передаємо вже зібраний сервіс (ранер не знає, як саме його зібрано)
this.orderPlacementService = orderPlacementService;
}
public void run() {
// Вхідні дані сценарію створюються тут, а не в DI-контейнері
Customer customer = new Customer("C-1", "Аліса", LoyaltyLevel.GOLD);
CreateOrderCommand cmd = new CreateOrderCommand(customer, 1000);
// Запуск бізнес-сценарію
Order order = orderPlacementService.place(cmd);
// Вивід результату (UI/консоль — це не бізнес-логіка)
System.out.println("Створено замовлення: " + order.id()); // Створено замовлення: ORD-1
}
}
Якщо запустити застосунок у такому вигляді, ви отримаєте приблизно такий потік виводу:
[NOTIFY] Замовлення створено: ORD-1
[AUDIT] створено ORD-1, finalTotal=900
Створено замовлення: ORD-1
Знижка спрацювала (1000 → 900), сповіщення надіслано, аудит записано — і все це без жодного new усередині OrderPlacementService.
8. Що покращилося і ціна ручного wiring
На цьому етапі легко зловити дивне, але чесне відчуття: коду стало більше. Це нормальна ціна за явність. Зате тепер не потрібно гадати, звідки у сервісу беруться залежності і чому він раптом змінює поведінку.
| Що порівнюємо | Було (приховані new) | Стало (DI + composition root) |
|---|---|---|
| Де створюються залежності | усередині бізнес-класів, розмазано по коду | в одному місці (Main) |
| Чи можна замінити реалізацію | зазвичай лише правкою бізнес-коду | одним рядком у composition root |
| Читабельність сервісу | сценарій змішано зі збиранням | сценарій читається як сценарій |
| Тестованість | важко підміняти залежності | залежності можна підставляти явно |
| Помилки конфігурації | спливають десь уже під час виконання | частіше видно одразу під час збирання |
Але є і чесний мінус: composition root починає розростатися. У маленькому проєкті це терпимо, але другий сценарій, третій сервіс і ще пара реалізацій — і Main швидко перетворюється на довгий список «створи те, створи це». Це не привід повертатися до new усередині сервісу. Це просто межа ручного збирання.
Саме тут і стає зрозумілою цінність ApplicationContext. Граф залежностей нам уже знайомий: ті самі ролі, ті самі конструктори, ті самі сервіси. Контейнер потрібен не для того, щоб заново вигадати OrderPlacementService, а для того, щоб зберігати правила збирання, створювати й повторно використовувати об’єкти та зупиняти застосунок зрозумілою помилкою, якщо wiring зламано.
Тобто ручний wiring уже зробив головне: зробив граф застосунку видимим і показав, яку частину цієї рутини далі розумно віддати контейнеру.
9. Типові помилки в DI-friendly plain Java-версії
Наприкінці дня корисно зафіксувати граблі, на які наступають майже всі, хто вперше робить DI-рефакторинг. Тут немає «ганебних» помилок: це ті самі місця, де мозок намагається повернути старі звички, бо так було швидше писати. Але наша мета — зробити код розширюваним і читабельним, а не перемогти в чемпіонаті з кількості new за хвилину.
Помилка № 1: винесли new із методу, але сховали його в приватний метод того самого класу.
Якщо OrderPlacementService більше не робить new прямо в place(), але робить new у private AuditWriter auditWriter() — ви не зробили IoC. Ви просто сховали wiring глибше, як кіт ховає капець під диван: капець усе одно зник, але легше не стало.
Помилка № 2: залежності передаються, але сервіс усе одно інколи створює собі альтернативу через new.
Наприклад, ви передали AuditWriter, а всередині сервісу на помилці робите new ConsoleAuditWriter() «про всяк випадок». Це ламає контракт: тепер незрозуміло, який аудит реально використовується і чому при вимкненому аудиті в тихому режимі все одно щось друкується.
Помилка № 3: composition root почав приймати бізнес-рішення.
Якщо в Main з’являються умови рівня «якщо сума замовлення більша за 1000 — то знижка така, інакше така», wiring перетворюється на другий бізнес-рівень. Composition root має вибирати реалізації та збирати граф, але не підміняти собою бізнес-логіку.
Помилка № 4: інтерфейс на кожен клас — просто тому, що DI.
Якщо ви починаєте робити OrderService → OrderServiceImpl, ScenarioRunner → ScenarioRunnerImpl без реальної причини, проєкт швидко обростає шумом. Інтерфейс потрібен там, де очікується осмислена варіативність — як у AuditWriter або NotificationSender, — а не для галочки.
Помилка № 5: довгий конструктор ігноруємо — отже все нормально.
Конструктор із 8–10 залежностями інколи говорить не «DI поганий», а «клас робить надто багато». На цьому етапі ще рано влаштовувати архітектурну революцію, але важливо хоча б помітити сигнал: можливо, сервісу варто виділити помічника і розділити відповідальність.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ