1. Слово «миграция» слишком общее
Когда вы впервые слышите слово migration, очень легко представить что-то однотипное: подняли версии, поправили пару импортов, прогнали тесты, готово. Проблема в том, что под одним словом прячутся изменения разной природы: одни ломают компиляцию, другие — поведение в рантайме, третьи — только сборку в CI. Поэтому первое взрослое действие в migration execution — назвать тип миграции, а не объявить: «мы обновляем проект».
До этого места у нас был один pilot: migration/boot3-pilot на reports/* с выбранным scope, rollback и validation. Сейчас я не расширяю его до всего сервиса, а раскладываю по типам изменений: где риск и какие датчики на каждом слое. Данные и конфигурацию сюда сознательно не включаю — там другой риск и другой паттерн безопасности.
На практике полезно держать перед глазами вот такую карту:
| Семейство миграции | Что реально меняется | Где чаще всего ломается | Первая полезная проверка |
|---|---|---|---|
| single-library | одна конкретная зависимость, которую вы обновляете явно | локальная сборка, интеграционные тесты | точечный diff версии + прогон целевого набора тестов |
| dependency | дерево зависимостей целиком, включая транзитивные | конфликты версий, несовместимые плагины, неожиданные классы в classpath | сравнение lockfile или графа зависимостей |
| framework | правила платформы, API, автоконфигурация, соглашения | компиляция, конфигурация, безопасность, поведение в рантайме | чтение release notes + characterization tests |
| runtime | версия языка и среды исполнения | CI, контейнер, рефлексия, TLS, поведение JVM | matrix-прогон на старой и новой версии |
| build | wrapper, build plugins, pipeline, образ сборки | «локально работает, в CI падает» | clean build в CI на целевой среде |
Эта таблица кажется почти скучной — а это хороший знак: инженерная карта и должна выглядеть чуть скучнее, чем катастрофа. Скучная таблица дешевле бодрого созвона в 23:40 с вопросом «а почему после обновления Java у нас отвалился отчёт, хотя код мы не трогали?».
Для CashFlow Dashboard это особенно полезно, потому что первый большой прыжок в курсе — не одна миграция, а гибрид: мы задеваем framework, runtime и build одновременно, а рядом шевелятся зависимости. Не разложите эту смесь на части — и любой MIGRATION_PLAN.md превратится в литературное произведение без шансов на исполнение.
2. Один цикл для всех типов миграций
Хорошая новость в том, что типов миграций много, а базовый цикл у них один и тот же: он применяется к разным классам изменений, каждый раз с уточнением, где риск и каким evidence доказывать корректность. Изобретать пять процессов не нужно.
flowchart LR
A[Changelog / release notes] --> B[Pilot-срез]
B --> C[Tests и checks]
C --> D[Маленький diff]
D --> E[Review и решение]
Ключевое здесь — сначала читать release notes, а не сразу редактировать build.gradle с фразой «ну сейчас всё само подскажет». Claude Code в этой фазе полезен, но в очень конкретной роли: читать документацию, сопоставлять breaking changes с файлами pilot-среза, подсказывать, где поможет компилятор, а где спасут тесты или smoke checks. Хороший запрос выглядит так:
Изучи release notes Spring Boot 3.x и наш pilot-срез `reports/*`.
Код не меняй.
Верни:
1) какие breaking changes относятся к этому срезу;
2) что поймает компилятор;
3) что поймают только тесты или runtime;
4) с какого файла безопаснее начать pilot.
Обратите внимание на тон запроса: не «мигрируй модуль», а «прочитай, сопоставь, объясни, где риск». Это verification-first: модель помогает увидеть карту, а не бросается перестраивать город.
3. Миграции single-library и dependency
Самый обманчивый тип миграции — тот, что выглядит маленьким. Кажется, вы просто меняете версию одной библиотеки, но на деле она редко приезжает одна: тащит транзитивные зависимости, ограничения совместимости, новый набор классов. Пакет обновили один, а разговаривать начали пятеро.
Для начинающего разработчика удобно разделить два близких понятия. Single-library migration — вы явно меняете одну зависимость: версию SDK, драйвера, клиента, starter-а (не путайте со сменой package или namespace). Dependency migration — когда из-за этого меняется всё дерево. В Java это заметно из-за BOM, плагинов и библиотек, связанных через classpath; в JavaScript аналог — peer dependencies.
Вот почему lockfile — не бюрократия, а улика: показывает, что реально поменялось, а не что вы думали, что поменяли.
./gradlew dependencies > build/deps-before.txt
./gradlew --write-locks
./gradlew clean test --tests 'reports.*'
git diff gradle.lockfile # смотрим, кто реально изменился
./gradlew dependencyInsight --dependency jackson-databind
Этот фрагмент кажется сухим, но он очень практичный, и это гораздо лучше, чем обновить пять пакетов разом и потом обсуждать, чья версия решила испортить вам утро.
В CashFlow Dashboard такой подход особенно важен рядом с финансовой логикой. Тронете библиотеку в payments или billing — цена ошибки высока, поэтому первый pilot-срез не начинается там. Берите reports или другой read-only участок: от сложных зон вы не отказываетесь навсегда, просто не делаете из них полигон для первых падений.
И ещё один важный момент — у single-library/dependency migration очень распространённый анти-паттерн: «раз уж залезли, обновим заодно всё устаревшее». Звучит хозяйственно, но разрушает доказуемость. Обновили восемнадцать зависимостей и два дня ищете, чей сюрприз пришёл в сборку, — это не миграция, а археология после взрыва.
4. Framework migration: меняются правила платформы
С framework migration всё хитрее: здесь вы меняете не версию зависимости, а правила игры всей платформы — соглашения, автоконфигурацию, жизненный цикл компонентов, интеграцию с безопасностью и валидацией. Поэтому major-upgrade почти всегда больнее, чем выглядит в diff.
В нашем проекте это видно наглядно: Spring Boot 2.7 → Boot 3.x — классическая framework migration. В ней есть dependency- и runtime-слой, но главная проблема — изменились базовые контракты платформы. Самый заметный и мемный пример — переход javax.* → jakarta.*.
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.Valid;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.Valid;
Компилятор такое ловит охотно, и это даже приятно — часть работы делается шумно и честно. Но framework migration не заканчивается на импортах. Куда неприятнее то, что не падает при компиляции: правила Spring Security, actuator, наблюдаемость, автоконфигурация. Полезно смотреть на framework migration так — кто что ловит:
| Что меняется | Кто обычно ловит проблему |
|---|---|
| javax → jakarta в коде | компилятор |
| изменения в security-конфигурации | интеграционные тесты |
| actuator / observability / автоконфигурация | runtime и CI |
| поведение web-слоя и сериализации | API tests и smoke checks |
Именно поэтому release notes для framework migration не факультативны: их не читают только те, кто любит узнавать про breaking changes из production-логов. Если у вас есть characterization tests из предыдущих тем, драматизма меньше: вы не верите, что «Boot 3 тоже должен работать», а сравниваете поведение pilot-среза до и после. Это превращает миграцию из ритуала в инженерную процедуру.
В CashFlow Dashboard первый framework-pilot логично начинать не с billing, payments или auth, а со спокойного отчётного контроллера, где цена ошибки ниже.
5. Runtime migration: тот же код, новая платформа
Runtime migration часто недооценивают, потому что кажется: раз код компилируется на новой версии языка — вроде всё хорошо. К сожалению, среда исполнения гораздо коварнее. Вы меняете поведение виртуальной машины, базовые библиотеки, TLS, рефлексию, контейнерный образ, иногда даже то, как запускаются тесты.
В курсовом проекте Java 8 → Java 21 — не приложение к framework migration, а полноценная runtime migration. Boot 3 требует более новую Java, поэтому линии идут вместе, но разделять их в голове полезно. Иначе любая проблема выглядит как «Boot 3 что-то сломал», хотя на деле CI просто собирает проект на старом JDK.
Самый дешёвый способ не спорить о среде исполнения — гонять старый и новый runtime на одном наборе проверок. Если код поддерживает обе версии, удобен matrix в CI; но для pilot-а Boot 2.7 / Java 8 → Boot 3.x / Java 21 корректнее сравнивать baseline branch на JDK 8 и migration branch на JDK 21 — Boot 3 не обязан запускаться на Java 8.
# Такой matrix-подход подходит, когда один и тот же код поддерживает обе JDK.
strategy:
matrix:
java: [ '8', '21' ]
steps:
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
- run: ./gradlew test --tests 'reports.*'
Смысл этого фрагмента не в красоте YAML, а в дисциплине: воспроизводимые проверки на двух рантаймах покажут, где поведение расходится. Если baseline стабилен, а migration branch сыпется — копать в runtime-слое, а не обвинять вслепую весь framework upgrade.
У runtime migration есть ещё одна неприятная особенность: часть проблем не видна локально, когда среда разработчика отличается от CI. Отсюда правило: не «на ноутбуке зелёное», а «в пайплайне зелёное». Только это evidence — остальное оптимизм.
6. Build migration: ломается способ сборки
Build migration — тот тип изменений, который чаще всего раздражает сильнее всех, потому что код может быть абсолютно нормальным, а проект всё равно не собирается. И самое обидное, что разработчик в таких случаях любит говорить: «Но я же бизнес-логику не менял». Именно в этом и проблема: ломается не предметная область, а механика доставки — wrapper, плагины, pipeline, образ сборки, иногда шаги генерации кода.
Когда вы меняете build-слой, вы фактически отвечаете на вопрос: «Соберём ли мы тем же способом проект завтра, в CI, на другой машине?». Если ответ зависит от фаз луны — build migration ещё не завершена.
Простейший build-pilot не должен быть героическим — его задача доказать воспроизводимость:
- name: Проверка окружения
run: |
java -version
./gradlew --version
- name: Полная сборка pilot-среза
run: ./gradlew clean build
Этот кусок хорош тем, что сразу показывает, какая Java и какой Gradle wrapper реально в пайплайне. Если локально вы на Java 21, а в CI старый рантайм — лучше увидеть это в первых строках лога, чем после двадцати минут падений на непонятных плагинах.
Здесь же прячется ещё одна тонкость: build-пайплайн и сгенерированный конфиг — тоже часть migration-картины. Обновили Boot и Java, а Docker-образ в CI тащит старую среду; или wrapper новый, а plugin для генерации кода несовместим. «Работает локально, падает в CI» — build migration, которую просто не назвали своим именем.
Если сказать совсем просто, build migration проверяет не «умеет ли код жить», а «умеет ли команда снова и снова получать один артефакт из чистого состояния». Для legacy это важнее рефакторинга: без воспроизводимой сборки вы и откат не сделаете.
7. Карта типов миграций для CashFlow Dashboard
Теперь соберём всё в одну картинку на нашем проекте — это полезно не только для понимания, но и как почти готовый фрагмент MIGRATION_PLAN.md. Когда вы называете тип миграции по слоям, план сразу становится внятнее: у каждого слоя появляется своя проверка, свой pilot и свои исключения.
| Слой в первом pilot CashFlow Dashboard | Что меняем в reports-срезе | Чем доказываем корректность | Что сознательно не трогаем |
|---|---|---|---|
| framework | Spring Boot 2.7 → 3.x, javax → jakarta, часть web/autoconfig | компиляция + интеграционные тесты отчётов + smoke на endpoint | billing, payments, auth |
| runtime | Java 8 → 21 для того же pilot-среза | CI matrix, один и тот же набор тестов на двух JDK | глобальные JVM-тонкости всей системы |
| build | wrapper, CI Java version, шаги сборки | clean build в CI и повторяемый лог окружения | release pipeline целиком |
| dependency | сопутствующие библиотеки, подтянутые переходом | lockfile diff + dependencyInsight + targeted tests | крупные обновления внешних SDK |
Данных и конфигурации здесь сознательно нет: как только вы добавляете schema changes, backup и owner approval начинают жить по другим правилам, а pilot перестаёт быть тем самым дешёвым Boot 3 срезом.
Небольшой фрагмент плана в таком стиле может выглядеть так:
## Карта pilot-среза
- framework: Spring Boot 2.7 → 3.x в `reports/*`
- runtime: Java 8 → 21 для того же среза
- build: обновить wrapper и CI JDK
- excluded: `billing/*`, `payments/*`, schema changes
Это уже звучит как инженерный документ, а не боевой клич. И в этом, собственно, весь смысл сегодняшней лекции. Перестаёте говорить «обновим проект» и начинаете «делаем framework + runtime + build pilot на reports/* с отдельным dependency review» — и миграция становится управляемой. Не лёгкой, не безошибочной — управляемой. В legacy-коде это почти всегда лучшая новость дня.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ