1. Проблема: сервіси з «памʼяттю»
У ContextFlow спокуса та сама, що й у будь-якому невеликому проєкті: покласти currentOrderId, список кроків або інший тимчасовий фрагмент даних просто в поле сервісу. Спочатку все працює: ви задоволені, кіт задоволений. А потім singleton-bean починає памʼятати попередні операції й тихо перетворюється на спільний блокнот для всього застосунку. Навіть без вебшару це робить поведінку крихкою: сервісу вже недостатньо просто викликати placeOrder() — важливо ще знати, що залишилося в його полях після минулого разу.
2. Поведінка живе довго, дані операції — ні
Тому межа тут проста. OrderPlacementService, AuditService, NotificationDispatchService та інші сервіси живуть довго й відповідають за поведінку: координують кроки, викликають залежності, приймають параметри. А дані однієї обробки замовлення — кроки сценарію, тимчасові позначки, проміжні прапорці — мають жити або всередині методу, або в окремому короткоживучому об’єкті. Інакше ми змішуємо «хто виконує сценарій» і «що накопичилося всередині конкретного запуску».
3. Робимо сервіси ContextFlow stateless — і це нормально
Stateless тут не означає «без полів узагалі». Поля із залежностями залишаються: OrderStore, AuditWriter, NotificationSender. Не мають залишатися дані конкретного виклику — currentOrderId, currentChannel, список кроків і все, що стосується лише однієї операції.
Якщо такі дані постійно хочеться покласти в поле, це вже сигнал: сервісу потрібен окремий носій тимчасового стану. Для простої операції вистачить локальних змінних. Для більш насиченого сценарію зручніше завести OrderProcessingSession.
4. Проєктуємо OrderProcessingSession: що це і що в ній зберігати
OrderProcessingSession — не нова доменна сутність рівня Order. Це прикладний об’єкт однієї операції: своєрідна робоча папка, у якій накопичуються кроки обробки замовлення. Її не потрібно зберігати в OrderStore і не потрібно робити головним об’єктом системи.
Для навчального проєкту достатньо списку кроків обробки. А якщо хочеться швидко перевірити, що на різні виклики приходять різні session-об’єкти, для демонстраційного коду вистачає System.identityHashCode(...) — не потрібно роздувати сам клас заради однієї діагностики. Цього вже досить, щоб стан операції став явним і перестав розповзатися по полях сервісів.
5. Реєструємо OrderProcessingSession як prototype-bean
Тут prototype потрібен не задля ще однієї анотації. Якби нам був потрібен просто локальний список рядків, звичайний new був би чеснішим. Але OrderProcessingSession — осмислена частина графа ContextFlow: ми хочемо, щоб вона народжувалася заново на кожну операцію й водночас залишалася звичайною залежністю контейнера. Якщо пізніше їй знадобляться власні налаштування, Clock або інший допоміжний клас, сервіс не почне збирати її вручну.
Тому фінальна версія цього вузла в ContextFlow виглядає так:
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope("prototype") // Важливо: новий екземпляр під час кожного запиту до контейнера
class OrderProcessingSession {
private final List<String> steps = new ArrayList<>();
void addStep(String step) {
steps.add(step);
}
List<String> steps() {
return List.copyOf(steps);
}
}
Це рівно той об’єкт, якому нормально бути змінюваним: його стан живе одну операцію й не ділиться між викликами.
6. Сесія на операцію через ObjectProvider
Далі все впирається в момент отримання екземпляра. Якщо впровадити OrderProcessingSession просто в конструктор singleton-сервісу, контейнер створить її один раз на старті — і ніякої «сесії на операцію» не вийде. Тому сервіс тримає не саму session, а ObjectProvider<OrderProcessingSession>:
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
class OrderPlacementService {
// Тримаємо provider, а не саму session
private final ObjectProvider<OrderProcessingSession> sessions;
OrderPlacementService(ObjectProvider<OrderProcessingSession> sessions) {
this.sessions = sessions;
}
}
Так довгоживучий сервіс залишається stateless, а короткоживучий об’єкт ми отримуємо рівно тоді, коли починається операція.
7. Вбудовуємо OrderProcessingSession у сценарій створення замовлення
Тепер підхід складається цілком: сервіс бере нову session на початку placeOrder(...), записує туди кроки поточної обробки й після виходу з методу більше не тримає на неї посилання.
void placeOrder(CreateOrderCommand command) {
// Новий екземпляр для кожної операції
OrderProcessingSession session = sessions.getObject();
// Кроки поточної обробки живуть усередині session, а не в полях сервісу
session.addStep("validate");
session.addStep("save");
session.addStep("notify");
System.out.println(session.steps()); // [validate, save, notify]
}
Ось тут і зустрічаються обидві ідеї дня: singleton-сервіс залишився stateless, а стан однієї операції перейшов в окремий prototype-об’єкт.
flowchart TD
A["OrderPlacementService
singleton"] -->|має| P["ObjectProvider<OrderProcessingSession>"]
A --> S1["OrderStore
singleton"]
A --> N["NotificationDispatchService
singleton"]
A --> AU["AuditService
singleton"]
P -->|"getObject() для кожного виклику"| OS["OrderProcessingSession
prototype (новий щоразу)"]
Якщо хочеться зробити приклад трохи більш схожим на життя, можна вивести технічну мітку екземпляра:
OrderProcessingSession session = sessions.getObject();
// Технічна перевірка: у різних викликів буде інший об’єкт
System.out.println("сесія=" + System.identityHashCode(session)); // сесія=123456789 (приклад)
Значення в коментарі — лише приклад: на різних запусках число буде іншим. Але сенс лишається тим самим — на кожен виклик приходить новий екземпляр.
І ще важлива дисципліна: не переносіть у session всю логіку координації. Сервіс і далі керує сценарієм (створити замовлення, зберегти, сповістити), а session залишається «папкою з документами» одного запуску.
8. Перевіряємо під час виконання: один виклик — одна session
Зрозуміти щось у Spring — це лише половина справи. Друга половина — побачити на власні очі, що контейнер справді робить те, чого ви очікуєте. Тому корисно зробити маленьку перевірку у вашому ScenarioRunner (який уже запускає сценарії застосунку). Нам не потрібні повноцінні тести чи окрема інфраструктура — достатньо двох викликів одного й того самого сценарію та порівняння session-екземплярів.
Наприклад, якщо ви тимчасово додасте до OrderPlacementService метод для діагностики:
boolean newSessionEachTime() {
// Два звернення до provider мають повернути різні prototype-екземпляри
OrderProcessingSession a = sessions.getObject();
OrderProcessingSession b = sessions.getObject();
// Порівнюємо посилання: нам важливо саме "це інший об’єкт"
return a != b;
}
то в runner можна вивести:
// Очікуємо true: provider видав дві різні session
System.out.println(orderPlacementService.newSessionEachTime()); // true
Це маленька «інженерна лупа»: ви переконуєтеся, що provider справді видає різні екземпляри, тобто scope працює так, як ми очікуємо.
Якщо ви хочете прив’язати перевірку до реальної операції, можна в runner викликати placeOrder(...) два рази й подивитися логи кроків. Головне — не намагатися перетворювати identityHashCode на «бізнес-факт», ми використовуємо його лише як демонстрацію.
І ще один приємний ефект: щойно ви впровадили такий session-підхід, стає набагато легше пояснювати студенту (і собі) різницю між «станом операції» та «станом сервісу». Сервіс має бути передбачуваним. А session якраз і дає місце, де «тимчасове» дозволено.
9. Типові помилки під час роботи з prototype-сесіями
Помилка № 1: зберігати OrderProcessingSession у полі singleton-сервісу.
Щойно session пережила метод, вона перестала бути станом однієї операції й перетворилася на спільний об’єкт.
Помилка № 2: викликати sessions.getObject() один раз наперед і кешувати результат.
prototype тоді знову перетворюється на один екземпляр на весь час життя сервісу, тільки вже в обхідний спосіб.
Помилка № 3: замість ObjectProvider тягнути ApplicationContext і викликати getBean() із бізнес-коду.
Тут достатньо ObjectProvider; контекст у сервісі швидко перетворює код на service locator і робить залежності прихованими.
Помилка № 4: перетворювати session-bean на «новий сервіс», куди переїжджає вся бізнес-логіка.
Іноді студент думає: «Раз session зберігає кроки, нехай вона сама й виконує кроки». І раптом session перетворюється на другий OrderPlacementService, тільки зі станом. Це небезпечне змішування відповідальності. Нехай координація сценарію залишається в сервісах, а session зберігає лише тимчасові дані (кроки, позначки, накопичені попередження).
Помилка № 5: робити prototype-bean із доменних об’єктів (Order, Customer).
Це часта спроба «прив’язати все до Spring»: раз контейнер уміє створювати об’єкти, нехай створює і Order. Зазвичай це зайве. Доменні об’єкти — це дані сценарію, вони створюються в бізнес-коді, живуть у пам’яті чи в сховищі, серіалізуються тощо. У контейнер варто віддавати сервіси та інфраструктуру, а не кожну сутність предметної області.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ