--- name: legacy-migration description: "Migrate a Spring Boot 2.x or 3.x app to Boot 4.x (Maven or Gradle) — audit first, then OpenRewrite-led version hops with a green build per hop, covering Jakarta, Jackson 3, modular starters, and runtime-only breakages. Use when asked to upgrade or migrate Spring Boot, or fix javax imports after an upgrade. Not for new projects." --- # Legacy Migration Skill Takes a Spring Boot 2.x/3.x app to Boot 4.x without losing the plot: **assess → automate the mechanical 80% → hand-sweep the semantic 20% → prove every hop works before the next one.** `SKILL_DIR` = the directory containing this SKILL.md. **Load `SKILL_DIR/references/migration-reference.md` before writing anything** — the per-hop recipe map, the javax→jakarta table, property renames, starter/API swaps, and the symptom → cause → fix table for the changes that compile fine and break at runtime. Two rules everything else hangs on: 1. **Never squash hops.** Each major version is its own migration with its own green build and its own commit. A 2.x → 4.x jump in one step makes every failure unattributable — you will not know whether the Jakarta jump, the Security 6 rewrite, or Jackson 3 broke the thing that broke. 2. **The audit comes before any edit.** You cannot size a migration you are guessing at, and a single third-party starter that hasn't shipped Boot 4 support blocks the whole last hop — that is something to learn from a grep in Step 1, not from a failed build in Step 5. --- ## The path | Hop | From → To | Java floor | What breaks here | |---|---|---|---| | A | 2.x → **2.7.18** | 8/11 (unchanged) | Deprecations surface; circular references refused at startup since 2.6; PathPatternParser is the default matcher since 2.6 | | B | 2.7.18 → **3.5.16** | **17** | Jakarta namespace (`javax.*` → `jakarta.*`), `WebSecurityConfigurerAdapter` gone, Hibernate 6, trailing-slash matching off by default | | C | 3.5.16 → **4.0.x** | 17 (unchanged) | Jackson 3, modularized starters, `@MockBean`/`@SpyBean` removed, Testcontainers 2.x, Undertow removed | | D | 4.0.x → **4.1.1** | 17 (unchanged) | Almost nothing — property deprecations, defaults tightened. A version bump with a checklist | A Boot 2.x app walks all four. A Boot 3.x app starts at hop C (or B's tail if it isn't on 3.5 yet). A Boot 4.0 app only needs hop D. State which entry point applies before doing anything. Verify the current versions before pinning — these are the pins as of writing: ```bash # latest Boot GA per line (Initializr lists in-flight versions too) curl -s -H 'Accept: application/json' https://start.spring.io/metadata/client \ | python3 -c "import json,sys; print([v['id'] for v in json.load(sys.stdin)['bootVersion']['values']])" # latest 3.5.x and 2.7.x on Maven Central curl -s "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-parent/maven-metadata.xml" \ | grep -oE '(2\.7|3\.5)\.[0-9]+' | tail -5 ``` **Java floor per hop:** Boot 3 requires Java 17 — the JDK upgrade happens at hop B, not before and not after. Boot 4 keeps the floor at 17 (25 supported). Upgrading the JDK *and* the framework in the same commit as anything else is how diffs become unreviewable; the OpenRewrite recipe bumps the compiler level as part of hop B, let it. --- ## Step 0 — Branch and git discipline ```bash git checkout -b migration/boot-4 ``` One commit per hop, message naming the hop (`migrate: Boot 2.7.18 → 3.5.16 (Jakarta)`). Never mix migration with feature work — if the user asks for both, the migration finishes first. If the work tree is dirty, stop and say so. --- ## Step 1 — Audit (before touching anything) Run all of these. Every hit goes on the hit list with a category; the hit list is what sizes the work and decides whether hop C is even possible today. ```bash # current versions grep -m1 -A2 'spring-boot-starter-parent\|spring-boot-dependencies' pom.xml grep -m1 'java.version\|maven.compiler' pom.xml # 1. javax exposure (hop B surface) — imports AND strings grep -rn --include=*.java 'import javax\.' src/main src/test | cut -d: -f1 | sort -u | wc -l grep -rn 'javax\.' src/main/resources src/main/webapp 2>/dev/null | head -20 # 2. APIs removed in Boot 3 / 4 grep -rn --include=*.java -e 'WebSecurityConfigurerAdapter' -e 'WebMvcConfigurerAdapter' \ -e '@MockBean' -e '@SpyBean' -e 'springfox' src pom.xml | head -20 # 3. config shape and property risk ls src/main/resources/application.* src/main/resources/bootstrap.* 2>/dev/null # 4. Spring Cloud (must move to 2025.x at hop C; BOM must be upgraded in lockstep) grep -n 'spring-cloud' pom.xml | head -10 # 5. custom auto-config and deep Boot integration find src/main/resources -name 'spring.factories' -o -name '*.imports' | head grep -rln --include=*.java -e 'EnvironmentPostProcessor' -e 'spring.autoconfigure.EnableAutoConfiguration' src/main | head # 6. third-party starters — the blockers list grep -B1 -A2 '.*-spring-boot-starter\|spring-boot-starter-.*' pom.xml \ | grep -v 'org.springframework.boot' | head -30 # 7. test style grep -rln --include=*.java -e 'org.junit.Test' -e '@RunWith' src/test | wc -l # JUnit 4 grep -rn --include=*.java 'org.testcontainers.containers\.' src/test | head -10 # TC 1.x imports grep -rn --include=*.java -e ':latest' src/test | head # unpinned images # 8. server + features with known Boot 4 removals grep -n -e 'undertow' -e 'spring-session' -e 'spring-retry' -e 'launch.script' pom.xml ``` Report the audit as a short categorized list: *javax hits, removed-API hits, property risk, third-party starters (with their Boot-4 status), test style, removals triggered*. **Check every third-party starter against Boot 4 support now** — one unmaintained starter is a blocker to name out loud, not to discover later. --- ## Step 2 — The OpenRewrite stance OpenRewrite does the mechanical 80%: dependency versions, property renames, import swaps, removed API replacements, starter renames. It does **not** see strings, XML, reflection, SpEL, bean names, or behavior — that 20% is the hand-sweep in Step 6. The Boot 4 recipes exist, are GA-grade, and ship in the community `rewrite-spring` artifact. The composites chain across recipe artifacts, so the plugin classpath needs all four: | Artifact | Pin (verify below) | Provides | |---|---|---| | `org.openrewrite.recipe:rewrite-spring` | 6.37.1 | All `UpgradeSpringBoot_*` recipes, Jakarta chaining, modular-starter migration | | `org.openrewrite.recipe:rewrite-migrate-java` | 3.42.1 | `UpgradeToJava17`, `JakartaEE10` — chained by the 3.0 recipe; run fails without it | | `org.openrewrite.recipe:rewrite-hibernate` | 2.25.0 | `MigrateToHibernate61` / `71` — chained by the 3.0 / 4.0 recipes | | `org.openrewrite.recipe:rewrite-testing-frameworks` | 3.44.0 | `Testcontainers2Migration` — chained by the 4.0 recipe | Missing artifacts fail with "recipe not found" — that error means the classpath, not the recipe name. ```bash # verify the pins (Maven Central metadata) for a in rewrite-spring rewrite-migrate-java rewrite-hibernate rewrite-testing-frameworks; do printf '%s: ' "$a" curl -s "https://repo1.maven.org/maven2/org/openrewrite/recipe/$a/maven-metadata.xml" \ | grep -o '[^<]*' | sed 's///' done curl -s "https://repo1.maven.org/maven2/org/openrewrite/maven/rewrite-maven-plugin/maven-metadata.xml" \ | grep -o '[^<]*' | sed 's///' ``` **Newer releases moved off Maven Central** into Moderne's authenticated Code Genome Project repository. The Central pins above still resolve anonymously and contain every recipe this skill uses — pin them; do not chase `RELEASE` into the gated repo. No pom change is needed — the plugin runs from the command line, always `dryRun` before `run`: ```bash ./mvnw -U org.openrewrite.maven:rewrite-maven-plugin:6.46.1:dryRun \ -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:6.37.1,org.openrewrite.recipe:rewrite-migrate-java:3.42.1,org.openrewrite.recipe:rewrite-hibernate:2.25.0,org.openrewrite.recipe:rewrite-testing-frameworks:3.44.0 \ -Drewrite.activeRecipes= # read target/rewrite/rewrite.patch, then re-run with :run ``` Gradle delta: apply the `org.openrewrite.rewrite` plugin with the same four artifacts in the `rewrite` configuration, `activeRecipe('')`, then `gradle rewriteDryRun` / `rewriteRun`. Everything else in this skill is build-tool-neutral. **Review the dry-run patch like production code.** A recipe that deletes something it can't prove unused is the known failure mode; revert individual hunks with `git checkout -p` after `run`. --- ## Step 3 — Hop A: 2.x → 2.7.18 Recipe: `org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7` (chains the whole 2.0→2.7 line). The bump itself is small; the point is reaching the 2.7 baseline the 3.0 migration guide assumes, with every deprecation warning visible. Two defaults already flipped back at 2.6 and bite now if the app predates them — circular bean references are refused at startup, and PathPatternParser is the default path matcher. Both are in the reference's symptom table; fix the cause, don't re-enable the old behavior as a permanent answer. Run the verification ladder (Step 7), commit. --- ## Step 4 — Hop B: 2.7.18 → 3.5.16 (the Jakarta jump) Recipe: `org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0` — this one chains the Java 17 bump, the full `javax.*` → `jakarta.*` migration (via `JakartaEE10`), Hibernate 6.1, the Spring Framework 6 and Security 6 API migrations, and the Boot 3.0 property renames. Then bump the parent to 3.5.16 by hand and resolve whatever deprecations the build surfaces. **OpenRewrite rewrites imports. It does not see:** - `persistence.xml`, `web.xml`, `orm.xml` — `javax.*` in schema locations and class names - strings: `@Qualifier("javax.persistence...")`-style names, reflection (`Class.forName`), SpEL, `getProperty("javax...")` - Logback/Log4j2 config, `spring.factories`, Spring XML config - Hibernate dialect and `hibernate.*` properties in `application.yml` The javax→jakarta table — including what does **not** move (`javax.sql`, `javax.net`, `javax.naming`, `java.*` — over-migrating these breaks against the JDK) — is in the reference, along with the exact greps for the string sweep. **Security config:** `WebSecurityConfigurerAdapter` is gone in Security 6. The recipe converts simple cases; anything with custom filters, multiple chains, or `authorizeRequests` nuance is handwork. Target state is `SecurityFilterChain` beans — follow the **`spring-security`** skill for the house conventions rather than inventing them here. **Properties:** add `spring-boot-properties-migrator` (runtime scope), boot the app, read the warnings, rename the keys, then **remove the migrator again**. It is a diagnostic, not a permanent dependency — a migrator left in the pom silently papers over every stale key forever. The common renames are tabled in the reference. Run the verification ladder, commit. --- ## Step 5 — Hop C: 3.5.16 → 4.0.x Recipe: `org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0` — chains Boot 3.5, Spring Cloud 2025.1, Framework 7, Security 7, Batch 5→6, the Boot 4.0 property renames, `@MockBean`→`@MockitoBean`, Hibernate 7.1, Testcontainers 2.x, SpringDoc 3, and the modular-starter migration. There is **no** `UpgradeSpringBoot_4_1` recipe — hop D is a version bump plus the properties recipe below. If the recipe's starter reorganization is too much to review in one diff, the bridge is deliberate: swap to `spring-boot-starter-classic` + `spring-boot-starter-test-classic` (the Boot-3-style all-in classpath), get green, then decompose starter by starter using the compile errors as the map. Land on the modular starters — classic is scaffolding, not a destination. The target layout is the one **`spring-scaffold`** generates; use it as the reference for what "done" looks like. Hand-sweep after the recipe, in this order: 1. **Jackson 3.** `com.fasterxml.jackson` → `tools.jackson` wherever application code surfaces Jackson — except `jackson-annotations`, which stays `com.fasterxml.jackson.annotation`. Custom serializers, mixins (`@JsonComponent` → `@JacksonComponent`), `ObjectMapper` beans (now `JsonMapper`), `Jackson2ObjectMapperBuilderCustomizer` (now `JsonMapperBuilderCustomizer`), and `spring.jackson.*` property moves are all tabled in the reference. The stop-gaps (`spring.jackson.use-jackson2-defaults=true`, the deprecated `spring-boot-jackson2` module) are for buying time, not for finishing — flag any use in the report. 2. **Removed features.** Undertow (gone — Servlet 6.1 floor), Spring Session Hazelcast/MongoDB, reactive Pulsar, uber-jar launch scripts, Spock integration, Spring Retry dependency management (explicit version now required), classic uber-jar loader config. Step 1's audit already named which apply. 3. **Test API.** `@MockBean`/`@SpyBean` are *removed*, not deprecated — the recipe rewrites field usage, but mocks declared on `@Configuration` classes need the `@MockitoBean(types=...)` class-level form. `@SpringBootTest` no longer provides MockMvc, `WebClient`, or `TestRestTemplate` without their `@AutoConfigure*` annotations. **`spring-testing`** is the authority for the target API — align with it, batch all test edits into one commit, run the suite once. 4. **Behavior changes that compile green.** Jackson now registers *all* classpath modules, liveness/readiness probes are on by default, DevTools live reload is off by default, optional dependencies no longer land in the uber jar. Each is a runtime check in the reference's symptom table — a green build here proves nothing. Run the verification ladder, commit. --- ## Step 6 — Hop D and the manual sweep **Hop D:** bump the parent to 4.1.1, run recipe `org.openrewrite.java.spring.boot4.SpringBootProperties_4_1` for the 4.1 property renames (the pre-4.1 flat OTLP keys `management.otlp.tracing.*` / `logging.*` are among them — the 4.1 spellings are in the **`otel-setup`** reference), rebuild, run the ladder, commit. **The sweep** — categories the recipes structurally cannot cover. Each has its greps in the reference: - strings/XML/reflection remnants of `javax.` and `com.fasterxml.jackson` - property keys the migrator warned about, cross-checked against the rename table - third-party starter behavior (read their migration notes — Boot 4 forced every one of them into a major) - custom auto-config: `spring.factories` → `AutoConfiguration.imports` (hop B), moved `EnvironmentPostProcessor`/`BootstrapRegistry` packages (hop C) - actuator endpoints the app or its dashboards actually call — sanitization rules and probe defaults changed across both majors --- ## Step 7 — The verification ladder (after EVERY hop) A migration step is done when the ladder passes, not when the build compiles: 1. `./mvnw clean verify` — green, and read the warnings; deprecations left behind here are next hop's breakage 2. App boots: `./mvnw spring-boot:run` (or `test-run` where the project has it) — clean startup, no bean-override or circular-reference backdoors enabled to force it 3. One real web request — `curl` a critical endpoint, expect the exact old response shape 4. One real DB query — an endpoint that hits the database, not a cached or mocked path 5. `curl localhost:8080/actuator/health` — `UP`, and on Boot 4 the liveness/readiness groups are exposed by default; confirm nothing downstream chokes on the new payload 6. The runtime checks from the reference's symptom table that apply to this hop — trailing-slash routes, date serialization formats, `@Configuration` mocks Squashing hops skips ladders 2–6 on the intermediate versions — which is exactly where the "green build, broken app" class of failure hides. --- ## Step 8 — Report Lead with the verdict, then the board: ``` Boot 2.7.18 → 4.1.1 · 4 hops · suite green · runtime checks passed ━━━ LEGACY-MIGRATION ━━━━━━━━━━━━━━━━━━━━━━━ Audit ................. 41 javax files · 2 third-party starters (1 blocked: foo-bar-starter) Hop A 2.7.18 .......... ✅ recipe + 3 manual fixes · ladder passed Hop B 3.5.16 .......... ✅ Jakarta: 41 imports by recipe, 6 string hits by hand Hop C 4.0.8 ........... ✅ Jackson 3 swept · starters decomposed (no classic left) Hop D 4.1.1 ........... ✅ version bump + SpringBootProperties_4_1 OpenRewrite ........... 212 files changed across 3 recipe runs Manual sweeps ......... 23 files (strings, XML, SecurityFilterChain, @Configuration mocks) Properties migrator ... ✅ used at hop B, removed after Test suite ............ ✅ 187 passing · 0 skipped Remaining ............. ⚠ foo-bar-starter pinned to 3.x API — owner notified ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Next: re-enable the ci deploy stage on the migration branch and smoke the staging environment. ``` Mark a hop ⚠ with the reason if its ladder only partially passed — "verify green, staging untested" is a fair ⚠; a green board nobody ran is not. `Next:` names the single most concrete remaining action — typically deploying the migration branch somewhere real, or running **`spring-testing`** to modernize the suite the migration just rewrote.