JavaRush /Курси /Spring Core /Інверсія точки керування та

Інверсія точки керування та IoC

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

1. Точка керування в застосунку

Коли new живе всередині сервісу, бізнес-сценарій швидко змішується зі збиранням застосунку. Зараз розберемо цю проблему на чистій Java: хто взагалі має вирішувати, які залежності створювати, і що змінюється, коли контроль виходить за межі бізнес-класу. Згодом цю роботу візьме на себе контейнер, але спочатку корисно побачити все вручну.

Якщо дивитися на застосунок очима новачка, здається, що «головний» — це main(). І це правда, але лише частково. Нас сьогодні цікавить не те, звідки стартує програма, а хто приймає рішення: які обʼєкти створювати, коли саме і яку конкретну реалізацію обирати. У маленькому коді ці рішення майже непомітні, але саме з них потім виростає хаос.

Уявіть, що ваш сервіс — це співробітник, якому доручили завдання: «створити замовлення та надіслати сповіщення». Якщо цей співробітник ще й сам вирішує, де взяти пошту, який ноутбук купити, який месенджер використовувати й як налаштувати інтернет, він перетворюється не на виконавця, а на маленького «директора ІТ-відділу». Ось це «директорство» ми й будемо витягувати назовні.

У термінах коду точка керування — це місце, де вирішують, які залежності використовуватиме клас. До IoC вона часто живе просто всередині бізнес-класу. Після IoC її виносять назовні: у точку входу або в окреме місце, яке збирає застосунок.

2. new усередині бізнес-класу

Найчастіша стартова ситуація виглядає цілком невинно. Є відправник сповіщень і сервіс, який розміщує замовлення. Спочатку ми просто пишемо «як швидше», а швидше — це майже завжди new.

class ConsoleNotificationSender {
    void send(String orderId) {
        // Інфраструктурна деталь: конкретний спосіб сповіщення (поки — консоль)
        System.out.println("сповіщення " + orderId); // сповіщення ORD-1
    }
}

class OrderPlacementService {
    void place(String orderId) {
        // Проблема: сервіс сам обирає реалізацію і сам створює залежність
        ConsoleNotificationSender sender = new ConsoleNotificationSender();
        sender.send(orderId);
    }
}

На рівні результату все чудово. Код короткий, очі радіють, кіт теж задоволений. Але є одна важлива деталь: клас OrderPlacementService не лише виконує бізнес-дію, він ще й вирішує, який саме відправник сповіщень створити.

Тобто всередині place() змішані одразу два види логіки. Перший — бізнес-логіка («коли розміщуємо замовлення, треба надіслати сповіщення»). Другий — логіка збирання застосунку («сповіщення надсилаємо через ConsoleNotificationSender, створюємо його просто тут і зараз»). Поки проєкт маленький, ніхто не скаржиться. Коли проєкт росте, саме ці рішення починають заважати.

Щоб відчути проблему сильніше, додамо ще одну залежність. Наприклад, аудит: ми хочемо записувати, що замовлення створено.

class ConsoleAuditWriter {
    void write(String message) {
        // Ще одна інфраструктурна деталь: куди і як пишемо аудит
        System.out.println("AUDIT: " + message); // AUDIT: створено ORD-1
    }
}

class OrderPlacementService {
    void place(String orderId) {
        // Сервіс починає "збирати світ" усередині методу: це і є точка керування всередині бізнес-коду
        ConsoleNotificationSender sender = new ConsoleNotificationSender();
        ConsoleAuditWriter auditWriter = new ConsoleAuditWriter();

        sender.send(orderId);
        auditWriter.write("створено " + orderId);
    }
}

Тепер сервіс уже «збирає собі команду співробітників» просто всередині методу. У його голові одночасно живуть і бізнес-сценарій, і деталі інфраструктури. Це якби кухар перед тим, як приготувати пасту, спочатку будував плиту, потім заводив корову, потім вів бухгалтерію — і лише після цього варив макарони. Чи сильно смачніша паста? Ні. Чи сильно складніше жити? Так.

3. new як архітектурне рішення

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

Ось до чого це призводить, коли new живе всередині бізнес-класу.

Спочатку ви втрачаєте можливість легко змінювати поведінку. Припустімо, завтра ви вирішили, що сповіщення мають іти не в консоль, а електронною поштою — хоча б у деморежимі. Якщо new ConsoleNotificationSender захований у трьох сервісах, вам доведеться змінювати всі три сервіси. Тобто зміна інфраструктури змушує редагувати бізнес-код. І це дуже поганий обмін.

Потім стає важко тестувати. Припустімо, ви хочете перевірити, що «під час створення замовлення надсилається сповіщення». Якщо OrderPlacementService сам створює ConsoleNotificationSender, ви не зможете нормально підмінити його тестовою реалізацією, яка просто запамʼятовує факт виклику. Доведеться або лізти в приватні деталі, або вигадувати дивні трюки, або змиритися й дивитися на System.out очима сумної людини.

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

class OrderPlacementService {

    void place(String orderId) {
        // Ззовні здається, що все стало "гарно", але створення все ще всередині сервісу
        var sender = buildSender();
        sender.send(orderId);
    }

    private ConsoleNotificationSender buildSender() {
        // Точка керування не змінилася: рішення про реалізацію, як і раніше, тут
        return new ConsoleNotificationSender();
    }
}

Виглядає акуратніше, але точка керування не змінилася. Рішення про те, якого відправника створювати, як і раніше, живе всередині бізнес-класу. Ми просто засунули проблему за фіранку.

4. IoC: створення залежностей ззовні

Тепер назвімо речі своїми іменами. Inversion of Control (IoC) — це ідея, за якої бізнес-клас не керує створенням своїх залежностей. Він не має вирішувати, які конкретні реалізації використовувати й коли їх створювати. Замість цього клас отримує залежності ззовні, а сам зосереджується на своєму завданні.

Слово «інверсія» тут означає, що змінюється напрям контролю. До IoC сервіс каже: «мені потрібен відправник — зараз я сам його створю». Після IoC сервіс каже: «мені потрібен відправник — передайте його мені, і я працюватиму з ним». Контроль над створенням переїжджає з бізнес-класу у зовнішній код, який займається збиранням застосунку.

Важливо: IoC — це ідея, а не конкретний API. Spring справді є IoC-контейнером, але сьогодні він нам взагалі не потрібен, щоб зрозуміти суть. IoC можна зробити на чистій Java — буквально за кілька рядків.

Щоб картина стала зовсім конкретною, ось маленька таблиця порівняння:

До IoC (контроль усередині сервісу) Після IoC (контроль зовні)
Сервіс створює залежності через new Сервіс отримує залежності «готовими»
Рішення про реалізацію заховане всередині бізнес-методу Рішення видно там, де збирають застосунок
Важко підміняти поведінку без правки сервісу Поведінка змінюється заміною переданого обʼєкта
Тести часто впираються в «а як замінити залежність?» Тести легко підсовують фейкові залежності

5. IoC у чистій Java: створюємо зовні

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

Спочатку визначимо «роль» відправника. Так, це вже схоже на інтерфейси, але тут нам потрібен буквально один метод і один контракт — просто щоб показати: сервіс залежить від поведінки, а не від конкретного класу.

// Контракт: сервісу важливо "вміти надсилати", а не "бути консольним відправником"
interface NotificationSender {
    void send(String orderId);
}

Тепер зробимо реалізацію, яка пише в консоль.

class ConsoleNotificationSender implements NotificationSender {
    @Override
    public void send(String orderId) {
        // Реалізація деталі інфраструктури: куди саме надсилаємо сповіщення
        System.out.println("сповіщення " + orderId); // сповіщення ORD-1
    }
}

І перепишемо сервіс так, щоб він не створював відправника, а отримував його.

class OrderPlacementService {
    private final NotificationSender sender;

    // Впровадження через конструктор: залежність обовʼязкова для роботи сервісу
    OrderPlacementService(NotificationSender sender) {
        this.sender = sender;
    }

    void place(String orderId) {
        // Бізнес-дія: сервіс лише використовує залежність, не створюючи її
        sender.send(orderId);
    }
}

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

Залишилося перенести створення обʼєктів у точку входу. У нашому консольному застосунку це зазвичай main().

public class Main {
    public static void main(String[] args) {
        // Точка збирання: тут ми обираємо конкретні реалізації
        NotificationSender sender = new ConsoleNotificationSender();

        // Впроваджуємо залежність у сервіс явно
        OrderPlacementService service = new OrderPlacementService(sender);

        service.place("ORD-1"); // сповіщення ORD-1
    }
}

Саме тут і відбулася інверсія керування. Раніше OrderPlacementService керував тим, якого відправника створювати. Тепер цим рішенням керує Main, а сервіс просто працює з тим, що йому дали.

Щоб відчути, як це працює на практиці, додамо ще одну реалізацію: «email-відправник» (поки просто імітація).

class EmailNotificationSender implements NotificationSender {
    @Override
    public void send(String orderId) {
        // Інша інфраструктурна реалізація того самого контракту
        System.out.println("email клієнту: " + orderId); // email клієнту: ORD-1
    }
}

Тепер поведінка змінюється без зміни сервісу:

public class Main {
    public static void main(String[] args) {
        // Міняємо реалізацію — сервіс не чіпаємо
        NotificationSender sender = new EmailNotificationSender();
        OrderPlacementService service = new OrderPlacementService(sender);

        service.place("ORD-1"); // email клієнту: ORD-1
    }
}

Бізнес-клас не переписували ні на літеру. Ми просто передали іншу залежність. Ось у цей момент і починаєш поважати IoC не як «химерне слово», а як економію нервів.

6. IoC у ContextFlow: аудит і сповіщення

Давайте тепер акуратно приземлимо цю ідею в наш наскрізний проєкт ContextFlow. Візьмімо поки лише один зріз — сповіщення та аудит. Цього достатньо, щоб побачити саме перенесення контролю назовні, не тягнучи весь проєкт цілком. У сценарії «створити замовлення» особливо швидко видно: коли всі побічні дії збирати всередині сервісу, він перетворюється на «комбайн», який і бізнес робить, і інфраструктуру збирає сам.

Почнімо з мініверсії: нехай у нас є сповіщення й аудит (без сховища та знижок — це буде пізніше). Ми задамо дві ролі:

// Роль "аудит": сервісу важливо вміти писати аудит, а куди — вирішується ззовні
interface AuditWriter {
    void write(String message);
}

class ConsoleAuditWriter implements AuditWriter {
    @Override
    public void write(String message) {
        // Конкретна реалізація: пишемо аудит у консоль
        System.out.println("AUDIT: " + message); // AUDIT: створено ORD-1
    }
}

І тепер сервіс розміщення замовлення отримуватиме обидві залежності ззовні:

class OrderPlacementService {
    private final NotificationSender sender;
    private final AuditWriter auditWriter;

    // Обидві залежності обовʼязкові: без них сценарій "розмістити замовлення" неповний
    OrderPlacementService(NotificationSender sender, AuditWriter auditWriter) {
        this.sender = sender;
        this.auditWriter = auditWriter;
    }

    void place(String orderId) {
        // Бізнес-сценарій використовує залежності як інструменти, не створюючи їх
        sender.send(orderId);
        auditWriter.write("створено " + orderId);
    }
}

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

public class Main {
    public static void main(String[] args) {
        // Точка керування: обираємо реалізації (консольні) в одному місці
        NotificationSender sender = new ConsoleNotificationSender();
        AuditWriter auditWriter = new ConsoleAuditWriter();

        // Явно збираємо сервіс із залежностей
        OrderPlacementService service = new OrderPlacementService(sender, auditWriter);

        service.place("ORD-1");
        // сповіщення ORD-1
        // AUDIT: створено ORD-1
    }
}

Чому це важливо саме для ContextFlow? Тому що вибір реалізацій у цьому проєкті буде звичайною частиною життя. В одному режимі ми захочемо писати аудит у файл, в іншому — у консоль. Для сповіщень — аналогічно. Якщо сервіс обирає реалізації сам, у нас не буде нормального місця, де цей вибір видно й ним можна керувати. А IoC змушує зробити його явним — і це велика перемога над «магією в голові розробника».

7. Схема керування та розвантаження коду

Щоб закріпити ментальну модель, корисно подивитися на IoC не як на набір new, а як на зміну структури. Нижче — проста схема: ліворуч бізнес-клас «сам собі режисер», праворуч — бізнес-клас «актор», якому видали реквізит.

flowchart TD
    subgraph Before["До IoC: контроль усередині бізнес-класу"]
        S1["OrderPlacementService"] -->|new| N1["ConsoleNotificationSender"]
        S1 -->|new| A1["ConsoleAuditWriter"]
    end
flowchart TD
    subgraph After["Після IoC: контроль зовні"]
        M["Main / точка входу"] --> N2["ConsoleNotificationSender"]
        M --> A2["ConsoleAuditWriter"]
        M --> S2["OrderPlacementService"]
        S2 --> N2
        S2 --> A2
    end

Важливий нюанс: залежності OrderPlacementService не зникли. Він як залежав від сповіщень і аудиту, так і залежить. Але ми зробили дві важливі речі. По-перше, перестали ховати рішення про конкретні реалізації всередині сервісу. По-друге, виділили окреме місце, яке відповідає за збирання застосунку. Коли граф обʼєктів стає великим, його збирання вже хочеться автоматизувати. Поки нам достатньо навчитися не змішувати обовʼязки.

8. Типові помилки під час IoC

Помилка №1: вважати, що IoC починається лише після підключення Spring.
Дуже легко переплутати ідею та інструмент. Spring справді реалізує IoC на рівні контейнера, але сама інверсія керування починається в той момент, коли бізнес-клас перестає створювати свої залежності й починає отримувати їх ззовні. Це можна зробити й у чистій Java, і саме це ми сьогодні й робимо.

Помилка №2: переносити new у приватний метод того самого класу й думати, що стало краще.
Код виглядає чистішим, але зміст не змінюється. Точка керування все ще всередині сервісу. Якщо сервіс сам вирішує, яку реалізацію створювати, значить контроль не інвертовано — він просто схований глибше.

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

Помилка №4: плутати IoC із «передачею параметрів у метод».
Коли ви передаєте orderId у place(orderId), це вхідні дані виклику. IoC і DI стосуються довгоживучих залежностей класу: тих обʼєктів, без яких сервіс узагалі не може виконувати свою роботу (сповіщення, аудит, сховище тощо). Це різні речі, і змішувати їх — означає постійно плутатися, де у вас конфігурація, а де реальні дані сценарію.

Помилка №5: намагатися «вирішувати варіативність» усередині бізнес-методу через if/else і new.
Іноді хочеться написати: «якщо режим demo — створюємо Console…, якщо prod — Email…». Це повертає точку керування назад у бізнес-код і робить сервіс відповідальним за конфігурацію середовища. Навіть у маленькому навчальному застосунку це швидко приводить до каші. IoC якраз потрібен, щоб подібні рішення жили зовні й не захаращували сценарії.

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