JavaRush /Курси /Spring Core /Які об’єкти варто робити bean-ами

Які об’єкти варто робити bean-ами

Spring Core
Рівень 4 , Лекція 0
Відкрита

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 бере на себе найнеприємніше: збирання й супровід графа об’єктів сервісів та інфраструктури. А те, що предметні об’єкти створюються вручну, — не недолік, а нормальна, здорова модель застосунку.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ