1. Ожидание: prototype в каждом вызове
Если вы только что узнали про prototype, мозг автоматически дорисовывает приятную картину: «Ага! Значит, если мой сервис — singleton, а зависимость — prototype, то при каждом вызове метода сервиса я буду получать свежий объект». Это ожидание настолько человеческое, что я бы даже не называл его ошибкой — это просто очень естественная (и очень неверная) интерпретация.
Представьте, что вы делаете в ContextFlow короткоживущий объект OrderProcessingSession. Он нужен только на одну операцию обработки заказа: собрать «шаги», временные заметки и всё такое. Вы честно ставите @Scope("prototype") и внедряете OrderProcessingSession в OrderPlacementService. В голове уже играет музыка победы: «ура, на каждый placeOrder() будет новая session».
А потом вы запускаете приложение, вызываете placeOrder() два раза… и видите, что “session” почему-то одна и та же. Не «каждый раз новая», а «вчерашняя, но с повышенной самооценкой».
Создание bean-а и бизнес-операция
Чтобы понять проблему без магии и мистики, нужно развести две вещи, которые новичок часто смешивает. Первый момент — это создание singleton-bean контейнером (обычно при старте контекста, если bean не lazy). Второй момент — это выполнение вашей бизнес-операции, когда вы вызываете метод сервиса (placeOrder, cancelOrder, generateReport).
Spring-контейнер не живёт внутри ваших методов как маленький гном, который каждый раз подбегает и проверяет: «О, метод вызвали! Может, тут нужно обновить зависимости?». Внедрение зависимостей происходит в момент создания bean-а. Если bean — singleton, то создаётся он один раз, и зависимости ему внедряются тоже один раз.
Это можно представить как простую временную шкалу:
flowchart TD
A["Старт ApplicationContext"] --> B["Создание singleton OrderPlacementService"]
B --> C["Разрешение зависимостей"]
C --> D["Создание prototype OrderProcessingSession (один раз!)"]
D --> E["Внедрение session в singleton"]
E --> F["Приложение готово"]
F --> G["Вызов placeOrder() #1"]
G --> H["Вызов placeOrder() #2"]
Ключевой «щелчок» в голове: prototype означает «создай новый объект, когда контейнер попросили выдать этот bean». А внедрение зависимости — это как раз один запрос к контейнеру… но он происходит на этапе создания singleton-а, а не на каждом вызове метода.
2. Мини-эксперимент: prototype “застыл” в singleton
Сейчас мы сделаем маленькую лабораторную работу, максимально похожую на реальный проект. Наша цель — увидеть эффект глазами, а не только поверить словам. Возьмём OrderProcessingSession как prototype и внедрим её прямо в OrderPlacementService как обычную зависимость через конструктор.
Prototype-bean: OrderProcessingSession
Возьмём OrderProcessingSession как объект, который накапливает шаги одной операции: такая форма хорошо ложится на сценарий обработки заказа в 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);
}
}
Здесь важно только одно: scope у bean-а — prototype, значит контейнер обязан создавать новый экземпляр при каждом запросе к контейнеру.
Singleton-сервис, который “держит” session как поле
А теперь — та самая «наивная» версия, которую пишут практически все в первый раз (и это нормально).
import org.springframework.stereotype.Service;
@Service // По умолчанию Spring делает такие сервисы singleton-ами
public class OrderPlacementService {
// ВАЖНО: это поле будет жить столько же, сколько живёт singleton-сервис
private final OrderProcessingSession session;
// Здесь prototype запрашивается у контейнера ровно один раз — при создании singleton-а
public OrderPlacementService(OrderProcessingSession session) {
this.session = session;
}
// Выводим identityHashCode и шаги, чтобы увидеть "застывание" prototype
public String snapshot() {
session.addStep("validate");
session.addStep("save");
return System.identityHashCode(session) + " " + session.steps();
}
}
Обратите внимание: session — поле. А поля у singleton-сервисов живут ровно столько же, сколько живёт сам singleton. То есть долго.
Запуск и наблюдение
В ContextFlow у вас уже есть конфигурация и точка входа, но для эксперимента достаточно просто два раза вызвать метод сервиса:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Получаем singleton-сервис из контекста
var service = ctx.getBean(OrderPlacementService.class);
// Первый вызов: накопятся шаги [validate, save]
System.out.println(service.snapshot()); // 88321 [validate, save]
// Второй вызов: hash тот же, а список шагов продолжится
System.out.println(service.snapshot()); // 88321 [validate, save, validate, save]
}
Здесь два симптома одновременно, и они оба полезны. Во-первых, identityHashCode одинаковый, то есть объект session один и тот же. Во-вторых, список шагов на втором вызове «продолжился», потому что это не новая session, а старая, в которой уже лежит предыдущая история шагов.
Если после этого вы подумали «но ведь я поставил prototype, почему ты не работаешь?!», то поздравляю: вы только что встретили classic boss уровня “Spring scopes”.
3. prototype — политика выдачи из контейнера
В этот момент хочется “поругаться” на Spring: мол, почему контейнер не создаёт новый prototype каждый раз, когда метод вызывается? Но если остановиться и подумать, станет понятно, что контейнеру просто неоткуда узнать про ваш «каждый вызов = новая операция». Для Spring вызов метода — это обычный Java-вызов. Он не обязан что-то означать контейнеру.
Scope относится к bean definition, и он отвечает на вопрос: «что делать, когда контейнеру нужно дать кому-то этот bean?». В нашем “наивном” случае контейнеру нужно было дать OrderProcessingSession один раз — когда он создавал OrderPlacementService. Контейнер честно выдал prototype, как и обещал. А дальше всё: объект уже передан, хранится в поле и живёт сколько угодно.
Здесь помогает аналогия с одноразовым стаканчиком. prototype — это как просьба «дайте мне новый одноразовый стаканчик, когда я приду на стойку». Но если вы один раз пришли, взяли стаканчик и положили его в карман, то он не превращается в новый каждый раз, когда вы делаете глоток. Чтобы получить новый стаканчик, нужно снова прийти на стойку. В терминах Spring — снова попросить контейнер выдать bean.
Важное следствие: когда singleton хранит ссылку на prototype, этот “краткоживущий” объект фактически становится долгоживущим, потому что на него есть ссылка. Контейнер не “забирает” его обратно и не “пересоздаёт” автоматически.
4. Новый prototype на операцию: ObjectProvider<T>
Хорошая новость в том, что Spring не оставляет вас в одиночестве с этой проблемой. Плохая новость — нужно чуть-чуть поменять мышление. Если вы хотите “новую session на каждую операцию”, вам нужно перенести момент получения prototype с этапа старта приложения на этап выполнения операции. И для этого идеально подходит уже знакомый инструмент: ObjectProvider<T>.
ObjectProvider<T> — это не “service locator во плоти”, а довольно аккуратный (и контролируемый) способ сказать: «контейнер, дай мне вот этот тип тогда, когда я попрошу». То есть вы внедряете не сам prototype-объект, а “краник”, из которого можно наливать свежие экземпляры.
Исправленная версия сервиса
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service // Сервис по-прежнему singleton
public class OrderPlacementService {
// Мы храним не session, а "поставщика" session-ов
private final ObjectProvider<OrderProcessingSession> sessions;
// Контейнер внедряет provider один раз, но сам provider умеет выдавать новые экземпляры
public OrderPlacementService(ObjectProvider<OrderProcessingSession> sessions) {
this.sessions = sessions;
}
public String snapshot() {
// Ключевой момент: новый prototype получаем внутри метода, т.е. на каждую операцию
var s = sessions.getObject();
s.addStep("validate");
s.addStep("save");
return System.identityHashCode(s) + " " + s.steps();
}
}
Здесь OrderPlacementService остался singleton, но теперь внутри метода он каждый раз берёт новый OrderProcessingSession.
Проверяем поведение
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Сервис тот же самый singleton
var service = ctx.getBean(OrderPlacementService.class);
// Первый вызов: будет один экземпляр session
System.out.println(service.snapshot()); // 12001 [validate, save]
// Второй вызов: будет другой экземпляр session
System.out.println(service.snapshot()); // 77834 [validate, save]
}
Теперь identityHashCode разный — значит, экземпляры разные. И список шагов не накапливается между вызовами, потому что каждый раз мы работаем с новой session.
Самая частая эмоция после этого момента: «Аааа… то есть prototype работает. Просто я просил его не там и не тогда». Да. Spring честен, просто он не читает мысли разработчика. Хотя иногда хотелось бы, конечно — особенно по понедельникам.
5. Практика и нюансы
Когда prototype вообще не нужен
После удачного опыта с ObjectProvider легко впасть в другую крайность: «О, значит, любое временное состояние — это prototype-bean!». И тут Spring снова тихо достаёт табличку “осторожно, грабли”. Контейнер — мощная штука, но не каждый “короткоживущий объект” обязан быть bean-ом.
Если временный объект не имеет зависимостей от контейнера и не является самостоятельным участником объектного графа, он часто прекрасно живёт как обычная локальная переменная. То есть вы просто создаёте его через new внутри метода. Это не “предательство Spring”, это нормальный инженерный выбор: контейнер управляет тем, что действительно нужно управлять.
Разницу удобно почувствовать на критерии: нужен ли этому объекту DI? Если OrderProcessingSession хочет получить, например, Clock, какие-то настройки, formatter или что-то ещё, тогда контейнерное создание может быть оправдано. Если это просто “временный буфер на 10 строк”, то, возможно, проще держать его обычным объектом.
В ContextFlow мы специально делаем OrderProcessingSession bean-ом, потому что нам нужно показать scope-ы и их последствия на живом примере. Но мысль дня звучит так: “прототип — это инструмент, а не религия”.
Применение в ContextFlow
Сейчас мы аккуратно вернёмся к нашему проекту и сделаем так, чтобы OrderPlacementService оставался “нормальным” singleton-сервисом, который оркестрирует сценарий, но не хранит состояние конкретного заказа в полях. Состояние одной операции уедет в OrderProcessingSession, которая живёт ровно столько, сколько нужно одному вызову.
Главная дисциплина тут простая: singleton-сервис не держит session как поле. Он получает session в момент операции, использует, и дальше она становится не нужна. Это как блокнот на один созвон: вы не обязаны прикручивать его к руке шурупами и носить всегда.
Мини-зарисовка внутри placeOrder() (без полного сценария, чтобы не расползтись по домену):
public void placeOrder(CreateOrderCommand command) {
// В начале операции берём "свежую" session
var session = sessions.getObject();
// Дальше session живёт только в рамках одного вызова метода
session.addStep("validate");
session.addStep("save");
// Логируем для самопроверки: hash должен меняться между разными вызовами placeOrder()
System.out.println("session=" + System.identityHashCode(session) + " " + session.steps());
}
Если вы вызовете placeOrder() два раза для двух разных заказов, вы увидите два разных session=.... И самое важное: никакое состояние не протекает от одного заказа к другому через поля singleton-ов. Это ровно тот дизайн, который помогает вам дальше спокойно жить и с singleton-scope, и с дальнейшими темами про состояние.
Схемы “prototype inside singleton”
После пары экспериментов полезно закрепить картинку простой схемой. Когда вы внедряете prototype напрямую в singleton, происходит “схлопывание” lifetimes: прототип перестаёт быть короткоживущим, потому что singleton хранит на него ссылку. Никакой магии, чистая Java: есть ссылка — объект живёт.
sequenceDiagram
participant C as Container
participant S as "OrderPlacementService (singleton)"
participant P as "OrderProcessingSession (prototype)"
C->>S: create singleton S
S->>C: need P
C->>P: create prototype instance #1
C-->>S: inject P#1
Note over S: "placeOrder() #1"
S->>P: use P#1
Note over S: "placeOrder() #2"
S->>P: use P#1 again
А вот вариант с ObjectProvider, где singleton хранит не “сам прототип”, а “способ его получать”:
sequenceDiagram
participant C as Container
participant S as "OrderPlacementService (singleton)"
participant OP as "ObjectProvider"
participant P as "OrderProcessingSession (prototype)"
C->>S: create singleton S
C-->>S: inject OP
Note over S: "placeOrder() #1"
S->>OP: getObject()
OP->>C: request P
C->>P: create prototype instance #1
C-->>S: return P#1
Note over S: "placeOrder() #2"
S->>OP: getObject()
OP->>C: request P
C->>P: create prototype instance #2
C-->>S: return P#2
Если вы научитесь быстро мысленно рисовать эти две картинки, то 80% вопросов про “почему prototype ведёт себя странно” будут отвечаться буквально за минуту.
6. Типичные ошибки при работе с prototype в singleton
Ошибка №1: ожидать, что @Scope("prototype") “сам обновляет поле” при каждом вызове метода.
Это интуитивное ожидание, но оно не соответствует модели Spring. Контейнер управляет созданием бинов, а не отслеживает, когда вы вызываете бизнес-методы. Если вы внедрили прототип в singleton — он будет создан один раз при инъекции и больше не изменится.
Ошибка №2: использовать ObjectProvider, но вызывать getObject() только один раз.
Формально всё сделано правильно: внедрён ObjectProvider<T>. Но если вы получили объект в конструкторе или при инициализации поля и сохранили его — вы снова получили “застывший” экземпляр. ObjectProvider работает только тогда, когда вы вызываете getObject() в момент каждой операции, а не заранее.
Ошибка №3: тянуть ApplicationContext в бизнес-слой и вызывать getBean(...) вручную.
Это кажется “надёжным решением”, но по сути превращает код в service locator. Бизнес-логика начинает зависеть от контейнера, и вы теряете преимущества DI. ObjectProvider<T> как раз существует, чтобы получать новые экземпляры, не нарушая архитектуру.
Ошибка №4: использовать prototype для любых временных объектов.
Не каждый короткоживущий объект должен быть бином. Если у объекта нет зависимостей и он живёт внутри одного метода — обычный new часто проще и понятнее. Прототип оправдан там, где объект действительно участвует в графе зависимостей и создаётся контейнером.
Ошибка №5: пытаться лечить stateful singleton через prototype.
Если singleton-сервис хранит состояние текущей операции в полях (orderId, traceId и т.п.), никакой prototype это не исправит. Проблема не в области видимости бина, а в дизайне. Такие сервисы должны быть stateless, а состояние операции — либо локальным, либо вынесенным в отдельный короткоживущий объект.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ