← назад к разделу

Это первая тема в Spring и основа всего остального: зачем нужен контейнер, как он создаёт и связывает объекты, где тут подводные камни. Фазы сборки бина здесь названы списком, чтобы было видно общую картину; подробный разбор каждой фазы с примерами живёт в отдельной статье.

контейнер собирает бин сам — до того, как его кто-то попросит PaymentClient зависимость PaymentClientсоздана первой OrderService конструктор OrderServicepayment уже здесь @PostConstruct поля на месте @PostConstructполя на месте прокси @Transactional прокси@Transactional готов лежит в контейнере готовsingleton — один на всех поток 1 поток 2 остановка приложения → @PreDestroy (только у singleton)

Порядок сборки жёсткий: сначала зависимость, потом конструктор, куда её передают, потом @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();
    }
}

Жизненный цикл бина

От создания до остановки бин проходит несколько фаз:

  1. создание (вызов конструктора);
  2. внедрение зависимостей;
  3. колбэки инициализации — @PostConstruct, затем afterPropertiesSet();
  4. обёртывание в прокси (если нужно — например, для @Transactional);
  5. бин готов к работе;
  6. при остановке приложения — @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 переносят ошибки конфигурации на первый запрос.

Что почитать дальше