Это первая тема в Spring и основа всего остального: зачем нужен контейнер, как он создаёт и связывает объекты, где тут подводные камни. Фазы сборки бина здесь названы списком, чтобы было видно общую картину; подробный разбор каждой фазы с примерами живёт в отдельной статье.
Порядок сборки жёсткий: сначала зависимость, потом конструктор, куда её передают, потом @PostConstruct — к этому моменту все поля уже заполнены. Готовый бин один: и первый поток, и второй получают тот же экземпляр.
Что такое IoC и DI простыми словами
Обычно объект сам создаёт то, что ему нужно:
class OrderService {
private final PaymentClient payment = new PaymentClient(); // создаём сами
}
Проблема: OrderService намертво привязан к конкретному PaymentClient — его не подменить в тесте и не настроить из конфигурации.
Inversion of Control (инверсия управления) — идея «не создавай зависимости сам, пусть их даст кто-то снаружи». Управление созданием объектов уходит из вашего кода в контейнер.
Dependency Injection (внедрение зависимостей) — конкретный приём, которым Spring реализует IoC: контейнер сам создаёт PaymentClient и передаёт его в OrderService.
@Service
class OrderService {
private final PaymentClient payment;
OrderService(PaymentClient payment) { this.payment = payment; } // нам его дали
}
Короткая формула: IoC — принцип, DI — паттерн, которым его реализуют.
ApplicationContext и BeanFactory
Объекты, которыми управляет контейнер, называются бинами (beans). При старте Spring сканирует пакеты, находит классы с @Component (и @Service, @Repository, @Controller — это его частные случаи), создаёт из них бины и связывает между собой.
Сам контейнер существует в двух видах. BeanFactory умеет читать описания объектов и создавать их по запросу; напрямую им почти не пользуются. ApplicationContext это BeanFactory плюс всё нужное в реальном приложении: события, свойства (Environment), сообщения для локализации, загрузка ресурсов. Именно его возвращает SpringApplication.run(App.class, args) при старте.
Способы внедрения и почему выбирают конструктор
Внедрить зависимость можно тремя способами:
// 1. Через конструктор — рекомендуется
@Service
class OrderService {
private final PaymentClient payment;
OrderService(PaymentClient payment) { this.payment = payment; }
}
// 2. Через поле — коротко, но проблемно
@Service
class OrderService {
@Autowired private PaymentClient payment;
}
// 3. Через сеттер — для необязательных зависимостей
@Service
class OrderService {
private PaymentClient payment;
@Autowired void setPayment(PaymentClient p) { this.payment = p; }
}
Почему обычно выбирают конструктор:
- поле можно сделать
final— объект нельзя оставить «недостроенным»; - видно все зависимости сразу: десять параметров в конструкторе — сигнал, что класс делает слишком много;
- легко тестировать — объект создаётся обычным
newс моками, без запуска Spring; - циклические зависимости вскрываются сразу, а не прячутся (об этом ниже).
С одним конструктором аннотация @Autowired не нужна — Spring и так его использует.
Несколько бинов одного типа: @Primary, @Qualifier и внедрение списка
Пока интерфейс реализован одним классом, DI работает сам. Стоит появиться второй реализации PaymentGateway, и приложение перестаёт стартовать: NoUniqueBeanDefinitionException: expected single matching bean but found 2. Это первая ошибка старта, которую видит почти каждый новичок, и за ней стоит обычный вопрос контейнера: «какой из двух?»
Ответов три. @Primary на одной реализации делает её ответом по умолчанию: везде, где просят PaymentGateway без уточнений, придёт она. @Qualifier("sbpGateway") в месте внедрения выбирает бин по имени (имя бина по умолчанию это имя класса с маленькой буквы). И третий ответ, самый полезный: попросить не один бин, а все.
@Service
public class PaymentRouter {
private final Map<String, PaymentGateway> gateways; // имя бина → реализация
public PaymentRouter(Map<String, PaymentGateway> gateways) {
this.gateways = gateways;
}
public Receipt pay(String method, Money amount) {
PaymentGateway gateway = gateways.get(method + "Gateway");
if (gateway == null) throw new IllegalArgumentException("нет шлюза для " + method);
return gateway.charge(amount);
}
}
Map<String, PaymentGateway> кладёт все реализации по именам бинов, List<PaymentGateway> отдаёт их списком в порядке @Order. На этом строится любой код со стратегиями: новый способ оплаты это новый класс с @Component, а в маршрутизаторе ничего менять не нужно. Отдельный вопрос — что делать, когда бина может не быть вовсе. Такое бывает у необязательной интеграции: метрики подключены не везде, кэш есть только в проде. Способов три, и они не равноценны.
@Service
public class OrderService {
private final Optional<MetricsClient> metrics; // пусто, если бина нет
private final ObjectProvider<AuditClient> audit; // спросим позже
public OrderService(Optional<MetricsClient> metrics, ObjectProvider<AuditClient> audit) {
this.metrics = metrics;
this.audit = audit;
}
public void place(Order order) {
metrics.ifPresent(m -> m.count("orders"));
audit.ifAvailable(a -> a.log(order)); // то же, но лениво
}
}
Optional<T> в параметре конструктора Spring понимает буквально: есть бин — придёт заполненный, нет — пустой, и приложение стартует. ObjectProvider<T> откладывает поиск до вызова (getIfAvailable(), ifAvailable(...), getObject()), и он же единственный способ спросить «а сколько их» (stream()). Старое @Autowired(required = false) работает только на поле или сеттере и оставляет null, поэтому в новом коде берут первые два.
Scopes — сколько живёт бин
Сервис завёл поле-счётчик обработанных заказов, и на проде оно врёт: один экземпляр @Service обслуживает все запросы параллельно, и счётчик делят все потоки. Сколько экземпляров бина создаёт контейнер, определяет scope. Встроенных шесть, на практике важны два. singleton, по умолчанию, это один экземпляр на весь контейнер; так живут почти все бины, @Service, @Repository, @Controller, и поэтому бины делают без изменяемого состояния (stateless). prototype даёт новый экземпляр на каждый запрос бина; Spring создаёт его и «отпускает», метод уничтожения (@PreDestroy) у prototype не вызывается.
Остальные четыре — для веб-приложений: request (новый бин на каждый HTTP-запрос), session (на сессию пользователя), application и websocket.
Как внедрить prototype в singleton
Внедрите prototype-бин в singleton обычным полем и посмотрите, что напечатает второй вызов:
живой пример
import java.util.function.Supplier;
public class ScopeDemo {
static int created = 0;
record RequestContext(int id) { }
static class OrderService {
private final RequestContext fixed; // внедрили один раз
private final Supplier<RequestContext> provider; // роль ObjectProvider
OrderService(RequestContext fixed, Supplier<RequestContext> provider) {
this.fixed = fixed;
this.provider = provider;
}
void handle() {
System.out.println("поле: #" + fixed.id() + " провайдер: #" + provider.get().id());
}
}
public static void main(String[] args) {
Supplier<RequestContext> factory = () -> new RequestContext(++created);
OrderService service = new OrderService(factory.get(), factory);
service.handle();
service.handle();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Поле оба раза печатает #1: prototype внедрился один раз, при создании singleton'а, и «новизна» потерялась. Провайдер печатает #2 и #3, потому что просит объект в момент вызова. В Spring эту роль играет ObjectProvider:
У того же приёма есть вторая, декларативная форма. Метод-фабрику помечают @Lookup, и Spring подменяет его реализацией, которая на каждый вызов просит у контейнера новый бин:
@Service
abstract class ReportService {
@Lookup
protected abstract ReportBuilder newBuilder(); // тело писать не нужно
public Report build(List<Row> rows) {
ReportBuilder builder = newBuilder(); // новый экземпляр на каждый вызов
rows.forEach(builder::add);
return builder.done();
}
}
Класс при этом должен быть наследуемым (Spring создаёт подкласс через CGLIB), а метод — не private и не final. ObjectProvider проще и не требует абстрактного класса, поэтому выбирают чаще его; @Lookup встречается в старом коде и в случаях, когда обращаться к контейнеру из бизнес-кода не хочется вовсе.
@Service
class OrderService {
private final ObjectProvider<RequestContext> provider; // RequestContext — prototype
OrderService(ObjectProvider<RequestContext> p) { this.provider = p; }
void process() {
RequestContext ctx = provider.getObject(); // новый экземпляр каждый раз
}
}
Когда singleton не годится: реальные случаи
Правило «бины без состояния» звучит как запрет, а на деле описывает, как контейнер устроен. Singleton собирается один раз, и с этого момента это набор методов над внедрёнными зависимостями: состояние приходит аргументом, живёт в базе или в объекте, который метод создал сам через new и выбросил после ответа. Контейнер занимается связыванием, а связывание делается однажды — поэтому singleton и оказывается ответом почти для каждого бина: сервисам, репозиториям, контроллерам своё состояние между вызовами не нужно.
Prototype нужен, когда объект одновременно хранит состояние и требует внедрённых зависимостей: билдер отчёта, который накапливает строки и в конце пишет их через внедрённый ReportStorage; парсер с буфером на несколько мегабайт, которому нужен ObjectMapper из контекста; мастер многошаговой формы. Если состояние есть, а зависимостей нет, prototype не нужен — хватает new внутри метода. И закрывать ресурсы такой бин обязан сам: контейнер его отпускает сразу после создания.
Request scope — контекст запроса: арендатор (tenant), идентификатор трассировки, текущий пользователь. Внедряется прокси, а настоящий объект лежит в RequestContextHolder, то есть в ThreadLocal потока, который обслуживает запрос. Отсюда режим отказа: в @Async-методе или в задаче, отданной своему ExecutorService, RequestContextHolder пуст, и первое обращение к прокси падает с IllegalStateException: No thread-bound request found. В другой поток контекст переносят явно — аргументом или через TaskDecorator.
Refresh scope — бин, который контейнер выбрасывает и создаёт заново по POST /actuator/refresh, когда изменилась конфигурация; зачем он и чем за него платят — в статье про конфигурацию на лету.
Самая частая ошибка со scope — не «забыли prototype», а состояние, положенное в singleton:
// так не надо: буфер общий для всех потоков
@Service
class ReportService {
private final StringBuilder buffer = new StringBuilder();
String build(List<Row> rows) {
buffer.setLength(0);
rows.forEach(r -> buffer.append(r.line()).append('\n'));
return buffer.toString(); // под нагрузкой — строки из чужого отчёта
}
}
// так надо: состояние в локальной переменной, бин остаётся singleton
@Service
class ReportService {
String build(List<Row> rows) {
StringBuilder buffer = new StringBuilder();
rows.forEach(r -> buffer.append(r.line()).append('\n'));
return buffer.toString();
}
}
Жизненный цикл бина
От создания до остановки бин проходит несколько фаз:
- создание (вызов конструктора);
- внедрение зависимостей;
- колбэки инициализации —
@PostConstruct, затемafterPropertiesSet(); - обёртывание в прокси (если нужно — например, для
@Transactional); - бин готов к работе;
- при остановке приложения —
@PreDestroy(только для singleton).
Порядок фаз виден и без Spring:
живой пример
public class LifecycleDemo {
static class PaymentClient {
String pay(String order) { return "оплачен " + order; }
}
static class OrderService {
private final PaymentClient payment; // пришёл конструктором
private String mode; // «внедряется в поле» — позже
OrderService(PaymentClient payment) {
this.payment = payment;
System.out.println("2. конструктор: payment пришёл? " + (payment != null));
System.out.println(" а поле mode сейчас = " + mode);
}
void afterAssembly() { // роль @PostConstruct
System.out.println("4. @PostConstruct: mode = " + mode + ", " + payment.pay("A-17"));
}
}
public static void main(String[] args) {
System.out.println("1. контейнер создал зависимость");
OrderService service = new OrderService(new PaymentClient());
service.mode = "боевой";
System.out.println("3. поле внедрено");
service.afterAssembly();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В конструкторе mode ещё null — поле заполняется после. Поэтому логику «после сборки» кладут в @PostConstruct, а обязательные зависимости берут конструктором: к его выполнению они уже на месте.
Когда бины создаются и что такое @Lazy
По умолчанию контейнер создаёт все singleton-бины на старте, до того как приложение примет первый запрос. Это и есть причина, по которой опечатка в имени бина, недоступная база или неверная настройка роняют приложение при запуске, а не в полночь на первом обращении: контейнер собирает всё и сразу проверяет, что собралось.
@Lazy на бине откладывает создание до первого обращения к нему, а spring.main.lazy-initialization=true делает так для всех бинов сразу. Старт становится заметно быстрее, и для локальной разработки и тестов это хорошая сделка. Для прода плохая, и ровно по обратной причине: ошибки конфигурации переезжают со старта на первый запрос, приложение рапортует «готово», проверка готовности в Kubernetes пропускает трафик, и падает уже пользователь. Отсюда правило: ленивая инициализация это инструмент ускорения разработки, а не настройка боевого профиля.
@Component и @Bean
Два способа объявить бин, и они не взаимозаменяемы:
@Component(и@Service/@Repository/@Controller) ставят на свой класс — Spring найдёт его при сканировании и создаст сам.@Beanставят на метод в@Configuration-классе — когда объект создаёте вы сами, например это класс из чужой библиотеки, на который нельзя повесить аннотацию.
@Configuration
class AppConfig {
@Bean
ObjectMapper objectMapper() { // чужой класс — настраиваем руками
return new ObjectMapper().findAndRegisterModules();
}
}
Важная деталь: @Configuration оборачивается в прокси, поэтому вызов одного @Bean-метода из другого вернёт тот же синглтон, а не новый объект. Если же @Bean-методы лежат в обычном @Component, такой гарантии нет — зависимости туда передают через параметры метода.
Циклические зависимости
Если A требует B, а B требует A через конструктор, Spring не сможет их собрать и упадёт на старте (BeanCurrentlyInCreationException): чтобы позвать конструктор A, нужен готовый B, а для него — готовый A. Сам по себе такой круг не разомкнётся.
Цикл, собранный внедрением в поля, Spring раньше разруливал сам, но с Boot 2.6 это запрещено по умолчанию — теперь такое приложение тоже не стартует (флаг spring.main.allow-circular-references возвращает старое поведение, но лучше его не трогать). Это хорошо: цикл почти всегда означает, что обязанности размазаны и классы стоит разделить — например, вынести общую логику в третий бин. @Lazy и внедрение в поле цикл не лечат, а прячут.
Глубже: request-scope внутри singleton: scoped proxyрасширенное
Скоупы request и session выше названы одной строкой, и с ними есть ловушка. Контроллер это синглтон, создан один раз при старте. Если внедрить в него бин со скоупом request, что должно попасть в поле? Запроса при старте нет.
Канонический ответ, прокси со скоупом:
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestAudit {
private final List<String> steps = new ArrayList<>();
public void step(String s) { steps.add(s); }
}
В поле синглтона попадает не сам объект, а прокси того же типа; при каждом вызове метода прокси находит бин текущего запроса (по RequestContextHolder) и делегирует ему. Синглтон живёт один, RequestAudit создаётся на каждый запрос, и код в контроллере ничего об этом не знает. ObjectProvider<RequestAudit> тоже работает, но требует вызывать getObject() каждый раз в нужном потоке, а забытая переменная с результатом молча переживёт запрос. Вне веб-потока (в @Async или планировщике) любой из вариантов упадёт с No thread-bound request found: запроса там действительно нет, и это ошибка дизайна, а не настройки.
Коротко
- IoC — принцип: созданием управляет контейнер; DI — приём: зависимости передают извне.
- Способов внедрения три (конструктор, сеттер, поле); выбирают конструктор —
final, видимость зависимостей, тестируемость. ApplicationContext— этоBeanFactoryплюс события, свойства, ресурсы, локализация.- Scope по умолчанию — singleton, поэтому бины делают stateless; prototype в singleton берут через
ObjectProvider, иначе экземпляр зафиксируется один раз. - Singleton не годится, когда объекту нужны и состояние, и внедрённые зависимости (prototype) или когда состояние принадлежит запросу (request scope: живёт в
ThreadLocalи в другой поток сам не переезжает). - Фазы бина: создание → внедрение →
@PostConstruct→ (прокси) → работа →@PreDestroy(только у singleton). - Циклическая зависимость через конструктор роняет старт — это сигнал переразбить классы, а не повод включать
@Lazy. - Две реализации одного интерфейса:
@Primaryпо умолчанию,@Qualifierпо имени,Map<String, T>илиList<T>для стратегий. - Бин скоупа request в синглтоне живёт через
@Scope(proxyMode = TARGET_CLASS); вне веб-потока его нет. - Необязательная зависимость это
Optional<T>в конструкторе илиObjectProvider<T>сifAvailable; prototype из singleton берутObjectProviderили методом с@Lookup. Все singleton создаются на старте,@Lazyиspring.main.lazy-initializationпереносят ошибки конфигурации на первый запрос.
Что почитать дальше
- Жизненный цикл Spring-бина с примерами — каждая фаза с демо-кодом.
- Auto-configuration, properties, профили — как Spring Boot собирает контекст автоматически.
- Spring AOP — как
@Transactionalи подобное превращают класс в прокси.