ESPNWhich seats are getting warmer? Five NBA coaching situations to monitorDaily MaverickFOOD BASKET: R20 sugar saving highlights SA’s food affordability crisis as grants fall shortThe Jerusalem PostTwo Israelis arrested in Cyprus after over 27 kg. of ketamine, other drugs found in apartmentPunchNigeria@66: 2027 election will test our democratic maturity – JonathanBollywood HungamaDisha Patani completes a decade in Bollywood as MS Dhoni: The Untold Story turns 10: “Beyond grateful ”InquirerZambales incubator to help Central Luzon farmers build businessesGhaflaHospital Contracts Extended- SHA Ensures Continued Service Delivery Through Mid-October20 Minuten«Sie mag Männer mit viel Einfluss»: Ex-Freund über Söder-TochterBBC عربيحبس فريق صحفي كامل يعيد ملف حرية الصحافة في مصر إلى الواجهة محلياً ودولياًTagesschauTeile des Batterieherstellers Varta gehen ins Insolvenzverfahrenגיקטייםסטארטאפ ישראלי בן 8 חודשים נמכר עכשיו ב-100 מיליון דולרIl Fatto QuotidianoIstat, ad agosto 5mila occupati in meno. Tasso di disoccupazione giovanile sale al 20,3%. Confcommercio: “L’occupazione ha smesso di crescere”
The Daily Newsstand · Free, Always
Thursday, October 1, 2026

Мы обновили Spring Boot до 4. Вот что сломалось

Translate

Это наш опыт миграции со Spring Boot 3.5.12 и Spring Cloud 2025.0.1 на Boot 4.0.6 и Spring Cloud 2025.1.x в большом коммерческом проекте, дополненный ссылками на официальные изменения Boot 4. В спорных моментах ссылки указаны прямо в тексте, а в конце есть раздел «Источники».

Введение

Началось всё довольно оптимистично: подняли версии, поправили пару импортов, прошла компиляция. Мы уже представляли, когда можно будет закрывать задачу, — а потом сервис не запустился.

Caused by: java.lang.ClassNotFoundException:
  org.springframework.boot.autoconfigure.http.HttpMessageConverters

Класс, о котором мы даже не думали. Коннектор, который мы написали сами и который был полностью готов к предыдущей версии Spring. Сборка и тесты были зелёными, но старт упал.

С этого момента мы перестали считать зелёную сборку окончанием миграции. Эта статья не о том, что заявлено в Release Notes, а о том, что нужно поправить после того, как всё уже собралось.

Почему всё оказалось не так гладко, как на бумаге? У нас большой монорепозиторий платформенных сервисов на Java и Kotlin: REST и OpenFeign, Kafka и Redis, Spring Security, Testcontainers — всё как у всех. Кроме того, есть отдельный набор внутренних библиотек‑коннекторов, которые обеспечивают интеграции с десятками соседних систем, используются разными командами и проектами (что, в общем, неудобно только во время больших миграций).

Чтобы не быть абстрактными, приведём цифры:

  • Две монорепозитории: первая состоит из 14 модулей, из них десять сервисов, вторая — из семи модулей (пять сервисов), итого 15 сервисов

  • Два библиотечных репозитория, на которые они опираются: в одном — 27 модулей, во втором — 56 модулей‑коннекторов

  • Кроме двух собственных библиотек, мы напрямую тянем ещё 22 публикуемых артефакта от других команд

Всё это нужно было довести до нового стека, не останавливая текущую работу.

Важно подчеркнуть, что мигрировали мы сервисы, а не библиотеки. Библиотеки старались мигрировать по минимуму, так как они общие и дорабатываются ежедневно. В идеале нужно было доработать библиотеки до совместимости и с третьей, и с четвёртой версией Spring Boot.

До миграции стек выглядел так:

Компонент

Было

Стало

Spring Boot

3.5.12

4.0.6

Spring Cloud

2025.0.1

2025.1.x

Kotlin

2.3.0

2.3.21

Jackson

2.x

2 + 3

Gradle

9.2.1

9.2.1

Testcontainers

1.19.8

1.21.4

Главный вопрос мы задали неправильно

Если открыть Migration Guide, всё кажется логичным и по шагам. Но крупный проект редко состоит из одних лишь открытых библиотек. Внутри есть собственные артефакты, шаблонные модели, коннекторы и интеграции. Есть код, написанный несколько лет назад и успешно работающий до сих пор. Есть зависимости, которые сами подтягивают другие зависимости.

Именно поэтому главный вопрос миграции звучал не так:

«Что изменилось в Spring Boot 4?»

А так:

«А как это всё будет работать со Spring Boot 4?»

Spring Boot 4 оказался миграцией экосистемы

Spring Boot 4.0 — это не просто новая версия одной библиотеки. Вместе с ней обновляются связанные уровни стека:

Схема: что тянет за собой Spring Boot 4

Что тянет за собой Spring Boot 4

Если у вас есть общие библиотеки — это главная сложность миграции, а не просто поднятие Spring Boot. Десятки компонентов должны одновременно согласовать новые API. При этом часть этих компонентов используется не только нами. Делать собственные форки и поддерживать их — тоже выбор не без изъяна, хотя критичные узлы нам пришлось форкнуть и привести в соответствие.

Внутренние библиотеки можно собрать для предыдущей версии Boot, сторонние библиотеки обновляются в своём темпе, а на стыке всего этого возникают ошибки, которые не описаны в одном руководстве по миграции.

Первой серьёзной точкой такого столкновения у нас стал Jackson.

Давайте просто обновим Jackson

Если читать Release Notes по диагонали, главное изменение можно свести к одной строке:

Spring Boot 4 переходит на Jackson 3 как библиотеку JSON по умолчанию.

Тихий дрейф контрактов — это то, чего хотелось меньше всего. К счастью, нам оставили пространство для плавной миграции на Jackson 3, правда, не везде.

Что изменилось

В Jackson 3 новые координаты и пакеты:

com.fasterxml.jackson
        ↓
tools.jackson

Единственное исключение — jackson-annotations, который остался в com.fasterxml.jackson.annotation. Поэтому аннотации @JsonProperty, @JsonIgnore и другие продолжают работать в обеих версиях. Держите это в голове, поскольку на этом строится решение.

Например, привычный ObjectMapper уступает место JsonMapper. Было:

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule

val mapper = ObjectMapper().apply {
    registerKotlinModule()
    configure(
        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
        false
    )
}

Стало:

import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.kotlinModule

val mapper = JsonMapper.builder()
    .addModule(kotlinModule())
    .build()

Есть ещё три изменения, о которых стоит знать до первой правки:

  • JsonMapper иммутабельный. Собрали через билдер — больше не меняем.

  • Автоконфигурация бэкоффит только по типу JsonMapper. Свой @Bean ObjectMapper, который в Boot 3 полностью заменял маппер, в Boot 4 просто игнорируется — компилятор об этом не скажет.

  • Jackson‑модули теперь обнаруживаются автоматически. В Boot 3 регистрировались только «хорошо известные» модули, остальные подключались вручную. В Boot 4 Mapper подхватывает всё, что нашло себя через ServiceLoader. Это значит, что на Classpath могут «внезапно» активироваться модули, которые раньше игнорировались. Отключить это можно свойством spring.jackson.find-and-add-modules=false.

На первый взгляд — механическая замена, но дальше начинаются проблемы, которые компилятор уже не всегда ловит.

Try-catch (IOException) больше не означает то же самое

Обработчик ошибочного JSON перестаёт срабатывать молча: исключение пролетает мимо catch, и дальше всё зависит от того, что стоит выше по стеку.

В старом коде ошибки Jackson часто обрабатывались как IOException:

try {
    mapper.readValue(json, Dto::class.java)
} catch (e: IOException) {
    handleBadJson()
}

Это связано с тем, что в Jackson 3 изменилась иерархия исключений: JacksonException теперь является Unchecked Exception. Соответственно, обработку ошибок нужно пересматривать:

try {
    mapper.readValue(json, Dto::class.java)
} catch (e: JacksonException) {
    handleBadJson()
}

Опасность не в том, что код не собирается, а в том, что Happy‑path продолжает работать, а обработка ошибочного JSON перестаёт работать так, как раньше.

Оговоримся: эта информация взята из Javadoc Jackson 3 — в Release Notes Spring Boot её нет, там вообще ничего нет об иерархии исключений. Поэтому искать её в руководстве по эксплуатации бесполезно — смотрите документацию Jackson.

Формат JSON тоже может измениться

Первым вылез не компилятор, а сам JSON. В тестах даты на проводе стали приходить в формате 2010-01-01T08:00:00 вместо привычного 2010-01-01T08:00:00.000, Boolean‑поля — как alternative вместо isAlternative, а все проверки через content().json(...) упали: в Spring‑test 7 JSONAssert стал строже и сравнивает не так снисходительно.

Одна из причин — стандартные настройки сериализации, которые изменились в Jackson 3. Важная деталь: это стандартные настройки самого Jackson 3, а не настройки, которые вводит Spring Boot, — в руководстве по миграции Boot их нет, они зафиксированы в Release Notes Jackson.

Изменений четыре, и вот за что отвечает каждое:

Настройка

За что отвечает

Было

Стало

WRITE_DATES_AS_TIMESTAMPS

Как писать даты в JSON: числом‑таймстампом или строкой ISO-8601

true

false

SORT_PROPERTIES_ALPHABETICALLY

Порядок полей в JSON: по алфавиту или в порядке объявления

false

true

FAIL_ON_NULL_FOR_PRIMITIVES

Падать ли при чтении, если в примитив (int, long, boolean) приходит null

false

true

FAIL_ON_UNKNOWN_PROPERTIES

Падать ли при чтении на поле, которого нет в модели

true

false

Первые две настройки касаются записи JSON, а вторые две — чтения.

WRITE_DATES_AS_TIMESTAMPS отвечает за формат даты на проводе: true — дата записывается числом‑временной меткой, false — строкой в формате ISO-8601. Это единственное из четырёх изменений, которое меняет сам формат JSON, поэтому оно наиболее заметно для внешних потребителей:

// так писалось при true
{ "bakedAt": 1699257000000 }

// так пишется при false
{ "bakedAt": "2025-11-06T05:30:00" }

SORT_PROPERTIES_ALPHABETICALLY отвечает за порядок полей при записи: true — поля сортируются по алфавиту, false — сохраняется порядок объявления в классе. Семантика JSON при этом не меняется, но ломаются все проверки, которые сравнивают JSON как строку, в том числе Snapshot‑тесты.

FAIL_ON_NULL_FOR_PRIMITIVES отвечает за поведение при чтении, если в примитивное поле (int, long, boolean) приходит null: true — выбрасывается ошибка, false — подставляется значение по умолчанию (0, false). Это принципиальное различие: раньше такой JSON проходил, теперь он падает.

FAIL_ON_UNKNOWN_PROPERTIES отвечает за поведение при чтении, если в JSON есть поле, отсутствующее в модели: true — выбрасывается ошибка, false — пропускается, чтение продолжается.

Что из этого действительно меняется в Spring Boot‑приложении

Здесь важно отметить, что часть этих фич Spring Boot отключал и сам. В документации Boot 3.5 состав настроек по умолчанию автоконфигурируемого маппера описан так:

  • MapperFeature.DEFAULT_VIEW_INCLUSION is disabled

  • DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES is disabled

  • SerializationFeature.WRITE_DATES_AS_TIMESTAMPS is disabled

  • SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS is disabled

Это значит, что WRITE_DATES_AS_TIMESTAMPS и FAIL_ON_UNKNOWN_PROPERTIES в Boot‑приложении работали так же и до миграции — изменилась стандартная настройка самой библиотеки, а не поведение вашего маппера. А SORT_PROPERTIES_ALPHABETICALLY и FAIL_ON_NULL_FOR_PRIMITIVES Boot не трогал, значит, изменилось именно поведение приложения, и искать последствия нужно там.

Две оговорки к этому: во‑первых, в документации Boot 4.0 список стандартных настроек маппера больше не приводится, поэтому нельзя гарантировать, что он совпадает со списком для 3.5, а во‑вторых, свойство spring.jackson.use-jackson2-defaults в документации 4.0 описано как возврат к стандартным настройкам, которые Boot использовал для Jackson 2. Поскольку такой возврат предусмотрен, значит, набор настроек в Boot 4 действительно отличается. Вывод простой: проверяйте на своём приложении, а не выводите из документации.

Если нужно вернуть поведение Jackson 2 целиком, то для этого есть отдельное свойство:

spring:
  jackson:
    use-jackson2-defaults: true

Однако это именно откат к прежнему поведению — временная мера на время миграции, а не способ жить дальше. (А мы же так и хотели?)

Почему не удалось полностью перейти на Jackson 3

В идеальном мире мы сделали бы так:

Jackson 2
    ↓
Jackson 3
    ↓
готово

Однако в реальном проекте всё оказалось иначе. Часть сторонних библиотек всё ещё использовала Jackson 2. Внутренние библиотеки‑коннекторы были собраны под Boot 3 и сохраняли Jackson 2 в своих контрактах — менять их резко в рабочем коде никто, конечно, не хотел. Отдельный событийный слой тоже жил по своим правилам. Кроме того, были компоненты вроде Logstash‑энкодера, которые не позволяли просто удалить Jackson 2 из Classpath.

Поэтому вместо полного перехода пришлось принять временную гибридную архитектуру:

Схема: два Jackson одновременно

Два Jackson одновременно

HTTP и Feign остались на Jackson 2, а событийный слой перешёл на Jackson 3. Это не выглядело как идеальное конечное решение, но позволило не переписывать весь парк внутренних библиотек одновременно.

Как это устроено технически

Рабочий мост для HTTP‑контракта — свойство и модуль совместимости:

spring:
  http:
    converters:
      preferred-json-mapper: jackson2
implementation("org.springframework.boot:spring-boot-jackson2")

Настройки Jackson 2 при этом живут в отдельном пространстве spring.jackson2.* — они повторяют то, чем был spring.jackson.* в Boot 3.5.

Следует отметить, что сам Boot описывает этот модуль как временный. Дословно:

This module ships in a deprecated form and will be removed in a future release. It’s intended as a stop‑gap for users that need more time to migrate to Jackson 3.

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

Два Jackson в одном приложении

Наличие двух JSON‑мапперов в одном приложении — это не просто две зависимости. Теперь нужно понимать, какой маппер за какой протокол отвечает. Сначала казалось, что достаточно аккуратно разложить, где какой маппер, и жить с этим, но проблема появилась не там, где ожидали. У нас выделились как минимум следующие зоны:

  • HTTP

  • Feign

  • События Stream Cloud (где используется только Jackson 3)

  • Redis

  • ORM и jsonb

Пример на @JsonNaming и Snake_case

Рассмотрим Kotlin‑модель:

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class TaskResponse(
    val messageId: Long,
    val taskId: Long,
)

В старом окружении это позволяло отправлять:

{
  "message_id": 123,
  "task_id": 456
}

Но в гибридной схеме возникла проблема: стратегия PropertyNamingStrategies.SnakeCaseStrategy относится к Jackson 2, а событийный маппер уже работает с Jackson 3.

В результате стратегия может не примениться, и вместо:

{
  "message_id": 123
}

на провод уйдёт:

{
  "messageId": 123
}

На стороне потребителя модель при этом может ожидать message_id, и тогда ошибка выглядит уже совершенно не так, как причина:

KotlinInvalidNullException:
value failed for JSON property message_id

На верхнем уровне можно увидеть ошибку AOP или несовпадение аргументов, хотя реальная причина гораздо прозаичнее:

продюсер и консьюмер перестали одинаково понимать JSON‑контракт.

Решение

Если контракт должен работать одновременно с Jackson 2 и Jackson 3, лучше сделать его явным:

data class TaskResponse(
    @JsonProperty("message_id")
    val messageId: Long,

    @JsonProperty("task_id")
    val taskId: Long,
)

Получается менее элегантно, зато контракт становится явным, и при миграции это зачастую важнее красоты.

Единственный «мост» между версиями Jackson — аннотации com.fasterxml.jackson.annotation, общие для обеих. Поэтому правило для гибридной схемы такое: в контрактах (DTO, сообщения шины) используйте только эти аннотации, без @JsonNaming со стратегиями из Jackson 2. «Is‑поля» и любые нестандартные имена всегда указывайте через явный @JsonProperty.

Место, где документация противоречит сама себе

Разберём пример подробно, потому что именно здесь легко сделать неверный вывод из документации.

Что меняется и почему. Boot 4 помечает собственный класс HttpMessageConverters как Deprecated. Официальная причина — в Spring Framework улучшили конфигурацию конвертеров в традиционном стеке (spring‑framework#33894), а старый HttpMessageConverters имел недостатки, в частности, смешивал клиентские и серверные конвертеры в одном типе.

Что говорит Migration Guide:

If you are contributing HttpMessageConverter beans to the context (like a JacksonJsonHttpMessageConverter), this is not supported anymore and you will need to update your configuration.

То есть объявлять свой конвертер бином и ждать, что Boot его подхватит, больше не работает.

Чего это НЕ касается. Речь идёт об отдельных конвертерах. Если приложение объявляет бин самого HttpMessageConverters (тип‑обёртка), то он по‑прежнему поддерживается, а Deprecated только сам тип:

If your application declares a custom org.springframework.boot.http.converter.autoconfigure.HttpMessageConverters bean, this is still supported but the type itself is deprecated.

Что предлагается взамен: кастомайзеры с гибкой семантикой.

Instead, your application can declare one or more ClientHttpMessageConvertersCustomizer and ServerHttpMessageConvertersCustomizer that will let you customize converters in a flexible way. Each customizer can choose to contribute converters as “custom” converters considered before default ones, or instead to use a converter instance to replace a default converter that was auto‑detected.

Разделение на клиентские и серверные кастомайзеры — прямое следствие того, что старый тип смешивал обе стороны. Оба интерфейса находятся в том же пакете, что и устаревший класс — org.springframework.boot.http.converter.autoconfigure.

Собственно расхождение. Справочная документация Boot 4.0 в разделе про Web/Servlet утверждает обратное:

Any HttpMessageConverter bean that is present in the context is added to the list of converters.

То есть Migration Guide и Reference описывают одно и то же поведение по‑разному.

Практический вывод: не пытайтесь выяснять это по документации — лучше напишите тест, который проверит, что ваш конвертер действительно применяется. А на месте кастомного конвертера в Feign‑клиенте эта развилка бьёт сильнее всего.

Почему так? Дословно по обеим сторонам. Migration Guide, раздел «HttpMessageConverters Deprecation»: “If you are contributing HttpMessageConverter beans to the context (like a JacksonJsonHttpMessageConverter), this is not supported anymore and you will need to update your configuration”. Reference‑дока Boot 4.0, раздел «Servlet Web Applications → Spring Web MVC → HttpMessageConverters»: “Any HttpMessageConverter bean that is present in the context is added to the list of converters. You can also override default converters in the same way”. Причём противоречие живёт внутри одной страницы: через абзац та же reference‑дока советует «declare one or more ClientHttpMessageConvertersCustomizer or ServerHttpMessageConvertersCustomizer as beans» и называет методы addCustomConverter и withJsonConverter. Обе страницы: https://github.com/spring‑projects/spring‑boot/wiki/Spring‑Boot-4.0-Migration‑Guide и https://docs.spring.io/spring‑boot/4.0/reference/web/servlet.html

А потом прошли компиляция и тесты

На этом этапе возникает опасная иллюзия: кажется, что большая часть работы уже сделана:

Boot 4
   ↓
проект собирается
   ↓
тесты исправлены
   ↓
можно выкатывать

Однако именно после этого у нас начались проблемы со Spring Cloud и Feign.

Про Spring Cloud стоит помнить, что трейн привязан к версии Boot. Для Boot 4.0.x таблица совместимости даёт 2025.1 «Oakwood», а предыдущая линия 2025.0 «Northfields» — это трейн под Boot 3.5.x. Внутри трейна компоненты получили версию 5.0.x — то самое «Spring Cloud 5». Это не имя релиза, а набор артефактов.

Есть ещё два важных момента, которые не всегда очевидны.

1. Spring Cloud имеет свой BOM, и держать его надо вместе с BOM Boot. Cloud не приходит из spring-boot-dependencies: у трейна отдельный BOM spring-cloud-dependencies, который подключают в dependencyManagement. Дословно из документации:

Now that you know which release train to use and the latest service release for that release train you are ready to add the Spring Cloud BOM to your application.

При этом облачный BOM не заменяет бутовый, а специально от него независим:

This is a BOM‑only version and it just contains dependency management and no plugin declarations or direct references to Spring or Spring Boot.

Таким образом, в проекте одновременно живут два BOM. Вручную сводится не каждый артефакт, а пара «трейн ↔ Boot»: версии Cloud‑компонентов приходят из spring-cloud-dependencies, а версию самого Boot по‑прежнему определяет Boot. Эта пара не произвольна: 2025.1.x совместим с 4.0.x (а с 2025.1.2 — ещё и с 4.1.x), 2025.0.x — с 3.5.x. Migration Guide Boot называет Cloud прямо:

You may also use dependencies that are not managed by Spring Boot (e.g. Spring Cloud). As your project defines an explicit version for those, identify the compatible version before upgrading.

Если пару не сверить, вы не получите «странную ошибку где‑то в рантайме». Приложение упадёт на старте с сообщением Сompatibility‑verifier — Spring Boot [X] is not compatible with this Spring Cloud release train. Список допустимых версий Boot зашит внутрb трейна (spring.cloud.compatibility-verifier).

Почему так? В spring-boot-dependencies версий 4.0.0 и 3.5.0 нет ни одного артефакта org.springframework.cloud (проверено по POM из Maven Central), в приложении Boot “Dependency Versions → Coordinates” отсутствуют вхождения org.springframework.cloud. Обе цитаты про BOM и таблица совместимости взяты здесь: https://spring.io/projects/spring‑cloud, а механизм проверки — spring.cloud.compatibility-verifier в spring-cloud-commons (в 5.0.x допустимы 4.0.x, 4.1.x; в актуальной документации раздел с описанием удалён, но живое описание осталось в документации cloud‑commons 3.1.x).

2. Отдельного руководства по миграции для Cloud не существует. Вся информация содержится в Release Notes трейна и блогах; у компонентов нет отдельных разделов «Upgrading».

Почему так? В Release Notes трейна 2025.1 секция Breaking Changes формально есть, но состоит практически из одной общей фразы про Jackson 3, JSpecify и изменения в Framework 7 / Boot 4. То есть рассчитывать на Release Notes тоже не стоит.

Старт упал

Одна из ошибок выглядела так:

Caused by: java.lang.ClassNotFoundException:
  org.springframework.boot.autoconfigure.http.HttpMessageConverters

Логичный первый шаг — добавить недостающую зависимость. Но причина оказалась интереснее. Boot 4 перенёс HttpMessageConverters в отдельный модуль spring-boot-http-converter и пакет org.springframework.boot.http.converter.autoconfigure. В новом месте класс помечен Deprecated с 4.0.0 с удалением в 4.2.0. В старом пакете его больше нет.

У нас один из внутренних коннекторов был собран под Boot 3 и содержал ручную конфигурацию Feign:

@Bean
fun decoder(
    converters: ObjectFactory<HttpMessageConverters>
) = SpringDecoder(converters)

То есть приложение уже было на Boot 4, а байткод внутренней библиотеки всё ещё ссылался на API предыдущей версии. При создании дочернего контекста Feign Spring доходил до этого класса и падал. Исходники самого сервиса могли быть полностью готовы к Boot 4, но бинарная зависимость — нет.

Что из этого следует

При мажорной миграции стоит обращать внимание не только на build.gradle.kts, но и на следующие части проекта:

service
  ├── internal libraries
  ├── connectors
  ├── shared modules
  └── generated code

Именно в этих местах может быть ссылка на удалённый API. Проверьте эти зависимости до начала миграции. Если библиотека не обновлена под новый Boot, ваш сервис может быть полностью готов, но при этом не запустится. В нашем случае решение — обновить сам коннектор: ручная конфигурация, завязанная на старый HttpMessageConverters, больше не нужна в прежнем виде.

Feign не нашёл Encoder — и виноват был не Feign

Следующая проблема выглядела проще:

No bean found of type feign.codec.Encoder for

В нашем случае это вылезло не в тестах, а при запуске сервиса на стенде. Причина крылась в условиях создания автоконфигурации OpenFeign. Сначала подозревали Feign: своя конфигурация декодеров, кастомные бины. Но к самой конфигурации это отношения не имело.

Хорошая новость: проблема не экзотическая — её знают в самом Spring Cloud. Она описана в трекере spring‑cloud‑openfeign — issue #1323, причём была закрыта как вопрос документации в версии 5.0.2.

Механика по коду FeignClientsConfiguration такая:

  • Резервный Encoder объявлен под условием @ConditionalOnMissingClass("org.springframework.data.domain.Pageable")

  • Вложенная конфигурация — под @ConditionalOnClass сразу на Pageable и DataWebProperties. Именно там создаётся Pageable‑вариант энкодера.

Получается, что если в Classpath есть Pageable, но отсутствуетDataWebProperties , ни одно из условий не выполняется, и бин Encoder просто не создаётся.

В нашем случае отсутствовал один из необходимых модулей. Сам DataWebProperties в Boot 4 переехал в опциональный модуль spring-boot-data-commons.

Решение:

implementation(
    "org.springframework.boot:spring-boot-data-commons"
)

Это хороший пример ошибки, которая выглядит как проблема Feign, хотя на самом деле является следствием изменения структуры автоконфигурации после обновления стека.

Security 7: здесь было меньше сюрпризов

После Jackson и Cloud Security уже выглядел значительно спокойнее. Если кратко, то глобально ничего не сломалось, но стоит проверить несколько моментов.

Lambda DSL

Старый стиль:

http.authorizeHttpRequests()
    .requestMatchers("/srv/**").authenticated()
    .and()
    .formLogin()

Переходит в новый DSL:

http {
    authorizeHttpRequests {
        authorize("/srv/**", authenticated)
    }

    formLogin { }
}

Для Kotlin DSL важно не забыть соответствующий invoke‑импорт — org.springframework.security.config.annotation.web.invoke. IDE не всегда его подставляет, а без него конфигурация не компилируется.

Уточним, что именно убрали: and() удалён из DSL HttpSecurity, поэтому старые цепочки просто не собираются. Также удалён apply(SecurityConfigurerAdapter) (deprecated с 6.2), ему на смену пришёл with(...). А вот перегрузка apply(SecurityConfigurer) осталась.

Matchers

Вместо старых AntPathRequestMatcher и MvcRequestMatcher (в 7.0 они удалены) используется PathPatternRequestMatcher.

Два уточнения, которые легко пропустить:

  • Документация требует, чтобы все URI были абсолютными — без Context Path

  • Если у приложения нестандартный Servlet Prefix, его задают в сборщике матчера, а не в самом матчере: PathPatternRequestMatcher.withDefaults().basePath("/mvc").

Security + Jackson

Внутри Spring Security также произошёл переход на Jackson 3:

SecurityJackson2Modules
        ↓
SecurityJacksonModules

Старый класс остался, но помечен как Deprecated с планом удаления. Spring Authorization Server тоже перешёл на Jackson 3 по умолчанию. Это ещё раз показывает общую проблему миграции:

Jackson 3 нельзя рассматривать только как изменение Dependency. Он проходит через другие компоненты Spring.

Мелочи, которые тихо меняют поведение

На всякий случай:

  • В 7 стандартный LoginUrlAuthenticationEntryPoint сместился в пользу Relative‑редиректов, чтобы вернуть прежнее поведение, используйте метод setFavorRelativeUris(false) — метод доступен начиная с 6.5

  • Удалён PortResolver (Deprecated ещё с 6.5)

  • Настройка CSRF‑cookie происходит через setCookieCustomizer на CookieCsrfTokenRepository

  • requiresChannel заменён на redirectToHttps

  • Тесты: @WithMockUser/@WithUserDetails теперь требуют spring-boot-starter-security-test (это пункт из Boot 4, а не из Security)

  • Legacy Access API (AccessDecisionManager, AccessDecisionVoter) вынесены в отдельный модуль spring-security-access (если он был у вас в зависимостях, теперь надо подключать его явно)

JUnit

Boot 4 поставляется с JUnit 6 начиная с версии 4.0.0 (не в патче). Поэтому старую явно закреплённую версию JUnit лучше не тащить в новый стек без необходимости, иначе можно получить:

NoSuchMethodError: ExtensionContext$Store.computeIfAbsent(…)

Остальное в тестовом стеке

Несколько изменений, которые стоит проверить:

  • Тестовые стартеры стали помодульными. Вместо «одного spring-boot-starter-test на всё» теперь используются spring-boot-starter-<technology>-test, а аннотации Test‑slices переехали в org.springframework.boot.<technology>.test.autoconfigure (у Data‑слайсов — org.springframework.boot.data.<technology>.test.autoconfigure).

  • Тестовые контексты можно ставить на паузу. Framework 7 приостанавливает неиспользуемые контексты. Управление происходит через свойство spring.test.context.cache.pause (значения ALWAYS, ON_CONTEXT_SWITCH (по умолчанию), NEVER). Если тесты с Lifecycle/Quartz/JMS повели себя иначе или Suit стал тормозить, то начинать стоит с NEVER. Для @Nested‑тестов есть @SpringExtensionConfig(useTestClassScopedExtensionContext = true).

HttpHeaders больше не тот Map, который мы знали

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

После первого вызова на стенде посыпалась лавина:

java.lang.NoSuchMethodError: org.springframework.http.HttpHeaders.get(Ljava/lang/Object;)Ljava/lang/Object;

Однако самая неприятная проблема была в том, что сервис не падал, а зависал. Ошибка не доходила до обработчика, потому что NoSuchMethodError — это Error, а перехватчик ловил только Exception. В реактивной цепочке Reactor счёл её фатальной и прибил подписку, из-за чего вызов никогда не завершался.

Виновником оказался наш собственный перехватчик временных зон — форк коннекторного, собранный ещё под Spring 6. Внутри была строка: headers["Time-Zone"]. Для Spring 6 это обычный Map.get, для Spring 7 — попытка вызвать метод get(Object), которого у HttpHeaders больше нет. Решение — заменить на классовый метод:

val timeZone = it.headers.getFirst(timeZoneHeader)

С тех пор у перехватчика появился собственный тест, воспроизводящий именно этот сценарий — чтобы он не выстрелил в третий раз.

В Spring Framework 7 HttpHeaders больше не расширяет старый контракт MultiValueMap, поэтому код, который работал с ним как с обычной map‑структурой:

headers[name]
headers.put(...)
headers.entrySet()

может перестать работать — причём не на этапе компиляции, а во время выполнения, и только на тех путях, где этот код реально исполняется. Особенно неприятно, если такая операция находится во внутренней библиотеке, собранной против Spring 6. Тогда ошибка будет выглядеть так:

java.lang.NoSuchMethodError: … HttpHeaders.get(…)

Правильный подход — использовать API самого HttpHeaders:

getFirst()
set()
add()

А переходный asMultiValueMap() уже помечен deprecated.

Ещё одна деталь, которую легко пропустить — миграции базы данных

Приложение может успешно стартовать, всё зелёное, но при этом миграции не применятся. Обнаружить это можно значительно позже, уже при работе с данными.

Причина в том, что с Boot 4 Flyway и Liquibase вынесены в отдельные Starter — spring-boot-starter-flyway и spring-boot-starter-liquibase. Руководство по миграции прямо требует заменить прежнюю зависимость на стартер, иначе Runtime‑модуля миграций в Classpath просто не окажется. Поэтому после миграции важно проверять не только Application started, но и Database migrations actually applied.

Это именно тот класс проверок, который легко потерять между «сборка зелёная» и «можно выкатывать»: запуск приложения и применение миграций — два разных события, и второе нужно подтверждать явно, а не выводить из первого.

Tracing и MDC

Tracing после миграции — отдельная проверка. У нас она началась с симптома: в логах пропали mdc.traceId и mdc.spanId. Формально приложение работало, но разбирать инциденты по журналам стало невозможно. Это как раз то, что замечаешь не сразу.

Причин оказалось две, и их важно не путать между собой.

Первая — структурная. В Boot 4 трассировка вынесена в самостоятельные модули (spring-boot-micrometer-tracing, spring-boot-micrometer-tracing-brave, ...-opentelemetry) и больше не является частью Actuator. В документации для конкретных трассировщиков указаны стартеры: spring-boot-starter-zipkin (Brave + Zipkin) и spring-boot-starter-opentelemetry. Если нужен Brave не для Zipkin (например, свой JSON‑журнал с traceId), то модули подключаются явно, иначе бинов трассировки в контексте не будет.

Вторая причина поведенческая. Она останется даже после того, как бины вернулись на место: Boot не наполняет MDC полями traceId и spanId автоматически.

Особенно это касается:

  • Реактивных цепочек

  • Kotlin coroutines

  • Ручных переключений контекста

  • MDC;

  • Исходящих HTTP‑вызовов

Если приложение использует в журналах traceId и spanId, то это стоит проверять отдельным интеграционным тестом, а не полагаться только на наличие Bean в контексте.

Также стоит сверить с Kubernetes: в Boot 4 Health Endpoint по умолчанию отдаёт группы liveness и readiness . При нестандартном базовом пути они будут, например, /health/liveness. Это можно отключить свойством: management.endpoint.health.probes.enabled=false.

Где именно возникали проблемы

Для удобства сводим основные проблемы в таблицу.

Проблема

Где обнаруживается

Причина

Направление решения

ObjectMapper / Jackson API

Compile

Jackson 3

JsonMapper и новые импорты

catch (IOException)

Runtime/Error Path

Новая иерархия исключений

Обработка JacksonException

Свои @Bean ObjectMapper

Runtime

Автоконфигурация бьёт по типу JsonMapper

Перейти на JsonMapper

@JsonNaming

Integration

Jackson 2/3

Явные @JsonProperty

HttpMessageConverter‑бины

Runtime

Изменённый вклад конвертеров

Client/ServerHttpMessageConvertersCustomizer

HttpMessageConverters

Runtime

Старый внутренний коннектор

Доработать библиотеку

Encoder

Runtime

Условие автоконфигурации Feign

Добавить нужный модуль

JSpecify

Compile

Новые Nullability‑аннотации

Исправить типы

@MockBean

Compile

Изменения тестового API

@MockitoBean

JUnit

Test Runtime

Несовместимость версий

Новый Baseline

HttpHeaders.get()

Runtime

Spring Framework 7

Использовать API HttpHeaders

DB migrations

Startup/Integration

Отдельный starter

Проверить автоконфигурацию

Tracing

Runtime

Propagation/Context

Проверить контекст

Эта таблица хорошо иллюстрирует главную особенность миграции: проблемы распределены по всему жизненному циклу приложения.

Что получилось

Финальный стек после миграции:

Компонент

Версия/Состояние

Spring Boot

4.0.6

Spring Cloud

2025.1.x

Kotlin

2.3.21

Jackson HTTP / Feign

2

Jackson Events

3

Testcontainers

1.21.4

Оценить объём можно по диффу: около 775 изменённых файлов в одной монорепозитории и примерно 177 — в другой. Большая часть этих изменений носит механический характер: импорты, имена, конфигурации. Основное время ушло на несколько ключевых мест, которым посвящена эта статья.

Отмечу еще раз: мы сознательно оставили Jackson 2 в HTTP‑слое. На нём завязаны внешние контракты и внутренние библиотеки‑коннекторы, а одновременный перевод всего слоя не давал достаточного выигрыша по сравнению с рисками. При этом событийный слой работает на Jackson 3, поскольку другого варианта не было.

Хотя это и не идеальное конечное состояние, такой подход позволил быстро и без лишнего риска избежать резкого дрейфа контрактов и перейти на новую мажорную версию фреймворка.

Чек‑лист

Ретроспективно можно собрать такой чек‑лист на будущее.

До обновления

  • [ ] Обновить Boot 3 до последнего патча 3.5.x

  • [ ] Убрать Deprecated API

  • [ ] Проверить внутренние библиотеки и коннекторы

  • [ ] Определить, против какой версии Spring они были собраны

  • [ ] Проверить Feign‑конфигурации

  • [ ] Найти кастомные JSON‑мапперы и кастомные HttpMessageConverter‑бины

  • [ ] Проверить Kotlin Nullability

  • [ ] Проверить тестовый стек

  • [ ] Заранее определить стратегию Jackson 2, Jackson 3 или Hybrid

После успешной компиляции

  • [ ] Запустить интеграционные тесты

  • [ ] Проверить реальные Feign‑вызовы

  • [ ] Проверить Kafka Producer/Consumer

  • [ ] Проверить JSON‑контракты

  • [ ] Убедиться, что кастомный конвертер реально применяется

  • [ ] Проверить Security Filters

  • [ ] ПроверитьTracing

  • [ ] Проверить MDC

  • [ ] Flyway/Liquibase: убедиться, что миграции действительно применились

  • [ ] Проверить Testcontainers в CI

  • [ ] Проверить Kubernetes Health Probes

Перед эксплуатацией

  • [ ] Провести Smoke‑тесты на стенде

  • [ ] Проверить внешние JSON‑контракты

  • [ ] Проверить форматы дат

  • [ ] Проверить snake_case

  • [ ] Проверить Kafka‑контракты

  • [ ] Провести нагрузочные тесты

  • [ ] Проверить Readiness/Liveness

  • [ ] Проверить Rollback‑сценарии

  • [ ] Убедиться, что spring-boot-jackson2 не остался в сборке как «постоянное» решение

Краткая шпаргалка

Было

Стало

spring-boot-starter-web

spring-boot-starter-webmvc (старое имя осталось Deprecated‑алиасом)

spring-boot-starter-aop

spring-boot-starter-aspectj (старое имя удалено)

@MockBean

@MockitoBean

@SpyBean

@MockitoSpyBean

ObjectMapper

JsonMapper

com.fasterxml.jackson.databind.*

tools.jackson.databind.*

Jackson 2 Kotlin module

Jackson 3 Kotlin module (tools.jackson.module:jackson-module-kotlin)

AntPathRequestMatcher

PathPatternRequestMatcher

spring.jackson.read/write.*

spring.jackson.json.read/write.*

spring.jackson.* (Jackson 2 настройки)

spring.jackson2.*

Заключение

Пройтись по Migration Guide — это только половина дела. Самое сложное — мигрировать все ваши самописные библиотеки и кастомные решения. Чем меньше «велосипедов» и чем ближе проект к нативной инфраструктуре, тем прозрачнее будут миграции. Каждая собственная библиотека — это дополнительная точка отказа и контракт, который придётся перепроверять вручную. Если вы только начинаете проект и раздумываете, стоит ли использовать большие общие коллекции библиотек, которые будут обновлять ежедневно, заранее продумайте стратегию их плавной миграции для всех потребителей или поддержку нескольких версий одновременно.

Источники

Официальная документация Spring:

Блоги и релизы:

Трекеры:

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.