Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

В этой статье расскажу, как перевести сервис со Spring Boot 3.5 на 4.0 рецептом OpenRewrite и заранее поймать то, что не заметят компилятор и существующие тесты сервиса.

Представьте обычный понедельник. Рецепт поднял версию Spring Boot, заменил стартеры и обновил импорты Jackson. Merge request аккуратный, пайплайн зелёный, ревьюеру придраться не к чему.

Сервис уезжает на стенд, а вечером приходит смежная команда: их клиент перестал понимать статус заказа, потому что вместо PAID в ответе теперь Оплачен. Заодно часть старых клиентов получает 400 на запросах, которые годами проходили.

Я собрал такой сервис и прошёл этот путь в отдельном репозитории, чтобы цифры можно было пересчитать одной командой.

Итог: рецепт отработал, mvn verify зелёный, а изменились все девять контрольных ответов.

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

Исходные условия

Сервис заказов orders-api: Spring Boot 3.5.16, Java 21, Maven 3.9, Spring MVC, JPA, Flyway, H2 в памяти. Восемь классов и два обычных теста контроллера.

Рецепт org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0 из артефактов Maven Central: плагин 6.46.1 под Apache 2.0, rewrite-spring 6.37.1 под Moderne Source Available License.

Для этих версий учётная запись не нужна, но условия второй лицензии стоит прочитать до применения в компании. Документация OpenRewrite предупреждает, что рецепты переезжают из Maven Central в Code Genome Project, поэтому для новых релизов придётся настроить доступ.

Сразу оговорюсь: ловушки заложены намеренно. В сервисе enum с собственным toString(), примитив int в запросе и тесты без проверки поля status. Девять из девяти показывают механизм, а не долю таких поломок на рынке. Но каждая взята из обычного кода: enum с русской подписью для логов легко встретить в живом сервисе.

Вот маршрут целиком на схеме:

Маршрут миграции со снимком контракта

Маршрут миграции со снимком контракта

Главная мысль схемы: снимок делается первым и повторяется после каждого решения, а зелёная сборка стоит посередине и ничего не завершает.

Шаг 1. Снять контракт до миграции

После рецепта старого поведения уже не снять, поэтому начинаем с него. Скрипт запускает собранный jar и записывает код ответа и тело байт в байт.

О Spring он ничего не знает и при миграции не меняется. Именно этим он отличается от тестов сервиса, которые рецепт переписывает вместе с кодом.

record 01-list-seeded     GET  /api/orders
record 02-get-seeded      GET  /api/orders/1
record 03-create-plain    POST /api/orders '{"sku":"B-200","price":1000.00,"discountPercent":10}'
record 04-create-null-int POST /api/orders '{"sku":"B-201","price":1000.00,"discountPercent":null}'
record 05-create-no-int   POST /api/orders '{"sku":"B-202","price":1000.00}'
record 06-create-unknown  POST /api/orders '{"sku":"B-203","price":1000.00,"discountPercent":0,"channel":"web"}'
record 07-create-trailing POST /api/orders '{"sku":"B-204","price":1000.00,"discountPercent":0} {"retry":true}'
record 08-set-status      POST /api/orders/2/status '{"status":"PAID"}'
record 09-health          GET  /actuator/health

Обратите внимание на запросы 04–07. Запросы из документации обычно и так работают, а ломается то, что клиенты шлют по привычке: null вместо числа, лишнее поле, хвост после тела.

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

Проверка шага: два прогона подряд на одной версии дают одинаковые файлы. Если нет, в ответах есть нестабильные значения, и их надо зафиксировать или вырезать. В демо время создания заказа задано константой.

Шаг 2. Запустить рецепт

mvn -B org.openrewrite.maven:rewrite-maven-plugin:6.46.1:run \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:6.37.1 \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0

Со всем, что видно при сборке, рецепт справился, и справился хорошо:

Что было

Что стало

родитель 3.5.16

4.0.9

spring-boot-starter-web

spring-boot-starter-webmvc

flyway-core

spring-boot-starter-flyway, миграция применилась

com.fasterxml.jackson, Module

tools.jackson, JacksonModule

@MockBean

@MockitoBean

Особенно приятна замена Flyway: по Migration Guide в Boot 4 без отдельного стартера миграции не запускаются, и это легко пропустить руками. Файл application.properties рецепт не тронул.

Проверка шага: git diff --stat и версия родителя в pom.xml. Формулировка «BUILD SUCCESS» здесь ничего не доказывает.

На первом прогоне моего скрипта рецепт работал в каталоге build/, который указан в .gitignore. OpenRewrite пропускает игнорируемые файлы, поэтому не изменил ни строки, напечатал BUILD SUCCESS, а сравнение честно показало 0 из 9.

Такой результат выглядел бы отличной новостью, если бы родитель в pom.xml не остался на 3.5.

Шаг 3. Собрать и прогнать тесты

mvn clean verify на Boot 4.0.9 дал BUILD SUCCESS, 2 теста из 2. Вот почему они прошли:

mvc.perform(get("/api/orders"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$[0].sku").value("A-100"));

Тест проверяет код ответа и одно поле. Такие тесты пишутся постоянно, и у меня нет к ним претензий: они проверяют базовый сценарий контроллера.

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

Проверка шага: зелёная сборка подтверждает, что код собирается и базовый сценарий работает. О контракте она не говорит ничего.

Шаг 4. Снять контракт на 4.0 и сравнить

Тот же скрипт, новый jar, побайтовое сравнение с эталоном. Изменились все девять ответов:

Ответ

Spring Boot 3.5

Spring Boot 4.0

01, 02, 03, 06: тело заказа

id первым, "status":"PAID"

createdAt первым, "status":"Оплачен"

04: discountPercent равен null

201

400

05: нет discountPercent

201

400

07: лишний JSON после тела

201

400

08: на входе {"status":"PAID"}

200

400

09: /actuator/health

{"status":"UP"}

добавилось "groups":["liveness","readiness"]

Само по себе побайтовое различие поломку не доказывает: порядок полей JSON не меняет, по RFC 8259 объект неупорядочен. Но здесь ни один ответ не изменился только порядком. В 01, 02, 03 и 06 другой status (в 06 ещё и id, потому что отклонённые 04 и 05 не заняли номера), в 04, 05, 07 и 08 другой код, в 09 новое поле.

Поэтому run.sh сравнивает ответы ещё и как JSON с отсортированными ключами, и там тоже девять из девяти. Контроль: если исправить всё, кроме сортировки, побайтово отличаются 8 ответов, а как JSON ни одного.

Восемь, а не четыре: к 01, 02, 03 и 06 добавляются 04, 05, 07 и 08, они снова успешны и возвращают заказ с другим порядком полей.

Оговорка про 09: оба jar запускаются вне Kubernetes. На Kubernetes Boot 3.5 сам включает probes: с spring.main.cloud-platform=kubernetes его health тоже отдаёт groups, и этого различия там не будет.

Восемь различий из девяти дал переход Spring Boot 4 на Jackson 3 с его новыми настройками по умолчанию.

Чтобы не гадать, я отдельными прогонами включал старое поведение по одной настройке и смотрел, какой ответ возвращается. В run.sh их нет, но contract/capture.sh принимает настройку аргументом.

Результат на схеме:

Какая настройка какой ответ меняет

Какая настройка какой ответ меняет

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

Пропуск поля попал в одну настройку с null не случайно:

public record CreateOrderRequest(String sku, BigDecimal price, int discountPercent) {
}

В Jackson 3.1, который берёт Boot 4.0.9, отсутствующий компонент record попадает в конструктор как null, поэтому в логе у 04 и 05 одна строка: Cannot map null into type int. FAIL_ON_MISSING_CREATOR_PROPERTIES тут ни при чём: одна настройка fail-on-null-for-primitives возвращает оба ответа.

Это не правило для любых DTO: по issue 5734 Jackson так ведут себя record и @JsonCreator, а класс с пустым конструктором нет, и мейнтейнер обещает другое умолчание в 3.2.

Проверка шага: прежний jar, снятый тем же скриптом, даёт 0 различий из 9. Значит, расхождения принесла миграция, а не нестабильность снимка.

Отдельно про status. Рецепт честно перевёл сериализатор денег на новый API, но умолчания Jackson рецепту не подчиняются: это поведение библиотеки, а не строка кода.

Шаг 5. Принять решение по каждому различию

Первый порыв понятен: поставить флаг совместимости и забыть.

spring.jackson.use-jackson2-defaults=true

Он возвращает 8 ответов из 9 байт в байт. Остаётся только health, и её закрывает вторая настройка:

management.endpoint.health.probes.enabled=false

Итог: 0 различий из 9. Но флаг возвращает все старые умолчания разом, включая те, от которых Jackson 3 отказался не зря, а сам Migration Guide называет его помощью на время перехода и настоятельно советует переходить на Jackson 3.

Бывал он и с сюрпризом: на версиях 4.0.4 и 4.0.5 с этим свойством был включён FAIL_ON_UNKNOWN_PROPERTIES, хотя в Boot 3 он выключен (issue 49951, исправлено в 4.0.6). На 4.0.9 в моём прогоне запрос 06 с неизвестным полем сошёлся с эталоном, но проверять такое всё равно надо снимком, а не верой.

Тот же результат 0 из 9 дают пять явных настроек вместо флага, настройка probes при этом остаётся:

spring.jackson.mapper.sort-properties-alphabetically=false
spring.jackson.datatype.enum.write-enums-using-to-string=false
spring.jackson.datatype.enum.read-enums-using-to-string=false
spring.jackson.deserialization.fail-on-null-for-primitives=false
spring.jackson.deserialization.fail-on-trailing-tokens=false

Мой вариант такой: флаг годится как мост на первый выкат, если клиентов много и обойти всех быстро не получится. Затем я бы заменил его явными настройками и прошёлся по списку с вопросом «кому это нужно»:

Различие

Что я бы сделал

значение status, чтение и запись

вернуть обязательно: это контракт, а toString() для логов контрактом становиться не должен. Надёжнее всего @JsonValue на методе enum, который возвращает name(): в отдельном прогоне такой метод вернул PAID и на выходе, и на входе без единой настройки. Если константу могут переименовать, метод возвращает отдельное поле с внешним значением

порядок полей

вернуть на время миграции, если есть клиенты со сравнением строк или подписью тела. В остальных случаях можно принять. Настройка лишь выключает сортировку, а порядок Jackson при этом не обещает, гарантию даёт @JsonPropertyOrder

null и пропуск в int

вернуть, а затем решить явно: обязательное поле проверять валидацией, у необязательного сделать Integer и определить, что значат пропуск и null

хвост после тела

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

groups в health

принять, если клиенты не сравнивают тело целиком. Отключать probes ради старого JSON рискованно: /actuator/health/liveness и /readiness после этого отвечают 404, а на них часто смотрит Kubernetes

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

Проверка шага: повторный снимок, в различиях остаётся только принятое. Эталон обновляется отдельным коммитом, с объяснением в сообщении.

Шаг 6. Закрепить контракт в сборке

Снимок поймал различия, но разовый скрипт защищает только эту миграцию. Я добавил в репозиторий контрольный тест, который сверяет тело точно:

mvc.perform(get("/api/orders"))
        .andExpect(status().isOk())
        .andExpect(content().string("""
                [{"sku":"A-100","status":"PAID","total":"1500.00","createdAt":"2026-10-01T09:00:00Z"}]"""));

Поля id в ожидаемом теле нет не по ошибке: репозиторий здесь заглушка, у несохранённой сущности id равен null, а null не выводится из‑за spring.jackson.default-property-inclusion=non_null. В эталоне запроса 01 id есть.

Сравнение строкой тоже намеренное, чтобы увидеть и порядок. Если порядок ключей контрактом не оговорён, в рабочем тесте лучше брать content().json(expected, JsonCompareMode.STRICT): на 3.5 такая проверка с переставленными ключами зелёная, а на 4.0 падает на status.

В демо класс намеренно называется OrderContractCheck: под шаблон Surefire он не подходит, обычный mvn verify его пропускает, и только поэтому сборка на шаге 3 зелёная. run.sh запускает его отдельно через -Dtest=OrderContractCheck. В своём сервисе назовите такой класс ...Test, тогда он войдёт в обычную сборку: вместе с ним mvn verify на 4.0 падает, 4 теста, 2 из них красные.

Проверка шага: на 3.5 класс зелёный, а на 4.0 после рецепта оба его теста падают: responseBodyIsExact видит и порядок, и Оплачен, nullDiscountIsAccepted получает 400 вместо 201.

Значит, дело в глубине проверки: сборка осталась зелёной только потому, что проверяла мало. Жёсткая сверка всех полей подойдёт не везде, поэтому я бы держал её для нескольких ключевых ответов, а остальное отдал контрактным тестам с потребителями или снимку в CI.

Что делают другие

Мне попалась показательная история Duolingo: они обновляли JVM‑сервисы связкой OpenRewrite и AI‑агентов. Один из их выводов звучит так: «Don't over‑rely on AI. Learn to love determinism». Снимок контракта как раз детерминированная проверка, и ему всё равно, кто менял код: рецепт, агент или человек.

Похожую картину показывает свежий бенчмарк IBM ScarfBench, опубликованный 27 октября 2026 года. Он про миграцию между Java‑фреймворками силами агентов, задача другая, но разрыв тот же.

Без четырёх задач, на которых все получили ноль, восемь связок модели и агента из десяти скомпилировали не менее 97%, но лучший результат по поведенческим smoke‑тестам на том же наборе составил 79,4%, а на всех задачах 75,2%.

Из 745 классифицированных неудачных прогонов 535, около 72%, это сбои поведения: приложение собиралось, часто запускалось и не проходило проверку.

Типовые сбои

  • Рецепт ничего не изменил, но написал BUILD SUCCESS. Проверьте .gitignore и версию родителя.

  • Свойства spring.jackson.read.* и spring.jackson.write.* для JSON переехали в spring.jackson.json.read и spring.jackson.json.write. Маппер Jackson 3 настраивается через JsonMapper и JsonMapperBuilderCustomizer, а ObjectMapper из Jackson 2 живёт рядом с автонастройкой Boot и её не настраивает. Оба пункта есть в Migration Guide.

  • TestRestTemplate больше не предоставляется @SpringBootTest автоматически, а spring-retry выведен из BOM. Об этом сообщит уже сборка.

Где это не сработает

Снимок видит только те запросы, которые вы в него положили, и только код ответа с телом.

Заголовки вроде Content-Type и Location в него не входят, значимые для вашего сервиса надо добавить. В наборе нет запросов, которые уже на 3.5 отвечали ошибкой, поэтому сохранение ответов 4 xx и 5 xx не проверено, для них нужна отдельная группа запросов. Сообщения в Kafka, кэш и JSON в базе данных проходят мимо снимка.

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

Рецепт я гонял на одном маленьком модуле, поведение на многомодульном проекте и внутренних библиотеках здесь не измерено.

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

Что в итоге

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

Порядок простой: снимок до рецепта, сравнение после него, решение по каждому различию и тест в сборке.

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

Ссылка на мой репозиторий с экспериментом

Начать стоит с expected.txt: там все строки итога, на которых построена статья. Эталонные ответы версии 3.5 лежат в contract/golden, снимок контракта делает contract/capture.sh, а вся цепочка от рецепта до сверки запускается командой ./run.sh.

ИСТОЧНИКИ

После миграции на Spring Boot 4 зелёная сборка ещё не гарантирует, что сервис сохранил прежнее поведение. Как находить скрытые изменения API и безопасно обновлять Java‑проекты, разбираем на курсе «Автоматизированная модернизация Java и Spring Boot проектов».

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

  • 16 ноября в 20:00. «Мигрировали на Java 25 и Spring Boot 4 — всё зелёное. Почему система всё равно сломана?». Записаться

Только сегодня, 11 ноября, в OTUS действует скидка 20% на курсы и подписку. Подробности — в каталоге.