Java Spring и Spring Boot: DI, конфигурация, web-слой, что происходит под капотом
0%

Spring и Spring Boot: DI, конфигурация, web-слой, что происходит под капотом

Spring и Spring Boot: DI, конфигурация, web-слой, что происходит под капотом

Программа на Java из предыдущих статей трека — это граф объектов, который кто-то должен собрать. Пока объектов пять, сборка умещается в main: создать репозиторий, передать его в сервис, сервис — в контроллер. Когда объектов триста, а половина из них зависит от настроек, которые известны только в момент запуска в конкретной среде, ручная сборка превращается в самый хрупкий файл проекта.

Spring — это ответ на вопрос «кто собирает граф». Не «фреймворк для веба» и не «библиотека аннотаций»: в основе лежит контейнер, который читает описания объектов, вычисляет порядок их создания, подставляет зависимости и управляет временем жизни. Всё остальное — web, данные, безопасность, планировщик — надстройки над этим контейнером.

Проблема Spring в глазах новичка — ощущение магии: вы поставили аннотацию, и «оно заработало». Ощущение исчезает ровно в тот момент, когда становится понятна модель исполнения. Магии там нет: есть чтение метаданных, рефлексия, генерация подклассов в рантайме и очень аккуратно выстроенный порядок фаз. Эта статья — про механику. После неё аннотация перестаёт быть заклинанием и становится записью в BeanDefinition.

Ниша у Spring та же, что у ASP.NET Core в треке C#/.NET: серверные приложения бизнес-логики. Различия в подходе принципиальны — про них будет отдельный честный разговор в конце, но курс здесь самостоятельный, а не сравнительный.

Карта территории: что вообще называют «Spring»

Слово перегружено. Под ним прячутся несколько независимо версионируемых проектов.

Отношения такие: Spring Framework — фундамент, ему больше двадцати лет; Spring Boot — не отдельный фреймворк, а слой соглашений поверх Framework: он решает, какие бины создать, если вы ничего не сказали. Всё остальное — модули, которые Boot умеет включать автоматически.

Практический вывод: когда что-то не работает, сначала определите слой. «Не поднимается контекст» — это Framework. «Не подхватилось свойство» — это Boot. «403 вместо 200» — это Security, и до контроллера запрос вообще не дошёл.

Контейнер: BeanDefinition как единица работы

Центральная абстракция — не бин, а описание бина. Контейнер сначала собирает карту описаний и только потом начинает что-то создавать. Это разделение объясняет почти всё поведение Spring, включая то, почему циклические зависимости иногда работают, а иногда нет.

ApplicationContext — это BeanFactory плюс события, локализация, ресурсы и интеграция со средой. В приложении вы почти всегда работаете именно с ним, а BeanFactory встречается в стектрейсах.

Описания попадают в контейнер тремя путями:

  1. Сканирование компонентов@ComponentScan находит классы с @Component и его специализациями (@Service, @Repository, @Controller, @Configuration).
  2. Java-конфигурация — методы с @Bean внутри @Configuration-класса.
  3. Программная регистрацияBeanDefinitionRegistryPostProcessor, @Import, ImportBeanDefinitionRegistrar. Так работают Spring Data и половина автоконфигураций.

XML-конфигурация тоже жива и поддерживается, но в новых проектах не встречается.

Внедрение зависимостей: только конструктор

@Service
public class OrderService {

    private final OrderRepository repository;
    private final PricingClient pricing;
    private final OrderProperties props;

    // Единственный конструктор — @Autowired не нужен начиная со Spring 4.3.
    // Все зависимости final: объект нельзя создать в недособранном состоянии.
    public OrderService(OrderRepository repository, PricingClient pricing, OrderProperties props) {
        this.repository = repository;
        this.pricing = pricing;
        this.props = props;
    }
}

Три способа внедрения существуют, но выбор давно сделан:

Способ Поля final Тестируется без Spring Циклы Вердикт
Конструктор да да, new OrderService(...) падает на старте использовать
Сеттер нет да, но многословно скрывает только для опциональных
Поле (@Autowired на поле) нет нет, нужна рефлексия скрывает не использовать

Внедрение в поле — самая распространённая вредная привычка в Java-проектах. Оно позволяет классу иметь пятнадцать зависимостей и не выглядеть при этом уродливо, то есть прячет симптом переусложнения. Конструктор с пятнадцатью параметрами вызывает физическое отвращение — и это правильная реакция, класс пора делить.

Когда зависимостей действительно много и они однотипные, Spring умеет внедрять коллекции:

@Service
class ValidationChain {

    private final List<OrderValidator> validators;   // ВСЕ бины типа OrderValidator
    private final Map<String, TaxPolicy> policies;   // ключ = имя бина

    ValidationChain(List<OrderValidator> validators, Map<String, TaxPolicy> policies) {
        this.validators = validators;   // порядок задаётся @Order или Ordered
        this.policies = policies;
    }
}

Это идиоматичный способ реализовать стратегию или цепочку обязанностей: добавили класс с @Component — он сам оказался в списке. Для необязательной зависимости используйте не @Autowired(required = false), а ObjectProvider:

@Service
class Notifier {
    private final ObjectProvider<SmsGateway> smsProvider;

    Notifier(ObjectProvider<SmsGateway> smsProvider) { this.smsProvider = smsProvider; }

    void notifyUser(String phone, String text) {
        // ленивое разрешение: null, если бина нет; в циклах на старте не участвует
        SmsGateway gw = smsProvider.getIfAvailable();
        if (gw != null) gw.send(phone, text);
    }
}

Разрешение неоднозначности

Если подходящих бинов несколько, контекст не поднимется. Инструменты разрешения — по возрастанию явности:

@Bean @Primary                       // «по умолчанию берите этот»
DataSource mainDataSource() { ... }

@Bean @Qualifier("reporting")        // явная метка
DataSource reportingDataSource() { ... }

// В точке внедрения:
OrderService(@Qualifier("reporting") DataSource ds) { ... }

Лучше @Qualifier-строк — собственная аннотация-квалификатор: опечатка станет ошибкой компиляции, а не рантайма.

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE})
public @interface Reporting {}

Ещё один трюк, недооценённый в Java: дженерик-тип участвует в разрешении. Бины Repository<Order> и Repository<Customer> различимы для контейнера благодаря ResolvableType, несмотря на стирание типов (Spring читает generic-сигнатуру из метаданных класса — про то, что стирание не удаляет информацию из сигнатур, было в статье про коллекции и дженерики).

Жизненный цикл бина: где вклиниваются расширения

Это диаграмма, которую стоит держать в голове. Почти каждый вопрос вида «почему поле ещё null» или «почему аннотация не сработала» решается указанием пальцем на фазу.

Два следствия, которые экономят часы отладки.

Первое. @PostConstruct — единственное место, где объект гарантированно собран полностью. В конструкторе поля, внедряемые не через конструктор, ещё пусты; в @PostConstruct — уже нет. Но и злоупотреблять им нельзя: сетевые вызовы в @PostConstruct растягивают старт и делают приложение «живым», но неработоспособным. Прогрев выносите в ApplicationRunner/ApplicationReadyEvent.

Второе. Прокси навешивается на последнем шаге. Значит, внутри @PostConstruct собственные @Transactional-методы ещё не обёрнуты — транзакции там не будет. Это не баг, а прямое следствие порядка фаз.

Области видимости

singleton (по умолчанию, один экземпляр на контекст) и prototype (новый на каждый getBean) — базовые. В web-приложении добавляются request, session, application.

Классическая ловушка: prototype, внедрённый в singleton, создаётся один раз. Синглтон собирается один раз, значит и его зависимость разрешается один раз. Если нужен новый экземпляр на каждое обращение — берите ObjectProvider<T> и вызывайте getObject(), либо объявляйте бин со scopedProxyMode.

@Bean
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
ReportBuilder reportBuilder() { return new ReportBuilder(); }

Циклические зависимости

С Spring Boot 2.6 они запрещены по умолчанию: контекст падает с внятным сообщением. Свойство spring.main.allow-circular-references=true существует, но использовать его — значит согласиться жить с проблемой. Цикл A → B → A почти всегда означает, что есть третья, не выделенная ответственность. @Lazy на одной из сторон разрывает цикл технически, но оставляет архитектурный узел на месте.

Прокси: главный источник «магии» и главный источник ошибок

@Transactional, @Async, @Cacheable, @Retryable, method security — всё это работает одинаково: контейнер подменяет ваш бин прокси-объектом, который перехватывает вызовы, делает что-то до и после, а между ними зовёт настоящий метод.

Два механизма:

  • JDK dynamic proxy — если бин реализует интерфейс. Прокси реализует тот же интерфейс, но не наследует ваш класс. Отсюда ClassCastException при попытке привести прокси к конкретному классу.
  • CGLIB — генерация подкласса в рантайме. Работает без интерфейсов, но не может перехватить final-классы, final- и private-методы. В Spring Boot это режим по умолчанию (spring.aop.proxy-target-class=true).
System.out.println(orderService.getClass());
// class com.shop.OrderService$$SpringCGLIB$$0
// (до Spring 6 имя выглядело как $$EnhancerBySpringCGLIB$$)

Слои, через которые проходит HTTP-запрос в Spring Boot

Self-invocation: ошибка номер один

@Service
public class ReportService {

    @Transactional
    public void generateAll(List<UUID> ids) {
        for (UUID id : ids) {
            generateOne(id);   // ← вызов через this: прокси в стороне
        }
    }

    // Аннотация здесь НЕ РАБОТАЕТ при вызове из generateAll.
    // Ожидали независимую транзакцию на элемент — получили одну общую.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void generateOne(UUID id) { ... }
}

Симптом коварен: код работает, тесты «зелёные», а на проде одна ошибка в середине списка откатывает весь батч. Решения по убыванию честности:

// 1. Разделить классы — правильный ответ в 90% случаев:
//    внешний вызов проходит через прокси ДРУГОГО бина.
@Service
class ReportBatch {
    private final ReportItemService items;   // отдельный бин со своим прокси
    ReportBatch(ReportItemService items) { this.items = items; }

    void generateAll(List<UUID> ids) { ids.forEach(items::generateOne); }
}

// 2. Программная транзакция — когда границы сложнее одной аннотации.
@Service
class ReportBatch2 {
    private final TransactionTemplate tx;
    ReportBatch2(PlatformTransactionManager tm) {
        this.tx = new TransactionTemplate(tm);
        this.tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    }
    void generateAll(List<UUID> ids) { ids.forEach(id -> tx.execute(st -> { ...; return null; })); }
}

Третий вариант — self-injection (ReportBatch(@Lazy ReportBatch self)) — работает, но это признание поражения: класс просит контейнер выдать ему обёртку самого себя, чтобы обойти собственную структуру. Встретили такое на ревью — предложите вариант 1.

Соседняя грабля: @Transactional на private- или final-методе молчит без единого предупреждения. Spring Boot 3 умеет ругаться на часть таких случаев, но не на все. Правило простое: аннотации AOP ставим только на public-методы не-final классов.

@Configuration full mode: тоже прокси

@Configuration                          // proxyBeanMethods = true по умолчанию
class AppConfig {
    @Bean Clock clock() { return Clock.systemUTC(); }

    @Bean Auditor auditor() {
        return new Auditor(clock());    // вызов clock() перехвачен CGLIB →
    }                                   // вернётся ТОТ ЖЕ синглтон, а не новый объект

    @Bean Scheduler scheduler() { return new Scheduler(clock()); }  // тот же экземпляр
}

Если поставить @Configuration(proxyBeanMethods = false) (так делают все автоконфигурации ради скорости старта), перехвата не будет и clock() создаст новый объект на каждый вызов. Для Clock безразлично, для пула соединений — катастрофа. В lite-режиме зависимости между бинами передавайте параметрами метода:

@Configuration(proxyBeanMethods = false)
class AppConfig {
    @Bean Clock clock() { return Clock.systemUTC(); }
    @Bean Auditor auditor(Clock clock) { return new Auditor(clock); }   // контейнер подставит синглтон
}

Конфигурация: Environment и типобезопасные свойства

Environment — это упорядоченный список источников свойств. Поиск идёт сверху вниз и останавливается на первом попадании. Никакого слияния значений не происходит.

Порядок источников свойств в Spring Boot

Relaxed binding — свойство Boot, которое стоит понимать буквально: ключ shop.orders.max-retries совпадает с shop.orders.maxRetries, SHOP_ORDERS_MAXRETRIES и shop.orders.max_retries. Это позволяет писать переменные окружения в стиле Unix, не ломая имена в YAML.

@Value против @ConfigurationProperties

@Value("${...}") подходит для одного-двух значений. Для набора связанных настроек он плох: значения размазаны по классам, нет валидации, нет автодополнения в IDE, нет документации. Идиоматичный способ — типобезопасный объект, и в современной Java это record:

@ConfigurationProperties(prefix = "shop.orders")
@Validated
public record OrderProperties(

        @DefaultValue("2s") Duration timeout,          // Boot сам разберёт 2s, 500ms, PT1M

        @Min(1) @Max(10) @DefaultValue("3") int maxRetries,

        @NotBlank String currency,

        @DefaultValue Discounts discounts) {           // вложенный объект с дефолтами

    public record Discounts(
            @DefaultValue("false") boolean enabled,
            @Min(0) @Max(90) @DefaultValue("0") int percent) {}
}
# application.yml
shop:
  orders:
    timeout: 2s
    max-retries: 3
    currency: EUR
    discounts:
      enabled: true
      percent: 10

Включается через @ConfigurationPropertiesScan на главном классе или @EnableConfigurationProperties(OrderProperties.class).

Что здесь важно и часто упускается:

  • Record даёт неизменяемость бесплатно. Настройки нельзя случайно поменять в рантайме — ровно то, что нужно.
  • @Validated превращает опечатку в ошибку старта. Приложение с currency: "" не поднимется вообще, вместо того чтобы упасть через два часа на первом заказе. Это сознательный fail-fast: см. разбор в статье про идиоматику и ошибки.
  • Добавьте spring-boot-configuration-processor в зависимости — он сгенерирует META-INF/spring-configuration-metadata.json, и IDE начнёт подсказывать ваши свойства наравне со встроенными.

Профили — не система конфигурации

@Profile("prod") и application-prod.yml соблазняют развести всё окружение по профилям. Практика показывает, где предел: профиль хорош для выбора реализации (@Profile("test") InMemoryGateway), но плох для значений (адреса, пароли, таймауты) — их место в переменных окружения и внешнем конфиге. Иначе прод-путь кода начинает отличаться от тестируемого, и в первый же инцидент выясняется, что профиль prod никто не проверял.

Секреты в application.yml не хранят никогда. Минимум — переменные окружения, нормально — Vault/Secrets Manager через spring-cloud-vault или монтирование файлов и spring.config.import=file:/run/secrets/db.properties.

Spring Boot: что делает SpringApplication.run

@SpringBootApplication          // = @Configuration + @ComponentScan + @EnableAutoConfiguration
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

Три строки, за которыми стоит вполне конкретная последовательность.

Порядок здесь не декоративный. Автоконфигурации обрабатываются после ваших классов — именно поэтому @ConditionalOnMissingBean работает: к моменту проверки ваш бин уже зарегистрирован, и Boot отступает.

Автоконфигурация без магии

Механизм целиком: в jar-файле стартера лежит текстовый файл META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports со списком классов, по одному на строку. AutoConfigurationImportSelector читает его со всего classpath, применяет условия и регистрирует то, что выжило. Всё. До Boot 2.7 это был spring.factories — его вы ещё встретите в старых библиотеках.

Условия — обычные аннотации:

Аннотация Когда срабатывает
@ConditionalOnClass класс есть в classpath
@ConditionalOnMissingBean такого бина ещё нет — «уступи пользователю»
@ConditionalOnProperty свойство имеет заданное значение
@ConditionalOnWebApplication тип приложения совпал
@ConditionalOnBean другой бин уже определён

Своя автоконфигурация пишется за пять минут — это стандартный способ оформить внутреннюю библиотеку компании:

@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(AuditWriter.class)
@ConditionalOnProperty(prefix = "shop.audit", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(AuditProperties.class)
public class AuditAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean          // пользователь может объявить свой — победит он
    AuditWriter auditWriter(AuditProperties props, DataSource ds) {
        return new JdbcAuditWriter(ds, props.tableName());
    }
}
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
---
com.shop.audit.AuditAutoConfiguration

Когда «оно не подхватилось», не гадайте — спросите приложение:

# отчёт по всем условиям: что совпало, что нет и почему
java -jar app.jar --debug

# то же самое в рантайме, если включён Actuator
curl localhost:8080/actuator/conditions | jq '.contexts.application.negativeMatches'
curl localhost:8080/actuator/beans      | jq '.contexts.application.beans | keys'
curl localhost:8080/actuator/configprops
curl localhost:8080/actuator/env/shop.orders.timeout   # видно источник и перекрытые значения

Отключить лишнее можно точечно:

@SpringBootApplication(exclude = {SecurityAutoConfiguration.class})

Web-слой: путь запроса через DispatcherServlet

DispatcherServlet — реализация паттерна Front Controller: один сервлет принимает всё и раздаёт по обработчикам. Его работа — цепочка из четырёх решений: кто обработает, как превратить HTTP в аргументы, как превратить результат в HTTP, что делать с исключением.

Контроллер, каким он должен быть

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final OrderService service;

    OrderController(OrderService service) { this.service = service; }

    // DTO — record: неизменяемый, с валидацией на компонентах
    record CreateOrder(@NotBlank String sku, @Min(1) int quantity, @NotNull UUID customerId) {}

    record OrderView(UUID id, String sku, int quantity, BigDecimal total, Instant createdAt) {
        static OrderView of(Order o) {
            return new OrderView(o.id(), o.sku(), o.quantity(), o.total(), o.createdAt());
        }
    }

    @PostMapping
    ResponseEntity<OrderView> create(@Valid @RequestBody CreateOrder cmd,
                                     UriComponentsBuilder uri) {
        Order order = service.place(cmd.sku(), cmd.quantity(), cmd.customerId());
        return ResponseEntity
                .created(uri.path("/api/orders/{id}").build(order.id()))
                .body(OrderView.of(order));
    }

    @GetMapping("/{id}")
    OrderView byId(@PathVariable UUID id) {
        // Optional из сервиса разворачиваем в исключение прямо здесь
        return service.find(id).map(OrderView::of)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

Что здесь принципиально:

  • Сущность домена не уезжает в JSON. OrderView — отдельный тип. Иначе рефакторинг поля ломает контракт API, а ленивые связи Hibernate превращаются в загадочные исключения сериализации (об этом — в статье про работу с данными).
  • Контроллер не содержит логики. Он переводит HTTP в вызов и обратно. Всё остальное — в сервисе, который можно тестировать без веб-слоя.
  • @Valid на теле обязателен, иначе аннотации в record — просто украшение.

Ошибки: ProblemDetail вместо самописных форматов

Spring Framework 6 принёс встроенную поддержку RFC 9457 (Problem Details for HTTP APIs). Изобретать свой формат ошибок больше не нужно.

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
                "Заказ " + e.id() + " не найден");
        pd.setType(URI.create("https://errors.shop.example/order-not-found"));
        pd.setTitle("Заказ не найден");
        pd.setProperty("orderId", e.id());
        return pd;
    }
}

Ответ:

{
  "type": "https://errors.shop.example/order-not-found",
  "title": "Заказ не найден",
  "status": 404,
  "detail": "Заказ 8f14e45f-... не найден",
  "orderId": "8f14e45f-..."
}

Свойство spring.mvc.problemdetails.enabled=true включает тот же формат и для встроенных ошибок Spring (400 при кривом JSON, 405, 415), так что клиент видит один формат всегда.

Отдельно: не ловите Exception в @ControllerAdvice и не возвращайте 500 с текстом исключения. Текст исключения — внутренняя информация, а в проде вам нужен traceId, по которому запись найдётся в логах. Подробнее — в статье про деплой и наблюдаемость.

HTTP-клиенты: RestClient и декларативный @HttpExchange

RestTemplate в режиме поддержки — новые проекты используют RestClient (синхронный, fluent) или WebClient (реактивный).

@Bean
RestClient pricingRestClient(RestClient.Builder builder,
                             @Value("${shop.pricing.url}") String url) {
    // Таймауты обязательны: клиент без них однажды повесит весь пул потоков
    var settings = ClientHttpRequestFactorySettings.DEFAULTS
            .withConnectTimeout(Duration.ofSeconds(2))
            .withReadTimeout(Duration.ofSeconds(5));
    return builder.baseUrl(url)
            .requestFactory(ClientHttpRequestFactories.get(settings))
            .build();
}

// Декларативный клиент: описываем интерфейс, реализацию генерирует Spring
public interface PricingClient {
    @GetExchange("/prices/{sku}")
    PriceResponse price(@PathVariable String sku);
}

@Bean
PricingClient pricingClient(RestClient restClient) {
    return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
            .build().createClient(PricingClient.class);
}

Модель конкурентности: Servlet, WebFlux и виртуальные потоки

До Java 21 выбор был неприятным: либо блокирующий Servlet-стек с пулом на пару сотен потоков, либо реактивный WebFlux — быстрый под нагрузкой, но с чужой моделью программирования, нечитаемыми стектрейсами и требованием, чтобы весь стек, включая драйвер БД, был неблокирующим.

Виртуальные потоки этот выбор в значительной степени сняли. В Spring Boot 3.2+ достаточно одной строки:

spring:
  threads:
    virtual:
      enabled: true      # требует Java 21+

Tomcat начинает выделять виртуальный поток на запрос, @Async и планировщик — тоже. Код остаётся обычным, блокирующим и отлаживаемым, а масштабирование по числу одновременных запросов растёт на порядки. Механику подробно разбирали в статье про конкурентность, здесь — только следствия для Spring:

  • Пул соединений к БД остаётся узким местом. Десять тысяч виртуальных потоков будут честно ждать двадцати соединений HikariCP. Виртуальные потоки убирают лимит на потоки, а не на базу.
  • synchronized больше не приковывает несущий поток начиная с JDK 24 (JEP 491) — до этого блокировка внутри synchronized приводила к pinning. Если вы на Java 21, старые библиотеки с synchronized вокруг I/O всё ещё стоит проверять.
  • ThreadLocal под виртуальными потоками ведёт себя иначе по цене. Кэши на ThreadLocal, рассчитанные на двести потоков, при десяти тысячах перестают быть кэшами. Используйте ScopedValue, где это возможно.
  • WebFlux не умер, но его ниша сузилась: потоковая передача, SSE/WebSocket, gateway-слой и высококонкурентные прокси с минимальной логикой. Тащить WebFlux в CRUD-сервис ради «производительности» в 2026 году — плохо обоснованное решение.

Тестирование Spring-приложения

Детальный разбор — в статье про тестирование; здесь специфика контейнера.

// Полный контекст: медленно, но проверяет всё, включая автоконфигурацию
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrderApiTest {

    @Autowired MockMvc mvc;
    @MockitoBean PricingClient pricing;   // подменяет бин в контексте (Boot 3.4+; ранее @MockBean)

    @Test
    void создаёт_заказ() throws Exception {
        when(pricing.price("SKU-1")).thenReturn(new PriceResponse(new BigDecimal("10.00")));

        mvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"sku":"SKU-1","quantity":2,"customerId":"8f14e45f-0000-0000-0000-000000000000"}
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().exists("Location"))
                .andExpect(jsonPath("$.total").value(20.00));
    }
}

// Срез: поднимается только web-слой, сервисы мокаются. Секунды вместо десятков секунд
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
    @Autowired MockMvc mvc;
    @MockitoBean OrderService service;
}

Ключевая деталь, определяющая скорость сборки: Spring кэширует контексты между тестовыми классами, ключ кэша — набор конфигурации (классы, профили, свойства, mock-бины). Один @TestPropertySource с уникальным значением в каждом тесте — и вы получаете двадцать поднятий контекста вместо одного. Считайте уникальные конфигурации; на большом проекте разница измеряется в десятках минут CI.

Что Spring делает с производительностью и как это лечат

Честная картина: контекст среднего сервиса поднимается 1.5–4 секунды, потребляет 250–600 МБ и даёт стектрейсы на 60–90 кадров. Причины — рефлексия, сканирование classpath, генерация прокси. Что с этим делают:

# 1. Понять, на что уходит старт: Actuator + BufferingApplicationStartup
curl localhost:8080/actuator/startup | jq '.timeline.events | sort_by(-.duration)[:10]'

# 2. Ленивая инициализация — быстрый старт, но ошибки конфигурации всплывут в рантайме
#    Годится для локальной разработки, спорно для прода
SPRING_MAIN_LAZY_INITIALIZATION=true java -jar app.jar

# 3. Class Data Sharing: JVM переиспользует прогретую метаданными архивную область
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
java -XX:SharedArchiveFile=app.jsa -jar app.jar     # старт быстрее на 20-40%

# 4. AOT-обработка: Spring вычисляет конфигурацию на этапе сборки
./mvnw -Pnative native:compile     # нативный образ GraalVM: старт ~50 мс, память ~100 МБ

Нативный образ — не бесплатное решение: сборка занимает минуты, рефлексия требует конфигурации, динамическая подмена классов и агенты не работают, а профилирование становится другим ремеслом. Осмысленно для serverless и CLI, спорно для долгоживущего сервиса, которому важнее пиковая пропускная способность (JIT её даёт больше — см. JVM изнутри).

Альтернатива для быстрого старта без потери JIT — CRaC (Coordinated Restore at Checkpoint): снимок прогретого процесса, восстановление за десятки миллисекунд. Spring Boot 3.2+ умеет корректно останавливать и возобновлять бины по протоколу Lifecycle.

Грабли: список, который стоит перечитать перед код-ревью

  1. Внедрение в поле. @Autowired на приватном поле прячет рост числа зависимостей и ломает создание объекта в тестах без контейнера.
  2. Self-invocation. Внутренний вызов @Transactional/@Async/@Cacheable-метода не проходит через прокси. Самая частая причина «транзакция не откатилась».
  3. AOP на private/final. Молча не работает. Проверяйте модификаторы.
  4. proxyBeanMethods = false плюс вызовы между @Bean-методами. Получите несколько экземпляров там, где ожидали синглтон.
  5. Главный класс не в корневом пакете. @ComponentScan идёт от пакета класса с @SpringBootApplication; положили его в com.shop.web — половина бинов не найдена. Обратная крайность — класс в пакете com: сканируется весь мир, старт длится минуту.
  6. Prototype внутри синглтона. Создаётся один раз. Нужен ObjectProvider или scoped-прокси.
  7. spring.jpa.open-in-view включён по умолчанию. Соединение с БД держится до конца рендеринга ответа, ленивые связи подгружаются из контроллера, N+1 расцветает. Выключайте осознанно — разбор в следующей статье.
  8. HTTP-клиент без таймаутов. Один медленный внешний сервис выедает весь пул потоков.
  9. Блокирующий I/O в @PostConstruct. Старт растягивается, readiness-проба врёт.
  10. Отсутствие graceful shutdown. server.shutdown=graceful плюс spring.lifecycle.timeout-per-shutdown-phase — иначе при деплое рвутся живые запросы.
  11. @Async без @EnableAsync или с возвратом void — исключения исчезают бесследно. Возвращайте CompletableFuture.
  12. Взрыв тестовых контекстов. Уникальные @TestPropertySource/@MockitoBean в каждом классе убивают кэш и удлиняют CI в разы.
  13. @Value с дефолтом-заглушкой (@Value("${x:}")) вместо валидации: приложение поднимется с пустой строкой и упадёт позже и в другом месте.
  14. Цикл, «починенный» через @Lazy. Технически работает, архитектурно — незакрытый долг.

Честно о месте Spring

Где Spring выигрывает. Долгоживущие серверные приложения со сложной предметной областью, где важнее скорость разработки и предсказуемость эксплуатации, чем миллисекунды старта. Интеграционное тестирование с настоящей инфраструктурой в Java-мире лучшее, что есть. Обратная совместимость такова, что приложение на Boot 2.x поднимается на Boot 3.x за дни, а не месяцы. Кадровый рынок огромен: код, написанный идиоматично, читается любым Java-инженером.

Где Spring проигрывает. Старт и память — цена рефлексии и сканирования. Неявность: поведение задаётся сочетанием аннотаций, свойств и присутствия классов в classpath, и «почему так» иногда выясняется только через --debug. Огромная транзитивная поверхность зависимостей означает регулярный поток CVE и обязательный процесс обновлений. Наконец, стектрейс из семидесяти кадров — плохой инструмент обучения для новичка.

Куда его не стоит тащить. В библиотеки: библиотека не должна требовать контейнер, она должна предоставлять обычные классы, а Spring-обвязку выносить в отдельный *-spring-boot-starter. В короткоживущие лямбды без нативной компиляции. В горячий путь, где каждый вызов проходит через три прокси-слоя: там уместны обычные объекты, созданные вручную.

Соседи по нише. Quarkus и Micronaut делают DI на этапе компиляции: генерируют код вместо рефлексии, отсюда быстрый старт и малая память ценой более длинной сборки и меньшей экосистемы. Spring отвечает AOT-обработкой и CRaC. Выбор между ними — обычно выбор между широтой экосистемы и стоимостью холодного старта.

Сравнение с ASP.NET Core (трек C#/.NET) полезно именно различиями:

Spring ASP.NET Core
Регистрация зависимостей сканирование аннотаций явная в Program.cs
Область видимости singleton / prototype / request singleton / scoped / transient
Перехват вызовов прокси в рантайме (AOP) middleware и явные декораторы
Транзакции @Transactional через прокси явный TransactionScope/UoW
Конфигурация Environment + @ConfigurationProperties IConfiguration + Options
Старт 1.5–4 с 0.05–0.3 с

Ключевое различие философское: Spring предпочитает соглашение и обнаружение, .NET — явность. Отсюда у Spring нет ловушки self-invocation-эквивалента, но нет и @Transactional как декларации. Ни один из подходов не лучше — но зная механику обоих, вы перестаёте воспринимать чужие решения как странные.

Spring Boot 4 и Framework 7: что меняется

Ветка 4.0 (вместе со Spring Framework 7) продолжает то же направление: базовая версия Java 17+, разбиение монолитных jar-ов на более мелкие модули, null-safety на основе JSpecify вместо собственных аннотаций, версионирование API в MVC, декларативные HTTP-клиенты как первоклассный механизм и встроенные аннотации устойчивости (@Retryable, @ConcurrencyLimit) без внешних библиотек. Всё, что описано выше — контейнер, прокси, автоконфигурация, DispatcherServlet — остаётся в силе; меняются координаты артефактов и часть умолчаний. Перед миграцией читайте официальные заметки о релизе, а не советы из блогов.

Мини-итог

  • Spring — это контейнер, который строит граф объектов по описаниям (BeanDefinition), а не набор аннотаций.
  • Жизненный цикл бина строго упорядочен; прокси навешивается на последней фазе, и из этого следуют почти все «неработающие аннотации».
  • Внедряйте только через конструктор, поля делайте final — так класс честно показывает свою сложность.
  • @Transactional и родня работают лишь при вызове извне через прокси. Внутренний вызов this.method() их не активирует.
  • Конфигурация — упорядоченный стек источников без слияния. Типобезопасные @ConfigurationProperties на record с @Validated превращают ошибку настройки в отказ старта.
  • Автоконфигурация — файл AutoConfiguration.imports плюс аннотации @Conditional*. Отладка — --debug и /actuator/conditions.
  • Веб-запрос проходит восемь вложенных рубежей; знание, на каком из них вы находитесь, экономит часы.
  • Виртуальные потоки вернули блокирующему стеку конкурентоспособность и сильно сузили нишу WebFlux.

Источники

Что дальше

Контейнер собран, запрос доходит до сервиса, конфигурация читается из правильного источника. Осталось самое интересное и самое опасное место любого бизнес-приложения — данные. Дальше разберём, как Java разговаривает с базой: от голого JDBC до JPA/Hibernate, что на самом деле делает @Transactional за границей прокси, откуда берётся проблема N+1 и почему ленивая загрузка ломается ровно там, где вы её не ждёте.

Работа с данными: JDBC, JPA/Hibernate, транзакции, N+1 и другие ловушки

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов