JavaRush /Курси /Spring Core /ContextFlow після ре...

ContextFlow після рефакторингу

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

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
LoyalCustomerDiscountPolicy
надсилати сповіщення NotificationSender ConsoleNotificationSender
писати аудит AuditWriter ConsoleAuditWriter
NoOpAuditWriter
генерувати id OrderIdGenerator DeterministicOrderIdGenerator
UuidOrderIdGenerator

Тепер самі інтерфейси. Спробуйте читати їх як «словник можливостей», а не як «шматок коду».

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

Знижка спрацювала (1000900), сповіщення надіслано, аудит записано — і все це без жодного 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.
Якщо ви починаєте робити OrderServiceOrderServiceImpl, ScenarioRunnerScenarioRunnerImpl без реальної причини, проєкт швидко обростає шумом. Інтерфейс потрібен там, де очікується осмислена варіативність — як у AuditWriter або NotificationSender, — а не для галочки.

Помилка № 5: довгий конструктор ігноруємо — отже все нормально.
Конструктор із 810 залежностями інколи говорить не «DI поганий», а «клас робить надто багато». На цьому етапі ще рано влаштовувати архітектурну революцію, але важливо хоча б помітити сигнал: можливо, сервісу варто виділити помічника і розділити відповідальність.

1
Опитування
Контроль залежностей, рівень 2, лекція 4
Недоступний
Контроль залежностей
Основи IoC і DI
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ