📐 Розділ 18 · Питання #16

Як принцип Dependency Inversion пов'язаний з Dependency Injection

Зв'язка принципу інверсії залежностей (DIP) та впровадження залежностей (DI) — це класичне співвідношення «Стратегія і Тактика»:


🟢 Junior Level

Зв’язка принципу інверсії залежностей (DIP) та впровадження залежностей (DI) — це класичне співвідношення «Стратегія і Тактика»:

  • DIP (Dependency Inversion Principle) — це МЕТА («Що робити»): Архітектурне правило SOLID, яке вимагає, щоб бізнес-логіка не залежала від конкретних деталей (баз даних, шлюзів, сторонніх бібліотек), а спиралася на стабільні контракти (інтерфейси).
  • DI (Dependency Injection) — це ІНСТРУМЕНТ («Як робити»): Технічний патерн проєктування, за якого залежності передаються об’єкту ззовні (через конструктор або сетер), замість того щоб об’єкт сам створював їх через оператор new.

Життєва аналогія

  • DIP (Стандарт): У вашій квартирі встановлені стандартні водопровідні різьбові виходи (абстракція). Ви не приварюєте пральну машину намертво до труби центрального водопостачання.
  • DI (Впровадження): Сантехнік приходить і фізично з’єднує гнучкий шланг вашої пральної машини з цим стандартним виходом (впроваджує ресурс ззовні).

Код: як DI реалізує DIP на практиці

Порушення: Немає DIP і немає DI (Жорстка прив’язка через new):

public class OrderService {
    // Сервіс сам створює конкретну реалізацію — ми прив'язані до Postgres!
    private final PostgresOrderRepository repository = new PostgresOrderRepository();

    public void process(Order order) {
        repository.save(order);
    }
}

Дотримання: DIP через конструкторний DI:

public class OrderService {
    // 1. DIP: залежимо від абстрактного інтерфейсу, а не від конкретного класу Postgres
    private final OrderRepository repository;

    // 2. DI: реалізація передається ззовні через конструктор (Constructor Injection)
    public OrderService(OrderRepository repository) {
        this.repository = Objects.requireNonNull(repository, "repository must not be null");
    }

    public void process(Order order) {
        repository.save(order);
    }
}

🟡 Middle Level

Термінологічна карта: DIP vs DI vs IoC

На співбесідах рівня Middle критично важливо не плутати три споріднені концепти:

Поняття Категорія Що означає Приклад
DIP Архітектурний принцип (SOLID) Залежності повинні бути спрямовані в бік абстракцій. Інтерфейс належить модулю, що викликає. OrderService оголошує OrderRepository, інфраструктура його реалізує.
IoC (Inversion of Control) Загальний патерн архітектури Передача управління потоком виконання та життєвим циклом зовнішньому каркасу (Framework). Spring ApplicationContext сам сканує, створює біни та викликає методи життєвого циклу (@PostConstruct).
DI Патерн реалізації IoC Конкретний механізм: об’єкт пасивний, залежності передаються йому ззовні. Передача параметрів у конструктор: new OrderService(repo).
                     ┌──────────────────────────────────────────────┐
                     │          IoC (Inversion of Control)          │
                     │  (Загальний принцип: каркас керує ходом ПЗ)  │
                     └──────────────────────┬───────────────────────┘
                                            │ включає в себе
                                            ▼
┌────────────────────────────────┐   ┌────────────────────────────────┐
│ DIP (Архітектурна мета SOLID)  │◄──┤   DI (Технічний інструмент)    │
│   (Залежати від абстракцій)    │   │ (Передача залежностей ззовні)  │
└────────────────────────────────┘   └────────────────────────────────┘

Порівняння способів Dependency Injection у Spring

У Java-екосистемі існують три способи впровадження залежностей:

  1. Constructor Injection (Конструкторне впровадження — Золотий стандарт):
    @Service
    public class PaymentService {
        private final PaymentGateway gateway; // final: незмінність та safe publication!
    
        public PaymentService(PaymentGateway gateway) {
            this.gateway = gateway;
        }
    }
    
    • Плюси: Поля позначаються як final, захист від NullPointerException (fail-fast під час старту), легке тестування на чистому JUnit/Mockito без підняття Spring.
  2. Setter Injection (Впровадження через сетери):
    • Застосовується виключно для опціональних залежностей, які можуть динамічно змінюватися в рантаймі або мати дефолтні значення.
  3. Field Injection (@Autowired на приватні поля — Антипатерн):
    • Впровадження через рефлексію безпосередньо в private поля.
    • Мінуси: Неможливо оголосити поле final, неможливо протестувати клас без Spring або виклику повільної рефлексії Mockito, приховані залежності, провокування розростання монолітних класів.

DI проти Service Locator

Альтернативою DI тривалий час виступав патерн Service Locator:

// Service Locator (Антипатерн)
public class OrderService {
    public void process(Order order) {
        // Прихована залежність: клас таємно лізе в глобальний реєстр
        OrderRepository repo = ServiceLocator.getContext().getBean(OrderRepository.class);
        repo.save(order);
    }
}

Чому DI переміг Service Locator:

  • У DI всі залежності класу явно задекларовані в сигнатурі конструктора (Explicit Dependencies Principle).
  • Service Locator маскує залежності: клас виглядає порожнім, але падає в рантаймі з помилкою біна, що не знайшовся, під час звернення до глобального статичного контексту.

🔴 Senior Level

Апаратний вплив Constructor Injection: JMM та Safe Publication

З точки зору моделі пам’яті Java (Java Memory Model, JSR-133 / JLS §17.5) використання Constructor Injection з final-полями надає критично важливі системні гарантії:

  1. Семантика final полів (Freeze Action):
    • Наприкінці виконання конструктора процесор та компілятор вставляють спеціальний бар’єр пам’яті (StoreStore-бар’єр / клямку).
    • Це гарантує Safe Publication: посилання на об’єкт, переданий у конструктор і збережений у final полі, гарантовано видно всім іншим потокам повністю ініціалізованим без необхідності ручної синхронізації (synchronized або volatile).
  2. JIT Inlining & Devirtualization:
    • Компілятор C2 HotSpot бачить модифікатор байткоду ACC_FINAL у поля.
    • При мономорфному виклику JIT виконує Class Hierarchy Analysis (CHA) і повністю усуває оверхед віртуального виклику інтерфейсу, інлайнячи машинний код методу прямо в точку виклику.
    • При Field Injection (@Autowired на не-final поле) компілятор змушений зберігати safepoint-перевірки на випадок, якщо поле буде перезаписане рефлексією.

Pure DI (Ручний DI) проти Reflection-based Containers

У високонавантажених та Serverless-середовищах Senior-архітектор оцінює компроміси контейнерів впровадження залежностей:

Критерій Reflection-based (Spring Framework) Compile-time DI (Micronaut, Dagger) Pure DI (Без фреймворків)
Механізм зв’язування Пошук анотацій через рефлексію, створення CGLIB/JDK динамічних проксі в рантаймі. Генерація фабричних класів (*_Factory.java) процесором анотацій під час javac. Звичайні виклики new у корені збірки (Composition Root / ApplicationMain).
Час старту (Cold Start) Повільний (0.8–5 секунд на граф із 1500 бінів). Не підходить для AWS Lambda. Миттєвий (20–60 мілісекунд). Миттєвий (1–5 мілісекунд).
Витрата Heap пам’яті Висока (+15–30 МБ на збереження BeanDefinition, рефлексивних метаданих). Нульова (тільки самі екземпляри класів). Нульова.
Підтримка GraalVM Native Вимагає генерації JSON-конфігурацій рефлексії (Spring Boot 3 AOT). Працює з коробки. Працює з коробки ідеально.

Циклічні залежності (Circular Dependencies) як маркер деградації DIP

Якщо сервіс OrderService вимагає CustomerService, а CustomerService вимагає OrderService:

  • При Constructor Injection Spring негайно викидає BeanCurrentlyInCreationException під час старту (Fail-fast).
  • Розробники-початківці часто обходять цю помилку додаванням анотації @Lazy або переходом на Field Injection.
  • Позиція Senior: Цикл залежностей означає, що порушено принцип DIP та SRP. Модулі верхнього рівня перестали бути незалежними. Рішення:
    1. Виділення спільного інтерфейсу або третьої сутності-посередника (патерн Mediator).
    2. Перехід на асинхронну шину подій через публікацію Domain Events (ApplicationEventPublisher).

4 Tricky Questions

1. Чи можна дотримуватися Dependency Inversion Principle (DIP) без використання Dependency Injection (DI)?

Відповідь: Так, абсолютно. DIP — це архітектурний принцип, що визначає напрямок зв’язків у вихідному коді (високий рівень залежить від абстракції, а не від деталі). Реалізувати DIP можна без DI, використовуючи інші патерни:

  • Factory Method / Abstract Factory: клас залежить від абстрактного інтерфейсу фабрики і запитує в неї створення об’єкта.
  • Service Provider Interface (Java SPI): клас запитує реалізацію абстрактного інтерфейсу через java.util.ServiceLoader.load(MyContract.class), не використовуючи ін’єкцію залежностей ззовні. Проте DI є найбільш чистим, передбачуваним та зручним способом втілення DIP.

2. Чи може використання Dependency Injection (DI) порушувати Dependency Inversion Principle (DIP)?

Відповідь: Так, і це часта помилка в проєктах-початківцях зі Spring. Якщо клас сервісу використовує впровадження через конструктор (DI), але впроваджує конкретний інфраструктурний клас, а не інтерфейс:

@Service
public class OrderService {
    private final PostgresOrderRepository repository; // Конкретний клас!
    public OrderService(PostgresOrderRepository repository) { this.repository = repository; }
}

Тут застосовано чистий патерн Dependency Injection, проте принцип Dependency Inversion грубо порушено: модуль бізнес-логіки жорстко зав’язаний на конкретну реалізацію бази даних нижнього рівня.


3. Де в чистій архітектурі має відбуватися збірка графа залежностей (Composition Root)?

Відповідь: Збірка графа залежностей повинна відбуватися в одній централізованій точці на найзовнішнішому периметрі застосунку, яка називається Composition Root (Корінь Композиції). У Spring Boot застосунку Composition Root — це модуль запуску програми (@SpringBootApplication та конфігураційні класи @Configuration). У чистій Java без фреймворків — це метод public static void main(String[] args). Жодні бізнес-сервіси та доменні класи не мають права займатися створенням або пошуком інших сервісів: вони отримують уже готові екземпляри на етапі ініціалізації.


4. Чому Field Injection (@Autowired на приватні поля) заборонений до використання в production-коді сучасними гайдлайнами?

Відповідь:

  1. Неможливість створення final полів: компілятор забороняє неініціалізовані final поля, що позбавляє об’єкт гарантій безпечної багатопотокової публікації (Safe Publication) за Java Memory Model.
  2. Порушення принципу явних залежностей (Explicit Dependencies): клас можна помилково створити через new MyService() у тестах, і він успішно скомпілюється, але впаде з NullPointerException при першому ж виклику методу.
  3. Приховане заохочення порушення SRP: розробник не помічає, як клас розростається до 15–20 залежностей, оскільки додати анотацію @Autowired психологічно легко, на відміну від конструктора на 20 аргументів, що роздувається.

🎯 Шпаргалка для інтерв’ю

Головні тези (30 секунд на відповідь):

  • Відмінність: DIP — це архітектурне правило («залежати від інтерфейсів, а не від класів деталей»). DI — це технічний шаблон («передавати залежності через конструктор ззовні»).
  • Формула успіху: DIP задає абстракцію, DI пов’язує реалізацію зі споживачем.
  • Золотий стандарт: Constructor Injection із final-полями.
  • Антипатерни: Field Injection (@Autowired на приватних полях), Service Locator (приховані глобальні залежності).
  • Багатопотоковість: final поля в Constructor Injection забезпечують JMM Safe Publication без додаткових блокувань.

Чого уникати (Red Flags):

  • ❌ Стверджувати, що DIP і DI — це синоніми.
  • ❌ Вважати, що застосування @Autowired на конкретний клас задовольняє DIP (DI є, але DIP порушено).
  • ❌ «Лікувати» циклічні залежності анотацією @Lazy замість архітектурного рефакторингу.

Пов’язані теми