🍃 Розділ 5 · Питання #2

В чому різниця між constructor, setter та field injection

В екосистемі Spring існує три основні варіанти передачі залежностей у бін: через конструктор, через сетер та безпосередньо у поле:


🟢 Junior Level

1. Три способи впровадження залежностей

В екосистемі Spring існує три основні варіанти передачі залежностей у бін: через конструктор, через сетер та безпосередньо у поле:

// 1. Constructor Injection (Впровадження через конструктор) — РЕКОМЕНДОВАНИЙ СТАНДАРТ
@Service
public class OrderService {
    private final OrderRepository repository;

    // У Spring 4.3+ анотація @Autowired над єдиним конструктором є необов'язковою
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

// 2. Setter Injection (Впровадження через сетер) — ДЛЯ ОПЦІОНАЛЬНИХ ЗАЛЕЖНОСТЕЙ
@Service
public class OrderService {
    private NotificationService notifier;

    @Autowired(required = false)
    public void setNotifier(NotificationService notifier) {
        this.notifier = notifier;
    }
}

// 3. Field Injection (Впровадження через рефлексію в поле) — АНТИПАТЕРН
@Service
public class OrderService {
    @Autowired
    private OrderRepository repository; // Впроваджується через Reflection API
}

2. Порівняльна таблиця способів

Критерій Constructor Injection Setter Injection Field Injection
Незмінність (final) ✅ Так (поля final) ❌ Ні ❌ Ні
Гарантія ініціалізації ✅ Об’єкт завжди валідний ⚠️ Можна забути викликати ⚠️ Поля null до ін’єкції
Чисті Unit-тести (new) ✅ Так (new Service(mock)) ⚠️ Вимагає ручних сетерів ❌ Вимагає Mockito/Рефлексії
Явність контракту ✅ Залежності видно в API ⚠️ Розмиті по методах ❌ Залежності приховані
Рекомендація Spring 🟢 Основний стандарт (99%) 🟡 Тільки для опціональних 🔴 Заборонений у Production

🟡 Middle Level

1. Фатальні недоліки Field Injection

Попри візуальну лаконічність, ін’єкція в поля через @Autowired офіційно визнана спільнотою та командою Spring шкідливим антипатерном:

  1. Порушення інкапсуляції та приховування залежностей:
    Клас із Field Injection має публічний конструктор без аргументів new OrderService(). Розробник, інстанціюючи такий клас у коді або тестах, очікує отримати робочий компонент, але при першому ж виклику бізнес-методу стикається з аварійним NullPointerException.
  2. Неможливість оголосити поля як final:
    Поля залишаються мутабельними. Посилання на залежність може бути ненавмисно або випадково змінене в рантаймі.
  3. Жорстка прив’язка до DI-контейнера в Unit-тестах:
    Щоб протестувати такий клас без завантаження повного важкого контексту Spring, розробнику доводиться використовувати інструменти глибокої рефлексії (ReflectionTestUtils.setField(...)) або магію @InjectMocks від Mockito.
  4. Маскування порушення принципу єдиної відповідальності (SRP):
    Коли в клас додають 10 полів з @Autowired, код виглядає оманливо компактним. Проте якщо переписати це на конструктор із 10 аргументами, розробник одразу помітить, що клас перевантажений обов’язками й вимагає декомпозиції на дрібніші сервіси.

2. Хронологія фаз ініціалізації біна

Типове джерело багів — нерозуміння послідовності, в якій Spring конструює об’єкт:

[ 1. Виклик конструктора ] ──► Constructor Injection
           │
           ▼
[ 2. Інстанціювання об'єкта ]
           │
           ▼
[ 3. Ін'єкція в поля ]     ──► Field Injection (через AutowiredAnnotationBeanPostProcessor)
           │
           ▼
[ 4. Виклик сетерів ]      ──► Setter Injection
           │
           ▼
[ 5. Виклик @PostConstruct ]──► Бін повністю зібраний, усі залежності доступні

[!WARNING] Якщо спробувати звернутися до поля з @Autowired всередині конструктора класу, ви гарантовано отримаєте NullPointerException, тому що в момент виконання конструктора об’єкт лише створюється в пам’яті, а Field Injection відбудеться пізніше!

@Service
public class ReportService {
    @Autowired
    private DataRepository repository;

    public ReportService() {
        // ❌ ПОМИЛКА: repository тут ВСЕ ЩЕ ДОРІВНЮЄ NULL!
        repository.loadInitialData(); // Викине NullPointerException
    }

    @PostConstruct
    public void init() {
        // ✅ ПРАВИЛЬНО: на цьому етапі всі поля гарантовано заповнені
        repository.loadInitialData();
    }
}

🔴 Senior Level

1. Архітектура під капотом: AutowiredAnnotationBeanPostProcessor

Впровадження в поля та сетери не є частиною поведінки компілятора Java — це робота спеціалізованого системного компонента Spring: AutowiredAnnotationBeanPostProcessor (BPP):

  1. Під час старту контексту Spring сканує метадані BeanDefinition цільового класу.
  2. Після того як фабрика створила сирий екземпляр класу через дефолтний конструктор, викликається метод postProcessProperties().
  3. BPP знаходить усі поля та методи з анотацією @Autowired, примусово відкриває до них доступ через рефлексію (Field.setAccessible(true) або MethodHandles.privateLookupIn()), знаходить відповідні біни в контексті та записує посилання безпосередньо в комірки пам’яті об’єкта.
  4. Це спричиняє накладні витрати на старті додатка та обходить правила безпеки JVM.

У випадку Constructor Injection контейнер знаходить потрібні залежності на етапі визначення конструктора (ConstructorResolver) і передає їх як прямі аргументи у Constructor.newInstance(args). Рефлексивного зламу інкапсуляції приватних полів не відбувається, а JVM гарантує безпечну публікацію (Safe Publication) final-полів згідно зі специфікацією JMM (Java Memory Model).


2. Поведінка при циклічних залежностях

  • Constructor Injection: якщо Сервіс $A$ вимагає в конструкторі Сервіс $B$, а Сервіс $B$ вимагає в конструкторі $A$, Spring фізично не може сконструювати жоден із них. Додаток падає в момент старту з помилкою BeanCurrentlyInCreationException. Це зразок принципу Fail-Fast: архітектурна помилка стає очевидною до розгортання в продакшені.
  • Field Injection: Spring може спочатку інстанціювати обидва сирі об’єкти через дефолтні конструктори, а потім за допомогою трирівневого кешу синглтонів перехресно заповнити поля. Проблема маскується, але може вистрілити в рантаймі при створенні проксі або зверненні до методів у @PostConstruct. Починаючи зі Spring Boot 2.6, такі цикли заборонені за замовчуванням.

3. Ідіоматичний підхід: Constructor Injection + Lombok

Щоб уникнути рутинного шаблонного коду (boilerplate) при оголошенні конструкторів, сучасним індустріальним стандартом є використання анотації @RequiredArgsConstructor бібліотеки Lombok:

@Service
@RequiredArgsConstructor
public class UserService {
    // Lombok автоматично згенерує конструктор для всіх final-полів:
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final EmailService emailService;
}

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

30-секундна відповідь (Elevator Pitch)

«У Spring розрізняють три типи DI:

  1. Constructor Injection — золотий стандарт сучасної розробки. Гарантує імутабельність (final), забезпечує повну ініціалізацію об’єкта, унеможливлює NullPointerException, спрощує unit-тестування без Spring і реалізує принцип Fail-Fast при виявленні циклічних залежностей.
  2. Setter Injection — застосовується виключно для опціональних залежностей через @Autowired(required = false).
  3. Field Injection — визнаний антипатерн: приховує залежності класу від клієнтського коду, блокує використання final, вимагає важкої рефлексії в тестах і маскує порушення SRP».

3 каверзних запитання з інтерв’ю та відповіді

1. Чому звернення до поля, позначеного @Autowired, всередині конструктора класу призводить до NullPointerException?

Відповідь: Життєвий цикл біна суворо поетапний. Спочатку викликається конструктор для створення об’єкта в купі (Heap). Лише після успішного завершення конструктора керування отримує AutowiredAnnotationBeanPostProcessor, який через Reflection API шукає поля з @Autowired і записує в них значення. Тому всередині самого конструктора ці поля ще не ініціалізовані й містять null. Для пост-ініціалізації слід використовувати метод з анотацією @PostConstruct.

2. Чи потрібно ставити анотацію @Autowired над конструктором у сучасних версіях Spring?

Відповідь: Починаючи зі Spring Framework 4.3, якщо бін має рівно один конструктор, анотація @Autowired є опціональною — Spring автоматично використовує цей конструктор і резолвить усі його параметри. Якщо ж у класі оголошено два або більше конструкторів, @Autowired необхідно явно вказати над тим, який контейнер має обрати для створення екземпляра.

3. У чому перевага Constructor Injection з точки зору Java Memory Model (JMM)?

Відповідь: Поля при ін’єкції через конструктор оголошуються як final. Згідно зі специфікацією JMM (§17.5 Final Field Semantics), після завершення конструктора значення final-полів гарантовано безпечно публікуються для всіх потоків без потреби у volatile чи синхронізації. При Field Injection поля залишаються мутабельними, що в умовах паралельного виконання створює ризики читання частково ініціалізованого стану іншими ядрами CPU.


Типові помилки та Red Flags

  • ❌ Використання @Autowired на приватних полях у новому коді: Свідчить про нерозуміння архітектурних принципів та низьку культуру тестування.
  • ❌ Спроба звернутися до інжектованої залежності всередині конструктора при Field Injection: Гарантований NullPointerException.
  • ❌ Використання Setter Injection для критично важливих залежностей: Створює ризик функціонування об’єкта в недоініціалізованому стані.
  • ❌ Роздування конструктора до 10–15 параметрів через @RequiredArgsConstructor: Хоча Lombok робить код візуально компактним, наявність десятка залежностей є прямим індикатором порушення SRP — клас слід розбити на кілька спеціалізованих сервісів.

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