The task an agent was made for
Large migrations are the worst kind of Java work: too repetitive to be interesting, too context-dependent to fully script, and too sprawling to finish in an afternoon. A javax.* → jakarta.* migration touches every entity, every servlet filter, every validation annotation. A Java 8 → 21 upgrade ripples through build files, deprecated APIs, and a thousand small idiom changes. This is where an agent that can edit broadly and verify continuously changes the economics.
The trick in a big migration is breaking it into verifiable steps and never letting the codebase drift far from compiling.
Strategy: stage the migration, verify each stage
Don’t ask for the whole upgrade in one prompt. Ask for a staged plan first (this is plan mode’s moment), then execute one stage at a time, building between each.
# In plan mode (Shift+Tab):
> Plan our migration from Spring Boot 2.7 to 3.2. Spring Boot 3 needs
Java 17 as a hard minimum, so if we're below that, raising the JDK is
stage zero — nothing else compiles until it moves. Then break the rest
into stages that each leave the project compiling: the javax→jakarta
namespace move; the Spring Security rewrite (WebSecurityConfigurerAdapter
is *removed* in Security 6, so we move to the SecurityFilterChain bean
style); the spring.factories → AutoConfiguration.imports change; the
Hibernate 5→6 dialect and mapping updates; and the remaining
deprecated-API and property changes. For each stage, note the risk and
how we'll verify it.
Once you approve the plan, drive it stage by stage:
> Execute stage 1 only: move javax.persistence and javax.validation
to jakarta across the whole codebase, update the imports, and adjust
the build dependencies. Then run `./gradlew compileJava` and fix
whatever doesn't compile. Don't start stage 2.
Stopping at “don’t start stage 2” keeps you in control of a long process and gives you a clean checkpoint to commit. A green compile (or a green test run) between every stage means a failure is always traceable to the stage that introduced it.
A migration that compiles after every stage is a migration you can stop, review, and resume. One giant uncompilable diff is a migration you can only pray over.
Strategy: pin behaviour before you move it
For upgrades that can subtly change runtime behaviour — a Jackson major version, a date/time library, a Hibernate dialect — lock the current behaviour down first with characterization tests (introduced in Module 4 — tests that pin what the code does today so you’d notice if it changed).
> Before we upgrade Jackson, write characterization tests that capture
how our key DTOs serialize today: null handling, date formats, and
the @JsonInclude settings. We'll run them after the upgrade to catch
any silent change.
The compiler catches API breaks; these tests catch behaviour breaks the compiler can’t see. They’re the difference between “it builds” and “it still does the same thing.”
Strategy: fan out with subagents
A subagent is a separate Claude instance the main session launches with a focused sub-task. It has its own fresh context, does the work, and reports a result back; subagents can run in parallel or one after another. For migrations they help in two ways: parallelism, and context isolation — each subagent reads only its slice, so none of them drown in the whole repo at once. (Module 7 covers them in full, including how to define specialized ones.)
> This monorepo has 30 modules that each need the same JUnit 4 → 5
migration. Spawn a subagent per module to do the conversion: imports
to org.junit.jupiter; @Before/@After → @BeforeEach/@AfterEach;
@BeforeClass/@AfterClass → @BeforeAll/@AfterAll; @Ignore → @Disabled;
@RunWith(SpringRunner) → @ExtendWith(SpringExtension) or @SpringBootTest;
@RunWith(MockitoJUnitRunner) → @ExtendWith(MockitoExtension); and
@Test(expected=…)/@Test(timeout=…) → assertThrows/assertTimeout. Have
each subagent run its module's tests and report which are green and
which need me.
Two items in that list are not mechanical, and they’re where your review attention goes. JUnit 4’s @Rule/@ClassRule have no annotation equivalent — Testcontainers rules, TemporaryFolder, and the like must be restructured, not renamed — and org.junit.Assert → org.junit.jupiter.api.Assertions reverses the position of the optional message argument, a silent source of wrong assertions. Tell the subagents to flag anything using @Rule rather than guess. The full mapping table lives in the Quick Reference. The orchestrating Claude coordinates; you review a summary instead of babysitting thirty near-identical edits.
Tip: Subagents are powerful but not free — each one spins up its own context and burns tokens. Reach for them when the work is genuinely large and parallel (per-module, per-file sweeps), not for a three-file change a single session handles fine.
Strategy: commit in reviewable slices
A 4,000-line migration diff is unreviewable, and an unreviewed migration is a future incident. Have Claude shape the work into commits a human can actually read.
> We've finished the jakarta namespace stage and it compiles. Stage
these changes and write a commit message that explains what moved
and why. Keep this commit to the namespace change only — nothing
from the security rewrite.
One coherent change per commit means your reviewer can reason about each step, git bisect still works if something breaks later, and you can revert a single stage without unwinding the whole upgrade.
Strategy: let the build be the spec
Modernizations have a natural verifier built in — the compiler and the test suite — so lean on them hard:
> Migrate this package off the deprecated Date/Calendar APIs to
java.time. After each class, run `./gradlew test`. If a test fails,
fix it before moving to the next class. Treat any new compiler
warning about deprecation as unfinished work.
“Fix it before moving to the next class” turns a risky sweep into a series of small, verified steps. Treating deprecation warnings as unfinished work gives the agent a clear, machine-checkable definition of done.
A realistic caution
Big migrations are also where over-delegation bites hardest. Two rules keep you safe:
- Never merge a migration you haven’t reviewed in slices. The fact that it compiles and tests pass is necessary, not sufficient — semantic regressions hide behind green builds, and your characterization tests only cover what you thought to pin.
- Keep a human in the loop on anything irreversible — a database migration that drops a column, a published-artifact version bump, a change to a contract another team consumes. The agent can prepare it; you decide to pull the trigger.
What just happened
You have a playbook for the migrations that used to eat weeks: stage and verify, pin behaviour with characterization tests, fan out with subagents for parallel sweeps, commit in reviewable slices, and lean on the build as the spec — while keeping a firm hand on anything irreversible. Next, you’ll extend Claude Code itself to fit your stack.
Key takeaways
- Stage large migrations so the project compiles (or tests green) after every step; never create one giant uncompilable diff.
- Pin behaviour with characterization tests before upgrades that can silently change runtime output.
- Use subagents for large, parallel, per-module sweeps — for context isolation as much as speed — but not for small changes.
- Commit in coherent, reviewable slices so review,
bisect, and revert all still work. - The compiler and tests are your spec; keep a human on anything irreversible.