1. Bean — керований контейнером об’єкт
Після першого запуску Spring-контексту майже в усіх виникає те саме бажання: «О, контейнер уміє створювати об’єкти — отже, доручімо йому взагалі все!». Порив зрозумілий — приблизно як після купівлі шуруповерта почати ним мішати суп. У цьому розділі спокійно розберемося, що таке bean, чим він відрізняється від звичайного об’єкта і чому слово «bean» говорить не про «крутість» класу, а про роль об’єкта в збиранні застосунку.
У Spring bean — це конкретний об’єкт (instance), який створює контейнер і яким він керує (ApplicationContext). У контейнера на цей об’єкт є цілком конкретні плани: створити його в потрібний момент, впровадити залежності, пізніше викликати колбеки життєвого циклу й узагалі вважати його частиною «штатної структури застосунку».
Одразу зафіксуймо думку, від якої новачкам зазвичай легше дихати: bean не обов’язково має бути особливим класом. Це може бути найзвичайніший POJO — без успадкування від Spring-класів, без магії, без інтерфейсів SomethingAware і без «extends FrameworkMonster». Bean — це не «тип класу», а статус об’єкта: контейнер знає про нього і керує ним.
Якщо спуститися до побутової аналогії, то контейнер — це «відділ кадрів», а beans — «штатні співробітники». Контейнер знає, хто кому підпорядковується (залежності), кого треба найняти першим (порядок створення) і хто взагалі потрібен, щоб застосунок працював. А ось предметні об’єкти на кшталт Order — не співробітники, а «документи», які співробітники створюють і опрацьовують у процесі роботи. Відділ кадрів не заводить на кожен документ окрему особову справу.
Корисне спостереження, яке стане в пригоді вже сьогодні: контейнер керує bean-ами через BeanDefinition. Тобто до того, як об’єкт став bean-ом, у контейнері з’являється його опис: «ось такий клас (або фабричний метод), ось такі залежності». А якщо об’єкт створюється через new просто у вашому коді, контейнер про нього просто не дізнається — і це нормально.
Невеликий приклад на рівні «відчути руками»:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
// Контекст — «фабрика» і «каталог» для bean-ів
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Цей об’єкт створюється контейнером і перебуває під його керуванням
OrderPlacementService service = context.getBean(OrderPlacementService.class); // bean
// Цей об’єкт ми створюємо самі: це дані конкретного сценарію
Order order = new Order("o-1", OrderStatus.NEW); // звичайний об’єкт
System.out.println(service.getClass().getSimpleName()); // OrderPlacementService
System.out.println(order.status()); // NEW
}
Тут service приходить із контейнера — це частина «скелета застосунку». А order ми створюємо самі: це конкретні дані конкретного сценарію. І жодного внутрішнього болю у Spring від цього немає — він не ревнує.
2. Межа контейнера і вибір bean-ів
Практичне правило тут просте: не все має бути bean-ом. Але після першого погоджувального кивка руки все одно тягнуться навісити @Bean на все, що рухається, і на частину того, що не рухається, — «про всяк випадок». У цьому розділі введемо поняття межі контейнера: воно допомагає відділяти «структуру застосунку» від «вмісту його роботи» і не перетворювати конфігурацію на склад випадкових сутностей.
Межа контейнера — це відповідь на запитання: які об’єкти є «інфраструктурою застосунку», а які — «вмістом його роботи». Spring-контейнер ідеально підходить для першого типу об’єктів: вони відносно стабільні, використовуються багатьма частинами коду й часто мають залежності. Другий тип об’єктів — це те, заради чого все й робиться: предметні сутності, команди, результати обчислень, тимчасові структури. Вони з’являються, зникають, множаться тисячами — і мають спокійно жити поза контейнером.
Чому це важливо не лише з філософського, а й з практичного погляду:
Якщо віддати контейнеру все підряд, конфігурація швидко перетворюється на каталог випадкових сутностей. Стає складніше зрозуміти, що тут «каркас застосунку», а що просто дані. А ще ви досить швидко упретеся в об’єкти, які неможливо чесно зробити bean-ами без дивних костилів, тому що їм потрібні дані, відомі лише під час виконання сценарію (наприклад, customerId, список товарів, поточна дата у звіті тощо).
Якщо ж, навпаки, bean-ів буде надто мало і ви продовжите створювати сервіси вручну, ви відкотитеся назад до болю перших днів: залежності знову почнуть ховатися, composition root розповзеться, а підміна реалізацій стане незручною.
У нашому проєкті ContextFlow ми хочемо тієї самої золотої середини: контейнер керує сервісами та інфраструктурою, а предметні об’єкти живуть як нормальні Java-об’єкти. Тоді проєкт залишається зрозумілим: Spring допомагає збирати й обслуговувати граф об’єктів, але не намагається бути «операційною системою для кожного new».
Щоб закріпити ідею, ось невелика таблиця з «людським» сенсом:
| Тип об’єкта | Приклад у ContextFlow | Живе скільки? | Має бути bean-ом? | Чому |
|---|---|---|---|---|
| Сервіс / use-case | OrderPlacementService | Довго (доки живе застосунок) | Так | Це частина каркаса, має залежності, зручно зібрати один раз |
| Інфраструктура | InMemoryOrderStore | Довго | Так | Це «зовнішній світ» для бізнесу, зручно підміняти, зручно конфігурувати |
| Точка входу сценарію | ScenarioRunner | Довго | Так | Це оркестратор сценарію, і йому потрібні готові залежності |
| Предметна сутність | Order | У міру сценаріїв | Ні | Їх багато, вони дані, а не інфраструктура |
| Команда | CreateOrderCommand | Миттєво / до кінця виклику | Ні | Це «пакет вхідних даних», який створюється в момент дії |
| Проміжний результат | BigDecimal totalAmount | Миттєво | Ні | Це обчислення, а не компонент застосунку |
Зверніть увагу: слово «довго» тут не про безсмертя об’єкта, а про те, що об’єкт не прив’язаний до одного конкретного замовлення. Він потрібен як механізм, а не як дані.
3. Beans у ContextFlow: сервіси та інфраструктура
Коли ми говоримо «bean — це керований об’єкт», одразу хочеться запитати: «Гаразд, а кого саме беремо у штат?». У цьому розділі застосуємо поняття межі до нашого ContextFlow і перерахуємо типових кандидатів у beans: сервіси, точки входу, порти (інтерфейси) та їхні реалізації. Нас цікавить одна логіка: що саме контейнеру має сенс збирати самостійно.
З погляду архітектури ContextFlow на цьому етапі схожий на невеликий консольний застосунок із сервісним шаром: є сценарій (створити замовлення), є сервіс, який його оркеструє, і є інфраструктурні залежності (сховище, аудит, сповіщення, генератор ідентифікаторів). Усі ці об’єкти — «механізми», які зручно зібрати один раз і далі використовувати повторно.
Типовий список beans на поточній стадії проєкту виглядає так:
ScenarioRunner — тому що це точка входу нашого застосунку. Він не має вручну збирати сервіси: його задача — просто запуститися вже готовим.
OrderPlacementService (і інші сервіси) — тому що це бізнес-оркестратор, який залежить від портів та інфраструктури.
OrderStore, AuditWriter, NotificationSender, OrderIdGenerator — тому що це точки варіативності й зовнішні залежності. Сьогодні це прості реалізації в пам’яті й через консоль, пізніше в межах курсу вони все одно змінюватимуться (у різних профілях, конфігураціях тощо), і Spring-контейнер тут саме для цього.
Цю узагальнену схему корисно тримати в голові й далі: ContextFlowApplication -> ScenarioRunner -> OrderPlacementService -> порти та інфраструктура, а команди й предметні об’єкти залишаються звичайним Java-кодом.
Покажемо це на невеликому живому фрагменті. Метод main для Spring Core-застосунку майже ідеальний у своїй простоті: створити контекст, узяти один стартовий bean і запустити його.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// На старті застосунку створюємо контекст: він збере bean-и за конфігурацією
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Беремо «точку входу» сценарію як готовий bean
ScenarioRunner runner = context.getBean(ScenarioRunner.class);
runner.run(); // Запускаємо бізнес-сценарій
}
}
}
Зверніть увагу, main тут не займається бізнесом. Він не будує Order, не рахує знижки, не вибирає канали сповіщення. Його задача — сказати: «Контейнере, зібрай застосунок. Мені потрібна стартова точка».
Тепер — фрагмент конфігурації. Ми не намагаємося зробити її ідеальною; зараз важливо зрозуміти сам принцип:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Кажемо Spring: це клас конфігурації, з нього потрібно зібрати bean-и
public class AppConfig {
@Bean // Bean інфраструктури: «механізм» зберігання, який зручно підміняти
public OrderStore orderStore() {
return new InMemoryOrderStore();
}
@Bean // Bean точки входу: контейнер сам передасть сюди потрібний сервіс
public ScenarioRunner scenarioRunner(OrderPlacementService service) {
return new ScenarioRunner(service);
}
}
Так, тут поки не вистачає @Bean для OrderPlacementService та інших залежностей — у реальному проєкті вони, звісно, будуть. Але навіть цей фрагмент показує головне: bean-ами стають «вузли каркаса», а не «дані сценарію».
Окремо корисно побачити, що сам ScenarioRunner — звичайний клас. Йому не обов’язково знати, що його створює Spring. Його залежність видно в конструкторі — це той самий DI-підхід, який ми вже опанували.
public class ScenarioRunner {
private final OrderPlacementService orderPlacementService;
public ScenarioRunner(OrderPlacementService orderPlacementService) {
// Залежність передається ззовні (контейнером) — це і є DI
this.orderPlacementService = orderPlacementService;
}
public void run() {
System.out.println("Сценарій запущено"); // Сценарій запущено
// Далі буде створення звичайних об’єктів сценарію (команд, предметних сутностей)
}
}
І це вже важлива перемога: стартовий об’єкт створює контейнер, а далі він запускає сценарій звичайним Java-кодом.
4. Що не робити bean-ами: предметні сутності та команди
Найчастіша помилка новачка — вирішити: «якщо Spring — це контейнер об’єктів, отже будь-який об’єкт має жити в контейнері». У цьому розділі спокійно подивимося на другу половину картини: які об’єкти в ContextFlow логічно залишати звичайними Java-об’єктами. Сенс не в тому, щоб щось «заборонити», а в тому, щоб не ламати дизайн і не перетворювати контейнер на склад тимчасових речей.
У ContextFlow предметна модель спеціально «тонка»: Order, OrderItem, Customer, статуси замовлення, команди на створення/скасування. Ці об’єкти існують, тому що бізнес-сценарій оперує ними як даними. Вони не мають мати залежностей від OrderStore або NotificationSender — інакше предметна модель почне протікати інфраструктурними залежностями, а це майже завжди боляче.
Ось приклад команди. Це типовий «мішечок даних» на вході до сценарію:
import java.util.List;
// Команда — це вхідні дані сценарію (зазвичай створюється в момент виклику use-case)
public record CreateOrderCommand(
String customerId,
List<OrderItem> items
) {
}
Команда не просить контейнер про допомогу. Вона просто зберігає інформацію, яку користувач або сценарій передав у use-case. Тому її природно створювати там, де у нас уже є конкретні дані: у ScenarioRunner або пізніше всередині тесту.
Те саме й з предметною сутністю. Вона описує замовлення як факт:
import java.time.Instant;
// Предметна сутність — це «дані бізнесу», а не інфраструктурний компонент
public record Order(
String id,
String customerId,
OrderStatus status,
Instant createdAt
) {
}
У реальному проєкті в замовлення буде більше полів, але суть не змінюється: це дані. Їх не «налаштовує контейнер» — вони з’являються в момент обробки сценарію.
І ось важливий момент: таких замовлень буде багато. Якщо у нас є 100 замовлень, буде 100 об’єктів Order. Контейнер не повинен знати про кожен із них і не повинен намагатися їх «утримувати». Контейнер утримує механізми, які вміють працювати із замовленнями.
Як це виглядає в сценарії:
import java.util.List;
public class ScenarioRunner {
private final OrderPlacementService orderPlacementService;
public ScenarioRunner(OrderPlacementService orderPlacementService) {
this.orderPlacementService = orderPlacementService;
}
public void run() {
// Команда створюється на місці, тому що дані відомі лише в момент сценарію
CreateOrderCommand cmd = new CreateOrderCommand(
"cust-1",
List.of(new OrderItem("PEN", 2))
);
// Сервіс (bean) обробляє команду і повертає предметний результат (звичайний об’єкт)
Order created = orderPlacementService.placeOrder(cmd);
System.out.println(created.status()); // NEW
}
}
CreateOrderCommand і OrderItem створюються через new або конструктор record прямо в сценарії. Це нормально, це правильно і це не «повернення до ручного збирання». Ручне збирання — це коли ви вручну збираєте каркас застосунку (сервіси та інфраструктуру). А створювати предметні об’єкти в процесі роботи — звичайне життя застосунку.
5. Як beans і звичайні об’єкти пов’язані в сценарії
Beans і звичайні об’єкти пов’язані дуже просто: вони зустрічаються в одному сценарії, але грають різні ролі. Контейнер створює сервіси, сценарій створює команди й предметні об’єкти, сервіс використовує інфраструктурні beans і повертає предметний результат. Наша мета — побачити в цьому не два випадково дотичні світи, а один нормальний потік.
Спочатку — загальна схема. Її корисно тримати в голові, коли ви не розумієте, «чому цей об’єкт не в контейнері».
flowchart LR
subgraph C[Spring ApplicationContext]
SR["ScenarioRunner (bean)"]
OPS["OrderPlacementService (bean)"]
GEN["OrderIdGenerator (bean)"]
STORE["OrderStore (bean)"]
end
SR -->|створює| CMD["CreateOrderCommand (звичайний)"]
CMD --> OPS
OPS --> GEN
OPS --> STORE
OPS -->|створює| O["Order (звичайний)"]
STORE -->|зберігає| O
Тут видно головне: контейнер керує «механізмами» (лівий блок), а CreateOrderCommand і Order живуть поза ним як дані.
Тепер шматок сервісу. Він — bean, тому що має залежності й є частиною каркаса:
import java.time.Instant;
public class OrderPlacementService {
private final OrderIdGenerator idGenerator;
private final OrderStore orderStore;
public OrderPlacementService(OrderIdGenerator idGenerator, OrderStore orderStore) {
// Залежності сервісу (bean-а) приходять ззовні — від контейнера
this.idGenerator = idGenerator;
this.orderStore = orderStore;
}
public Order placeOrder(CreateOrderCommand cmd) {
// Сервіс створює предметний об’єкт: це «продукт роботи», а не bean
Order order = new Order(idGenerator.nextId(), cmd.customerId(), OrderStatus.NEW, Instant.now());
// Використовуємо інфраструктурний bean для збереження
orderStore.save(order);
return order;
}
}
Тут є дуже показовий момент: сервіс сам створює предметний об’єкт. Це не привід робити Order bean-ом. Навпаки: це якраз показує, що предметні об’єкти — продукт роботи сервісу.
І тепер — шматок конфігурації, який показує: контейнеру достатньо знати лише про «механізми»:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Конфігурація, яка описує каркас застосунку
public class AppConfig {
@Bean // Інфраструктурний bean: генератор ідентифікаторів
public OrderIdGenerator orderIdGenerator() {
return new UuidOrderIdGenerator();
}
@Bean // Сервісний bean: контейнер сам «зв’яже» його залежності (gen, store)
public OrderPlacementService orderPlacementService(OrderIdGenerator gen, OrderStore store) {
return new OrderPlacementService(gen, store);
}
}
У цій конфігурації немає @Bean public Order () — і це чудово. Ми не намагаємося змусити контейнер утримувати замовлення. Контейнер утримує те, що потрібно, щоб замовлення створювати і зберігати.
6. Правила вибору bean-ів для новачка
На старті вирішувати це «на відчуттях» важко: здається, що і OrderStore важливий, і Order важливий, і CreateOrderCommand важливий… а отже все важливе має бути bean-ом. У цьому розділі зберемо кілька простих запитань, які допомагають вирішувати, робити об’єкт bean-ом чи ні, без магії й без догматизму. Вони не ідеальні, але для Junior-рівня дають дуже хорошу дисципліну.
Перше запитання — «це механізм чи дані?». Якщо об’єкт — механізм (він щось робить, взаємодіє з іншими компонентами, має залежності), то він кандидат у bean. Якщо об’єкт — дані (він описує конкретне замовлення, конкретного клієнта, конкретну команду), то він майже завжди має бути звичайним об’єктом.
Друге запитання — «скільки таких об’єктів буде в ході роботи?». Якщо їх потенційно багато (замовлення, позиції замовлення, команди), контейнер тут не потрібен: він не повинен керувати тисячами однотипних екземплярів, які народжуються щосекунди. Якщо об’єкт зазвичай один на застосунок (сховище, генератор id, сервіс), bean — хороший варіант.
Третє запитання — «чи потрібні об’єкту дані, відомі лише під час виконання?». Якщо об’єкт не можна створити без значень, які з’являться лише в момент сценарію (наприклад: customerId, список OrderItem, createdAt), то це майже прямий сигнал, що перед вами не bean. Контейнер створює bean-и під час старту застосунку, а не в момент «користувач вирішив купити ручку».
Щоб не залишатися в абстракції, подивімося на поганий, але показовий приклад. Ось так робити не треба:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Це виглядає «як конфіг», але насправді конфігурує предметні дані — так не треба
public class BrokenConfig {
@Bean // Антипатерн: робимо предметну сутність bean-ом і ще й із захардкоженими даними
public Order order() {
return new Order("o-1", "cust-1", OrderStatus.NEW, java.time.Instant.now());
}
}
Чому це погана ідея навіть без тонкощів Spring:
Такий Order перетворюється на «структуру застосунку», хоча має бути «даними». Він створюється під час старту контексту — ще до того, як ми взагалі вирішили оформити замовлення. Екземпляр буде один, хоча замовлень нам потрібно багато. І до того ж ви фактично захардкодили customerId, а Instant.now() на старті застосунку — це взагалі окремий вид сюрреалізму: замовлення «створене» в момент запуску програми, навіть якщо користувач ще нічого не робив.
Хороший контрприклад — зробити bean-ом не Order, а сервіс, який уміє його створювати, і залежності, які цьому сервісу потрібні. Тоді система залишається природною: Spring керує механізмами, а бізнес-сценарій керує даними.
Щоб це було простіше запам’ятати, можна дивитися на межу контейнера як на просте правило: «усередині контейнера — те, що допомагає працювати», «зовні — те, над чим ми працюємо».
7. Типові помилки під час вибору bean-ів
Помилка №1: намагатися зробити bean-ом кожну сутність домену («нехай Spring створить мені Order-и»).
Зазвичай це стається через бажання «використовувати Spring по максимуму». На практиці ви отримуєте дивну конфігурацію, де предметні дані стають частиною стартової структури застосунку, а потім ще й намагаються жити як один спільний екземпляр. Предметна сутність — це дані конкретного сценарію, і їй нормально з’являтися через new всередині сервісів або сценарних класів.
Помилка №2: тримати тимчасові дані сценарію всередині bean-а як поле класу.
Це виглядає невинно: «ну я збережу останнє створене замовлення в полі, раптом знадобиться». Але дуже швидко це перетворюється на прихований стан, який не належить до «механізму». Bean має бути сервісом, а не папкою з поточними документами. Тимчасові дані краще тримати в локальних змінних методів, у повертаних об’єктах або в явно виділених структурах збереження (наприклад, у OrderStore).
Помилка №3: плутати «важливий об’єкт» і «bean».
Order важливіший за будь-який OrderPlacementService за змістом предметної області, але це не робить його bean-ом. Bean-ом об’єкт стає не тому, що він важливий, а тому, що він частина каркаса застосунку: його потрібно зібрати, зв’язати із залежностями й підтримувати в робочому стані.
Помилка №4: перетворювати контейнер на універсальний new, а getBean — на повсякденний спосіб створювати об’єкти.
На старті навчання так і хочеться писати: «якщо мені потрібен об’єкт — візьму його з контексту». Проблема в тому, що переважна більшість об’єктів у застосунку має створюватися звичайним Java-кодом: команди, предметні сутності, результати обчислень. Контекст добре підходить для інфраструктури й сервісів, але якщо ви почнете ходити в нього за кожним «одноразовим» об’єктом, код стане важче читати й підтримувати.
Помилка №5: вважати, що «якщо об’єкт не bean, то Spring не потрібен».
Іноді студенти після поділу на beans і звичайні об’єкти думають: «То в мене половина об’єктів створюється через new, отже Spring не надто допоміг». Це пастка. Spring бере на себе найнеприємніше: збирання й супровід графа об’єктів сервісів та інфраструктури. А те, що предметні об’єкти створюються вручну, — не недолік, а нормальна, здорова модель застосунку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ