1. Коли доречні setter/method injection
Якщо ви щойно закохалися в constructor injection (а це цілком нормальний етап дорослішання Spring-розробника), то setter injection і method injection можуть здаватися чимось на кшталт: «Навіщо нам друге кермо в машині, якщо перше працює?» Але реальна розробка інколи підкидає ситуації, коли конструктор або незручний, або недоступний. Тоді ці підходи стають охайним компромісом, а не «поганою звичкою».
І ще одне коротке уточнення щодо терміна: у цій лекції під method injection ми маємо на увазі @Autowired на довільному методі налаштування. lookup-method / @Lookup — інший, рідкісніший механізм, і зараз він нам не потрібен.
Почнемо з найважливішого: setter injection і method injection — це все ще Dependency Injection, лише точка впровадження інша. Замість «отримати все в момент народження обʼєкта» ми робимо так: «обʼєкт народжується, а потім окремим кроком отримує частину залежностей». Це називається двокрокова збірка обʼєкта, і вона має сенс, коли в класу є обовʼязковий каркас і додаткові налаштування або колаборатори, які логічно йдуть пізніше.
Побутовий приклад: ви купили стіл. Конструктор — це ніжки та стільниця. Без них стіл — не стіл. А от підставка під чашку, декоративна лампа й килимок для миші — це вже «донастроювання». Без них можна обійтися. Саме для такого «донастроювання» setter- і method-підходи найчастіше й доречні.
У нашому навчальному проєкті ContextFlow подібна логіка теж трапляється. У сервісів на кшталт «оформлюємо замовлення» обовʼязкові залежності мають бути як на долоні — у конструкторі. А ось у допоміжних інфраструктурних обʼєктів, наприклад для друку звіту, форматування або консольного виводу, інколи хочеться дати дефолтну поведінку та можливість акуратно перевизначити її через контейнер.
Щоб тримати в голові межу, корисно запамʼятати просте правило: якщо без залежності клас не може виконувати свою основну роботу, то це залежність конструктора. Якщо залежність лише змінює поведінку, а клас залишається життєздатним і без неї, або якщо вона належить до «налаштування», то це кандидат для method/setter injection.
2. Setter injection: механіка і її ціна
Setter injection працює просто: Spring створює обʼєкт, а потім викликає метод виду setXxx(...), передаючи туди відповідний бін. На рівні механіки це дуже схоже на присвоєння поля, але є важлива деталь: присвоєння відбувається після конструктора. Отже, певний час клас існує в стані «я вже народився, але ще не до кінця налаштований». Іноді це нормально, а іноді — прямий шлях до дивних багів.
Найпростіший навчальний приклад setter injection виглядає так:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ReportPrinter {
// Залежність зʼявиться ПІСЛЯ конструктора (у момент виклику сетера контейнером)
private ReportFormatter formatter;
@Autowired
public void setFormatter(ReportFormatter formatter) {
// Spring викличе цей метод після створення обʼєкта і передасть відповідний бін
this.formatter = formatter;
}
}
Зверніть увагу: формально ReportPrinter можна створити через new ReportPrinter(). Але якщо ви відразу викличете метод, який використовує formatter, то отримаєте або NullPointerException, або «порожню» роботу — залежно від того, як написано код. У цьому й полягає головна ціна setter injection: обʼєкт може існувати в некоректному стані, доки контейнер не завершить свою частину роботи.
Щоб показати це на пальцях, додамо мінімальну функціональність і подивимося, як легко «зламати собі ногу» під час ручного створення:
public class ReportPrinter {
// До інʼєкції це поле буде null
private ReportFormatter formatter;
public String print(String raw) {
// Якщо обʼєкт створено вручну (new), formatter може так і залишитися null → NPE
return formatter.format(raw);
}
}
Усередині Spring-контексту це зазвичай не проявляється: контейнер не віддасть вам бін, доки не завершить інʼєкцію. Але у звичайному Java-коді така проблема виникає легко. І ось тут важлива практична думка: setter injection найчастіше виправданий там, де ви контролюєте порядок життя обʼєкта, наприклад, коли обʼєкт створюється контейнером і використовується лише через контейнер.
Як зробити setter injection більш «безпечним» для життя? Один із простих прийомів — дати класу розумну дефолтну поведінку, щоб він міг жити без додаткового налаштування. Тоді setter injection перетворюється не на «зроби обʼєкт робочим», а на «покращ обʼєкт або налаштуй його».
Наприклад, нехай ReportPrinter уміє працювати за замовчуванням із простим форматуванням, але контейнер може підставити розумніший форматувач:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ReportPrinter {
// Дефолтна поведінка: обʼєкт життєздатний навіть без Spring-контейнера
private ReportFormatter formatter = new TextReportFormatter(); // резервний варіант
@Autowired
public void setFormatter(ReportFormatter formatter) {
// Якщо Spring знайшов бін форматувача, він «перевизначить» дефолт
this.formatter = formatter;
}
}
Так, тут є маленький new усередині класу, і ми не хочемо перетворювати це на стиль для бізнес-сервісів. Але як навчальна демонстрація для інфраструктурного допоміжного класу це корисно: ви бачите, чому setter injection інколи використовують для перевизначення значень, а не для обовʼязкових частин. Це саме варіант із безпечним початковим станом. Для обовʼязкової залежності після створення такий резервний варіант уже не годиться: там ціна двокрокової збірки має залишатися помітною.
Тепер важливе запитання: чому взагалі setter injection досі живе у світі, де є конструктори? Окрім optional-налаштувань, є ще два прагматичні мотиви.
Перший мотив — чужий або legacy-клас, який ви не можете змінити. Буває, що в обʼєкта немає зручного конструктора, зате є набір setter-методів, класичний JavaBeans-стиль. Якщо це стороння бібліотека, ви не можете просто взяти і додати конструктор із залежностями. Тоді Spring-конфігурація вимушено збирає обʼєкт за схемою «створили — налаштували».
Другий мотив — дуже перевантажений конструктор в інфраструктурного обʼєкта, де частина параметрів — це налаштування, а не залежності. Для сервісного шару довгий конструктор — сигнал, що клас робить забагато. А ось для якогось «менеджера виводу звітів» частина параметрів може бути справді конфігурацією: каталог, префікс файлів, режим перезапису тощо. У таких обʼєктів setter-підхід історично трапляється частіше.
Якщо вам хочеться сказати: «Ну тоді й у сервісах можна», згадайте попередню лекцію. Сервісний шар у проєкті — це місце, де ми хочемо максимальної передбачуваності. У ньому setter injection майже завжди програє за читабельністю.
3. Method injection: гнучке налаштування через методи
Method injection у нашому сьогоднішньому значенні — це коли Spring впроваджує залежність не через сетер за іменем, а через будь-який метод, позначений @Autowired. Технічно Spring усе одно викличе цей метод після створення обʼєкта; просто імʼя методу не зобовʼязане починатися з set. Ця маленька різниця дає великий плюс: ви можете назвати метод так, щоб він відображав сенс конфігурації, а не виглядав як звичайна зміна властивості.
Найпростіший приклад:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class NotificationGateway {
// Залежність, яку хочеться сприймати як «налаштували під час старту»
private NotificationSender sender;
@Autowired
public void configureSender(NotificationSender sender) {
// Це не «змінюємо щовівторка», а «один раз сконфігурували під час збірки біна»
this.sender = sender;
}
}
Це все ще впровадження через метод, але читається інакше. setSender(...) підсвідомо обіцяє, що відправника можна змінювати хоч щовівторка. А configureSender(...) каже: «це крок налаштування, і він має відбутися один раз під час збірки».
Друга корисна особливість method injection — метод може приймати кілька параметрів, і це інколи робить звʼязування ціліснішим. Наприклад, якщо ви хочете явно згрупувати налаштування в один крок і не плодити пʼять окремих setter-методів.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class AuditPipeline {
// Обидві залежності буде встановлено одним «конфігураційним» кроком
private AuditWriter writer;
private AuditMessageFormatter formatter;
@Autowired
public void configure(AuditWriter writer, AuditMessageFormatter formatter) {
// Spring підставить обидва біни сюди після виклику конструктора
this.writer = writer;
this.formatter = formatter;
}
}
Із погляду чистого DI-стилю обовʼязкові залежності все одно краще тримати в конструкторі. Але як прийом для інфраструктури це буває доречно: метод configure(...) явно показує, що це один етап збірки, а не оновлення полів де завгодно.
Тут важливо не переплутати корисний інструмент із новим стандартом. Method injection не замінює constructor injection, а закриває свою нішу: акуратне постконструкторне налаштування, чесніші імена та можливість згрупувати параметри в один крок.
Якщо ви помічаєте, що почали писати все через configure(), це тривожний сигнал. Зазвичай він означає, що ви або обходите нормальний дизайн класу, або намагаєтеся сховати обовʼязкові залежності від читача. У цей момент краще зупинитися і запитати себе: «А чому це не конструктор?»
4. Життєвий цикл біна та порядок інʼєкцій
Коли ви вперше бачите @Autowired на методі, виникає природне і дуже людське бажання запитати: «Гаразд, а коли Spring взагалі встигає його викликати?» Поки що не потрібно заглиблюватися в повний пайплайн контейнера, але базову картину корисно тримати в голові. Інакше setter/method injection сприйматиметься як магія, а не як передбачуваний крок.
У спрощеному вигляді життєвий шлях звичайного singleton-біна такий: контейнер створює обʼєкт, виконується конструктор, потім заповнюються залежності — і саме тут викликаються @Autowired-методи та сетери. Далі відбувається етап ініціалізації, і лише після цього бін вважається готовим та може нормально брати участь у роботі застосунку. Для нас сьогодні важливий один факт: конструктор завжди виконується раніше, а @Autowired-методи — пізніше.
Схематично це можна представити так:
flowchart TD
A["Створення обʼєкта (конструктор)"] --> B["Інʼєкція залежностей через @Autowired-методи та сетери"]
B --> C["Ініціалізація біна після інʼєкції"]
C --> D["Бін готовий і доступний іншим"]
Щоб буквально побачити порядок, можна зробити міні-демо з System.out.println(...). Це навчальний код, але він чудово знімає запитання: «Коли саме?»:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class WiringOrderDemo {
public WiringOrderDemo() {
// 1) Спочатку завжди виконується конструктор
System.out.println("1) конструктор");
}
@Autowired
public void setAuditWriter(AuditWriter writer) {
// 2) А вже потім Spring викликає @Autowired-методи
System.out.println("2) @Autowired-метод");
}
}
Якщо ви піднімете контекст і попросите цей бін, то побачите саме такий порядок. Головний практичний наслідок такий: method/setter injection — це не спосіб «пізніше, як-небудь» підставити залежність у процесі роботи застосунку. Це все ще частина процесу збірки біна. Різниця лише в тому, що збірка стала двокроковою: спочатку «народили», потім «дозібрали».
І ще один важливий наслідок: у межах Spring-контейнера інші біни не повинні побачити напівзібраний обʼєкт. Контейнер намагається не віддавати назовні бін, доки не закінчить інʼєкцію. Але якщо ви десь створите обʼєкт вручну (new) або викличете методи до завершення налаштування, контейнер вас уже не врятує. Тому setter/method injection любить дисципліну і не любить підходу «а давайте посеред роботи змінимо залежність».
5. Застосування в ContextFlow без шкоди сервісам
Якщо ми будуємо ContextFlow як охайний Spring-застосунок, то сервісний шар (OrderPlacementService, AuditService, ReportingService тощо) має бути максимально рівним: відкрили клас — побачили конструктор — зрозуміли залежності. Setter/method injection ми використовуємо точково, там, де це справді схоже на налаштування або підключення допоміжного модуля, а не на життєво важливу частину сценарію.
Тут важливо відокремити допоміжний клас із можливістю перевизначення налаштувань від обʼєкта, який справді розраховує на збірку після створення. Нижче ReportPrinter беремо у другому, суворішому варіанті: без резервного форматувача, тому поза контейнером такий обʼєкт потрібно налаштовувати вручну.
Візьмімо реалістичний приклад: у нас є форматування звіту через ReportFormatter, і ми хочемо окремо мати маленький компонент, який просто друкує звіт, у консоль або кудись ще. У навчальному проєкті це може бути інфраструктурний допоміжний клас, який не зобовʼязаний бути надсуворим у стилі «тільки через конструктор», але все одно має залишатися передбачуваним.
Спочатку — інтерфейс форматувача, короткий і без зайвих деталей:
public interface ReportFormatter {
// Контракт простий: на вхід сирий текст, на виході — відформатований рядок
String format(String raw);
}
Далі — проста реалізація:
import org.springframework.stereotype.Component;
@Component
public class TextReportFormatter implements ReportFormatter {
@Override
public String format(String raw) {
// Text block покращує читабельність багаторядкового шаблону
return """
[TEXT]
%s""".formatted(raw);
}
}
Тепер — наш ReportPrinter, який отримує форматувач через setter injection. Зверніть увагу на саму ідею: ReportPrinter можна уявити як тонку обвʼязку. Він не зобовʼязаний тягнути половину доменної логіки в конструктор, але має залишатися зрозумілим:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ReportPrinter {
// Важливо памʼятати: поле буде встановлено після конструктора
private ReportFormatter formatter;
@Autowired
public void setFormatter(ReportFormatter formatter) {
// Контейнер «дозбирає» обʼєкт на цьому кроці
this.formatter = formatter;
}
public void print(String raw) {
// Очікується, що метод викликається в межах Spring, де formatter уже впроваджено
System.out.println(formatter.format(raw));
}
}
Так, тут formatter не final. Це компроміс. І це нормально саме тому, що ReportPrinter — інфраструктурна деталь, а не центральний сервіс сценарію. При цьому, якби ReportPrinter був критичним для бізнес-сценарію, ми б повернулися до конструктора.
Тепер покажімо, де method injection може бути навіть «чеснішим» за змістом, ніж сетер. Припустімо, ScenarioRunner — це клас, який ми створюємо в конфігурації, а так часто роблять для точки входу сценаріїв. Він може мати додаткове налаштування: наприклад, підключити принтер звітів для демонстраційного виводу.
import org.springframework.beans.factory.annotation.Autowired;
public class ScenarioRunner {
// Залежність приходить окремим конфігураційним кроком після створення обʼєкта
private ReportPrinter reportPrinter;
@Autowired
public void enableReportPrinting(ReportPrinter reportPrinter) {
// Тут немає прихованого резервного варіанта: поза контейнером цей метод треба викликати вручну
this.reportPrinter = reportPrinter;
}
}
Тут важливі два моменти. По-перше, метод називається enableReportPrinting(...), а не setReportPrinter(...), і це зроблено навмисно: ми показуємо конфігураційний крок, а не зміну властивості де завгодно. По-друге, тут немає прихованого резервного варіанта: якщо ви створюєте ScenarioRunner вручну, метод налаштування треба викликати теж вручну. Для setter/method injection це чесна ціна — обʼєкт не стає готовим сам по собі.
Якщо вам хочеться зробити цей приклад ще більш «у межах курсу», можна зібрати ScenarioRunner через @Bean і залишити method injection всередині. Наприклад:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ScenarioConfig {
@Bean
public ScenarioRunner scenarioRunner() {
// Бін створюється вручну, але залежності все одно можуть бути впроваджені методами @Autowired
return new ScenarioRunner();
}
}
Саме так добре видно думку: спосіб реєстрації біна (@Component або @Bean) не скасовує того, що залежності можна впроваджувати в різних точках. Але одного new ScenarioRunner() недостатньо — потім контейнер має дозібрати обʼєкт через enableReportPrinting(...).
Із практичного погляду підсумок для проєкту такий: constructor injection залишається нормою для всього, що схоже на use-case-сервіс і без чого бізнес-сценарій не відбудеться. Setter/method injection ми залишаємо для невеликих інфраструктурних обʼєктів, адаптерів, «друкарок» і випадків, де налаштування справді йде окремим кроком та не псує розуміння класу.
6. Типові помилки під час setter/method injection
Setter/method injection легко використовувати на автоматі, особливо коли хочеться написати менше коду. Але в цього інструмента є кілька типових пасток, у які новачок потрапляє майже гарантовано — приблизно як NullPointerException, тільки більш творчо. Нижче — помилки, які особливо часто трапляються в навчальних проєктах і в реальному коді команд-початківців.
Помилка №1: перенесення обовʼязкових залежностей із конструктора в сетер «бо так коротше».
Це найнебезпечніша деградація стилю. У результаті клас перестає мати читабельний контракт, а обʼєкт може бути створений у неробочому стані. У сервісному шарі це майже завжди перетворює код на пазл «знайдіть залежності по всьому файлу». Якщо залежність обовʼязкова для роботи сценарію, їй місце в конструкторі.
Помилка №2: відсутність захисту від недоналаштованого стану.
Коли поле заповнюється через setter/method injection, воно майже неминуче стає null до моменту інʼєкції. Якщо ваш клас можна створити вручну, а в навчальному проєкті таке трапляється постійно, то перший же виклик бізнес-методу може призвести до NullPointerException. Або дайте дефолтне значення там, де це доречно, або робіть явну перевірку коректності стану і не соромтеся падати зі зрозумілим повідомленням про помилку.
Помилка №3: публічні setter-методи як частина справжнього API класу.
Якщо метод setXxx(...) публічний, будь-який код може почати викликати його й змінювати залежність під час виконання. Це перетворює ваш обʼєкт на сутність із непередбачуваним станом, і дебажити таке дуже весело — у поганому сенсі. Для конфігураційних методів краще бодай обмежувати видимість, наприклад package-private, і вибирати імена, які натякають на одноразове налаштування, а не на регулярні зміни.
Помилка №4: логіка і побічні ефекти всередині @Autowired-методу.
Дуже хочеться: «Раз метод усе одно викличеться під час старту, давайте там зробимо половину роботи». Так легко сховати важливі дії в неочевидному місці. В ідеалі @Autowired-метод робить просту річ: зберігає посилання на залежності і, максимум, виконує легке налаштування полів. Якщо ви бачите в такому методі складну логіку, цикли та роботу із зовнішнім світом, це майже завжди сигнал, що ви переплутали інʼєкцію з ініціалізацією.
Помилка №5: змішування всього з усім без єдиного стилю.
Коли в одному класі частина залежностей іде через конструктор, частина — через поля, частина — через сетери, а ще одна — через довільний configure(...), читання класу перетворюється на квест. У межах нашого курсу правило просте: service layer тримаємо на constructor injection, а setter/method injection використовуємо точково й усвідомлено для допоміжних випадків. Тоді проєкт читається передбачувано навіть новачком, який відкрив код уперше в житті, тобто майже всіма нами в перший робочий день.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ