Menu
Module 6 / 8 Advanced 25 min read

Refactoring & Large Migrations

Where agents change the economics most — Java version upgrades, Spring Boot 3, dependency bumps, and codebase-wide change done in parallel.

In this module

Run large, repetitive migrations with Claude: javax→jakarta, Java 8→21, framework upgrades, and fanning work out to subagents.

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.Assertorg.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.