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. Граница контейнера и выбор beans
Практическое правило здесь простое: не всё должно быть 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("Scenario started"); // Scenario started
// Дальше будет создание обычных объектов сценария (команд, доменных сущностей)
}
}
И это уже важная победа: стартовый объект создаётся контейнером, а дальше он запускает сценарий обычным 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 (plain)"]
CMD --> OPS
OPS --> GEN
OPS --> STORE
OPS -->|создаёт| O["Order (plain)"]
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. Правила выбора beans для новичка
На старте решать это «на ощущениях» трудно: кажется, что и 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 управляет механизмами, а бизнес-сценарий управляет данными.
Чтобы это было проще запомнить, можно смотреть на container boundary как на простое правило: «внутри контейнера — то, что помогает работать», «снаружи — то, над чем мы работаем».
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 берёт на себя самое неприятное: сборку и сопровождение объектного графа сервисов и инфраструктуры. А то, что доменные объекты создаются вручную, — не недостаток, а нормальная, здоровая модель приложения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ