1. Проблема: «створення об’єкта» стає логікою
Поки застосунок маленький, здається, що «створити об’єкт» — це просто new. Але щойно у вас з’являються вибір реалізації за конфігурацією, режими запуску, різні оточення і ще кілька «якщо», створення перетворюється на окрему логіку. І тут легко зробити хибний крок: запхати цю логіку всередину бізнес-сервісу та отримати важкий if-else просто в сценарії використання.
Уявімо частину нашого проєкту ContextFlow, пов’язану зі звітами. У нас є інтерфейс ReportFormatter, а реалізацій щонайменше дві: текстова (TextReportFormatter) і CSV-подібна (CsvReportFormatter). Формат обирається налаштуванням contextflow.report.format. Наївне рішення виглядає приблизно так: бізнес-сервіс читає налаштування і сам обирає реалізацію.
public class BadReportingService {
// Конфігурацію протягнуто прямо в бізнес-сервіс — це одна з проблем
private final String reportFormat;
public BadReportingService(String reportFormat) {
// Тепер сервіс змушений знати про формат і приймати його як параметр
this.reportFormat = reportFormat;
}
public String formatReport(DailyReport report) {
// Сервіс сам обирає реалізацію і сам її створює — «прихована фабрика» всередині сценарію використання
return "csv".equalsIgnoreCase(reportFormat)
? new CsvReportFormatter().format(report)
: new TextReportFormatter().format(report);
}
}
Код запускається, звіт формується — і на цьому етапі новачок зазвичай радіє. Але радість швидко минає: BadReportingService тепер знає про дві конкретні реалізації, тягне в себе конфігурацію, і в ньому з’являється «прихована фабрика». Якщо завтра додасться третій формат, сервіс розростеться ще більше. Якщо форматувачі стануть складнішими — із залежностями, ресурсами, локалізацією, — то «просто new» перетвориться на «new із великим причепом».
Правильна думка тут така: бізнес-сервіс має залежати від контракту, а не від логіки вибору реалізації. Отже, десь має з’явитися компонент створення, але так, щоб споживач і далі отримував звичайний ReportFormatter.
2. Три моделі: звичайний бін, @Bean, FactoryBean
Коли ви починаєте конфігурувати Spring-застосунок більш усвідомлено, з’являється кумедна плутанина: «Зачекайте, але @Bean-метод же теж фабрика… тоді навіщо ще якийсь FactoryBean?» Питання справедливе, і якщо не відповісти на нього зараз, далі будуть дивні рішення на кшталт «про всяк випадок усюди FactoryBean» (спойлер: так робити не треба).
Давайте дуже приземлено розкладемо три моделі поруч. Суть не в термінах, а в тому, що побачить споживач — клас, який отримує залежність, — і де житиме логіка створення.
| Модель | Що реєструється в контейнері під іменем X | Що отримує споживач при getBean("X") або автозв’язуванні | Де живе логіка створення | Коли це зазвичай доречно |
|---|---|---|---|---|
| Звичайний бін (клас-компонент) | Сам об’єкт X (наприклад, TextReportFormatter) | Той самий об’єкт X | У конструкторі/методах X (або майже немає) | Коли об’єкт «звичайний» і не потребує особливого сценарію створення |
| @Bean-метод | Результат методу X x(), позначеного @Bean | Результат цього методу, тобто X | У конфігураційному класі | Коли створення можна виразити простим Java-кодом у конфігу |
| FactoryBean<T> | Фабрика X, яка вміє виробляти T | Вироблений об’єкт T, а не фабрика | В окремому класі-фабриці | Коли логіка створення — окрема інфраструктурна відповідальність і її хочеться ізолювати |
І тут важливо не змішати FactoryBean ані зі звичайною фабричною логікою, ані з постпроцесорами у стартовому пайплайні. BeanFactoryPostProcessor і BeanPostProcessor втручаються в роботу контейнера ширше: змінюють метадані або обробляють уже створені біни. FactoryBean розв’язує більш локальну задачу — акуратно виробити один конкретний об’єкт і віддати його споживачу як звичайний бін.
Найважливіша відмінність FactoryBean від просто «фабричного класу» полягає в тому, що Spring підміняє об’єкт, який повертається. Для звичайного біна — «імʼя → об’єкт» безпосередньо. Для FactoryBean — «імʼя → об’єкт-результат», а сам об’єкт-фабрика ховається за лаштунками (але дістати його можна — ми ще це покажемо).
Якщо проводити аналогію, то @Bean-метод — це коли ви вдома на кухні кажете: «Зараз я зроблю каву» — і робите. А FactoryBean — це коли у вас стоїть кавомашина: ви натискаєте кнопку «лате», а вона сама вирішує, як саме гріти молоко і скільки секунд наливати еспресо. Вам важливо отримати лате, а не доступ до внутрішностей кавомашини (і, будь ласка, не розбирайте кавомашину викруткою в продакшені).
3. Модель FactoryBean: фабрика і продукт
Із FactoryBean корисно відразу побудувати дуже конкретну модель у голові, інакше він сприймається як «ще одна анотація/інтерфейс». Насправді це особлива домовленість: контейнер бачить, що бін реалізує FactoryBean<T>, і починає ставитися до нього як до виробника об’єктів. При цьому за іменем біна ви зазвичай отримуєте не фабрику, а продукт.
flowchart TD
A["Опис біна: reportFormatter (FactoryBean)"] --> B["Створюється ReportFormatterFactoryBean"]
B --> C["Контейнер викликає getObject()"]
C --> D["Отримується ReportFormatter (Text/Csv)"]
D --> E["Споживач отримує ReportFormatter через DI"]
Терміни, які ми використовуватимемо далі, прості та корисні:
FactoryBean-об’єкт — це об’єкт-фабрика, тобто сам бін, який реалізує FactoryBean<T>.
Об’єкт, що повертається з getObject(), — це об’єкт-результат (produced object), тобто T.
І ось тут ключовий «фокус» Spring: коли якийсь інший бін хоче ReportFormatter, контейнер дивиться: «Ага, у мене є reportFormatter, але це фабрика. Зате вона обіцяє ReportFormatter — значить, підійде».
Для цього Spring має розуміти, який тип виробляє фабрика. Саме тому в FactoryBean є метод getObjectType(). Він важливий не для краси, а для того, щоб контейнер міг розв’язувати залежності до фактичного створення об’єкта-результату й не губитися під час побудови графа.
Ще один важливий момент: об’єкт-результат, який поверне FactoryBean, в очах споживача — звичайний Spring-бін. Це означає, що він теж може пройти через BeanPostProcessor (наприклад, наш діагностичний TrackedComponentBeanPostProcessor). Ми не підемо в тонкощі «як саме», але важливо розуміти: FactoryBean не «обходить» контейнер, а вбудований у нього.
4. Контракт FactoryBean<T>: 3 методи
У Spring-екосистемі легко втонути в API, якщо намагатися вивчити все «про всяк випадок». Тому будемо прагматичними: для базового розуміння FactoryBean нам достатньо трьох речей — уміти створити об’єкт (getObject()), уміти сказати контейнеру «що я створюю» (getObjectType()) і уміти пояснити, чи буде це singleton (isSingleton()). Усе інше сьогодні нам не потрібно.
Почнемо з найголовнішого: як фабрика створює об’єкт-результат. Для нашого кейсу це вибір між CSV і текстом.
import org.springframework.beans.factory.FactoryBean;
public class ReportFormatterFactoryBean implements FactoryBean<ReportFormatter> {
// Налаштування фабрики: який форматувач потрібно виробити (зазвичай надходить із properties)
private String format = "text";
public void setFormat(String format) {
// Setter зручний для «налаштування» фабрики з конфігурації, не втягуючи це в бізнес-шар
this.format = format;
}
}
Зверніть увагу на стиль: ми робимо setter для параметра format. Чому не конструктор? Можна і конструктор, але setter тут спеціально навчально зручний: він показує, що фабрику можна «налаштувати» з конфігурації, не втягуючи це в бізнес-шар.
Тепер ядро — getObject(). Тут важливо не влаштовувати бізнес-рішення, а рівно вибрати й створити потрібний об’єкт.
@Override
public ReportFormatter getObject() {
// Логіка фабрики має бути інфраструктурною: вибір реалізації за конфігурацією
if ("csv".equalsIgnoreCase(format)) {
// Повертаємо продукт — конкретну реалізацію інтерфейсу
return new CsvReportFormatter();
}
// Дефолтний варіант, якщо формат не csv
return new TextReportFormatter();
}
Наступний метод — getObjectType(). Він здається нудним, доки ви не ловите дивні помилки розв’язування залежностей. Контейнеру потрібно розуміти: «Якщо мене просять ReportFormatter, я можу це дати».
@Override
public Class<?> getObjectType() {
// Важливо повернути контракт об’єкта-результату, щоб контейнер міг розв’язувати залежності за типом
return ReportFormatter.class;
}
І нарешті, семантика singleton. За замовчуванням багато фабрик поводяться як singleton-виробники, тобто створюють один об’єкт-результат на контейнер, але краще бути явними, щоб не гадати. Для нашого форматувача singleton цілком логічний: форматувачі зазвичай без стану.
@Override
public boolean isSingleton() {
// true => об’єкт-результат буде створено один раз і кешовано контейнером
return true;
}
Окремо проговорю важливу інженерну звичку. Якщо значення format прийшло некоректно, у вас є два нормальні шляхи: або обрати зрозумілий дефолт (text) і залогувати попередження, або fail-fast упасти на старті й кинути виняток. У навчальному проєкті часто корисніше саме fail-fast, тому що помилка конфігурації — це не «ой, потім розберемося», а причина дивної поведінки.
5. Реєстрація FactoryBean у конфігурації ContextFlow
Тепер давайте приземлимо це в наш проєкт. За архітектурною дисципліною ми не хочемо, щоб ReportingService знав хоч щось про FactoryBean. Його світ має бути простим: «мені дали ReportFormatter, я форматую звіт». Тому FactoryBean ми реєструємо в конфігураційному шарі, а параметр format подаємо з properties, які ми вже вміємо читати.
Припустімо, у нас у src/main/resources/contextflow.properties є налаштування:
contextflow.report.format=csv
Реєструємо фабрику як бін. Тут важливий момент: ім’я біна (наприклад, reportFormatter) використовуватиметься споживачами як ім’я об’єкта-результату. Але в @Bean-методі ми створюємо саме фабрику.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReportingConfig {
@Bean
public ReportFormatterFactoryBean reportFormatter(
// Значення беремо з конфігурації (properties / env)
@Value("${contextflow.report.format}") String format) {
// У контейнері реєструється FactoryBean, але за іменем біна віддаватиметься об’єкт-результат
ReportFormatterFactoryBean factory = new ReportFormatterFactoryBean();
// Налаштовуємо фабрику параметром із конфігурації
factory.setFormat(format);
return factory;
}
}
А тепер найприємніше: споживач взагалі не бачить фабрику. Він просто отримує ReportFormatter.
public class ReportingService {
private final ReportFormatter formatter;
public ReportingService(ReportFormatter formatter) {
this.formatter = formatter;
}
}
Якщо ви зараз відчуваєте легке внутрішнє здивування: «Зачекайте, але як же Spring зрозумів, що ReportingService потрібен об’єкт-результат?», — це якраз те, заради чого ми й написали getObjectType(). Контейнер розв’язує залежність за типом і бачить, що фабрика обіцяє ReportFormatter.
І ось це ключовий сенс FactoryBean: логіка створення схована й ізольована, а споживачі залишаються чистими та незалежними від контейнера. Так, це звучить як «чиста архітектура», але насправді це просто нормальна гігієна проєкту, щоб у майбутньому не плакати.
6. Корисні нюанси FactoryBean
Доступ до фабрики: префікс &
Із FactoryBean виникає природне запитання: «Окей, а якщо мені раптом потрібна сама фабрика?» Зазвичай відповідь така: «краще не потрібна». Але в діагностиці, налагодженні або навчальних експериментах це буває корисно. Spring дає спеціальну домовленість: якщо перед іменем біна поставити &, ви отримаєте об’єкт-фабрику, а не об’єкт-результат.
Покажемо це на маленькому фрагменті коду. Припустімо, у нас є ApplicationContext ctx.
import org.springframework.context.ApplicationContext;
public class FactoryBeanProbe {
public static void probe(ApplicationContext ctx) {
// За іменем біна отримуємо об’єкт-результат
Object product = ctx.getBean("reportFormatter");
// За іменем із префіксом & отримуємо сам FactoryBean
Object factory = ctx.getBean("&reportFormatter");
System.out.println(product.getClass().getName()); // ...CsvReportFormatter
System.out.println(factory.getClass().getName()); // ...ReportFormatterFactoryBean
}
}
Ця штука чудово допомагає мозку клацнути: під тим самим іменем під час звичайного звернення ви отримуєте об’єкт-результат, а фабрика доступна окремим службовим способом.
Але тут важливо не зробити хибний висновок. Якщо ви починаєте думати: «Ага, значить я можу тепер у сервісі смикати ctx.getBean("&reportFormatter") і перебирати форматувачі», — ви майже гарантовано їдете в бік запаху service locator (ми про нього вже говорили). Тобто так, & існує, але це інструмент для інфраструктури та діагностики, а не щоденний патерн бізнес-коду.
Семантика isSingleton() для об’єкта-результату
Коли в застосунку з’являється FactoryBean, у новачків часто починається «подвійна бухгалтерія»: один singleton, другий singleton, а хто з них узагалі singleton? І тут легко заплутатися, бо в нас справді два об’єкти: фабрика і продукт. І вони можуть мати різну семантику створення.
isSingleton() відповідає не за те, чи singleton сама фабрика (вона зазвичай singleton як звичайний бін), а за те, чи singleton об’єкт-результат. Якщо isSingleton() повертає true, контейнер створює об’єкт-результат один раз і кешує. Якщо false, Spring викликатиме getObject() щоразу, коли хтось запросить цей бін.
Для форматувача в ContextFlow майже завжди хочеться true. Форматувач — без стану, його не потрібно плодити. Плюс так простіше: один об’єкт, передбачувана поведінка, менше сюрпризів.
Якщо ж ви повернете false, об’єкт-результат стане схожим на prototype. І тут починають працювати ті самі правила, що ми обговорювали в темі scopes. Зокрема, якщо об’єкт-результат справді створюється щоразу заново, контейнер не зможе красиво керувати його знищенням так само, як для singleton. У навчальному проєкті краще не ускладнювати собі життя й тримати об’єкт-результат singleton-ом, якщо немає серйозної причини інакше.
Ще одна тонкість: якщо getObjectType() повертає null або надто загальний тип, контейнеру складніше розв’язувати залежності, особливо на етапі старту. Іноді це призводить до дивних помилок «бін не знайдено», хоча він начебто десь є. Тому хороший стиль — завжди повертати максимально точний контракт об’єкта-результату: у нашому випадку ReportFormatter.class.
7. Приклад: звіти без if-else у сервісі
Тепер зберемо все в цілісну картину проєкту. Наша мета не в тому, щоб «зробити FactoryBean заради FactoryBean», а в тому, щоб покращити дизайн: ReportingService залежить тільки від ReportFormatter, а вибір реалізації живе окремо — в інфраструктурі та конфігурації. У результаті бізнес-шар залишається тонким і зрозумілим, а налаштування змінюють поведінку без переписування сервісів.
Для контексту нагадаю, що ReportFormatter — простий контракт: отримали DailyReport, повернули рядок.
public interface ReportFormatter {
// Контракт форматувача: перетворюємо звіт на рядок (наприклад, текст або CSV)
String format(DailyReport report);
// Тут свідомо String: так простіше писати у файл/консоль без зайвої інфраструктури
}
А ось «точка правди» щодо вибору реалізації тепер одна — фабрика.
@Override
public ReportFormatter getObject() {
if ("csv".equalsIgnoreCase(format)) {
return new CsvReportFormatter();
}
return new TextReportFormatter();
}
Щоб побачити ефект наочно, зручно тимчасово вивести в діагностиці, який саме форматувач потрапив у сервіс. Наприклад, у сценарії генерації звіту або в ScenarioRunner, якщо він у вас є.
public class ReportingScenario {
private final ReportFormatter formatter;
public ReportingScenario(ReportFormatter formatter) {
// У сценарій потрапляє вже вибрана реалізація, а не фабрика
this.formatter = formatter;
}
public void run() {
// Найпростіша діагностика: яка саме реалізація була впроваджена
System.out.println("Форматувач = " + formatter.getClass().getSimpleName());
// Форматувач = CsvReportFormatter
}
}
Після цього ви можете просто змінити contextflow.report.format=text і перезапустити застосунок. Жодних правок ReportingService, жодних нових if-else, жодних «а де ще обирається формат?». Тільки конфігурація і одна фабрика.
І це дуже по-дорослому: бізнес-код залишається стабільним, а варіативність оточення та складання контролюється там, де їй і місце, — у конфігурації та інфраструктурі контейнера.
8. Типові помилки при роботі з FactoryBean
З FactoryBean найчастіше помиляються не тому, що він «складний», а тому, що мозок за звичкою думає: «bean name → об’єкт». А тут раптом «bean name → об’єкт, який зробив інший об’єкт». Тому помилки зазвичай із розряду «переплутав фабрику і продукт» або «протягнув інфраструктуру в бізнес».
Помилка №1: споживач починає залежати від фабрики, а не від об’єкта-результату.
Іноді розробник бачить ReportFormatterFactoryBean і вирішує: «О, класно, впроваджу-но я його в ReportingService, а там буду викликати getObject()». Формально це працює, але ви руйнуєте ідею. Сервіс тепер знає про Spring-інфраструктуру і про спосіб створення, а ще потенційно починає створювати собі нові екземпляри під час виконання. Правильний шлях — залежати від ReportFormatter, а фабрику залишити в support.factory і конфігурації.
Помилка №2: getObjectType() повертає null або надто загальний тип.
Якщо ви скажете контейнеру «я не знаю, що створюю» (повернете null) або повернете Object.class, Spring гірше розв’язуватиме залежності. У кращому разі зв’язування стане менш передбачуваним, у гіршому — ви отримаєте дивні помилки на старті. Для навчального проєкту тримайте правило: об’єкт-результат має бути зрозумілим за контрактом, отже getObjectType() повертає конкретний інтерфейс (ReportFormatter.class).
Помилка №3: FactoryBean перетворюється на «мінібізнес-двигун».
У FactoryBean дуже спокуслива роль: «Він же створює, значить нехай ще вирішить, який формат кращий для VIP-клієнта, і нехай ще порахує знижку…». Так ви непомітно запихаєте бізнес-логіку туди, де їй не місце, і далі це буде складно тестувати й пояснювати. Фабрика має займатися тільки створенням або вибором об’єкта за конфігурацією, без доменних рішень.
Помилка №4: isSingleton() не осмислений, і getObject() починає викликатися неочікувано часто.
Якщо об’єкт-результат дорогий у створенні або ви просто не очікуєте, що його будуть пересоздавати, але isSingleton() повертає false, ви отримаєте зайві створення об’єкта. Іноді це проявляється як «чому у мене в логах десять разів створюється форматувач?». У нашому кейсі true майже завжди правильніше, а якщо вам справді потрібен «новий екземпляр щоразу» — це має бути усвідомлене рішення, а не випадковість.
Помилка №5: спроба використовувати &beanName як повсякденний спосіб доступу до контейнера.
Префікс & потрібен, щоб інколи дістати фабрику для діагностики або інфраструктурного налаштування. Але якщо ви починаєте використовувати ctx.getBean("&something") всередині бізнес-методів, ви непомітно повернетеся до підходу service locator, тільки тепер із додатковою «магією». Бізнес-код має бути нудним: конструктор, залежності, виклики методів. Усе.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ