The app nobody had touched in a while (for good reason)
Confession first: I'm rusty in Java. Years of frontend work will do that to you.
And yet there I was, staring at a small legacy Spring Boot 2.x service still on Java 8. Untouched dependencies, patchy tests, and every "small change" came with a knot in my stomach. The target: Java 21, Spring Framework 6 and Spring Boot 3.3.5.
I did it with Claude Code. This isn't a Java upgrade guide. It's how I let AI do the heavy lifting while keeping control firmly in my hands.
I asked for best practice. I got the textbook.
I asked Claude how to do this properly. The answer was very correct and very by the book: hop through Java 11 and 17, run OpenRewrite recipes, plus five specialist reviewer agents.
All sensible. All a lot. Hopping through 11 and 17 is wasted effort when you're landing on 21 anyway. Five reviewers for one small app means more time managing agents than migrating code.
Claude did hold one line, and it was right: split the work by area of change, not by Java version. The real risk is Spring Boot 3 landing jakarta, Security 6 and Hibernate 6 all at once. So here's what I did instead. Less by-the-book, more by-the-human.
Two agents, one rule
- The Migrator is my main Claude Code session (Opus). It plans, writes the code and fixes what gets flagged.
- The Verifier is a read-only subagent (a second Opus, or Fable if you have it). It reviews and never touches code.
The rule: the agent that writes the code never grades it. A subagent starts with a fresh context, so it doesn't inherit the Migrator's reasoning or its excuses. It's the colleague who wasn't in the room when you wrote that "temporary" hack.
The plan is a checklist, not a prompt
I never typed "upgrade this to Java 21." The Migrator inventoried the codebase in plan mode and proposed a checklist. Once I approved it, it went into MIGRATION.md.
Problem: I'm rusty, remember? I couldn't fully judge the plan. Is WebSecurityConfigurerAdapter really gone, or did Claude just not like it? So I opened a fresh Claude session (and yes, asked Gemini too) and grilled them on Spring Boot 3 breaking changes. AI fact-checking AI, for a language I used to know. It works, because a fresh session has no stake in the plan being right.
The order:
- Build file: Java 21, Spring Boot 3.3.5 BOM, Lombok (old Lombok breaks every file on JDK 21)
- Other dependency bumps
- javax to jakarta
- Springfox to springdoc
- Spring Security:
WebSecurityConfigurerAdaptertoSecurityFilterChain - Hibernate 6
- Web layer (trailing-slash matching) and renamed properties
Each item gets a scope, acceptance criteria and a "done when":
### Item 3: javax to jakarta
Scope: src/** (Java, properties, XML)
Acceptance:
- No javax.* imports, except JDK packages (javax.crypto, javax.sql, ...)
- javax.persistence property keys migrated too
- No logic changes
Done when: no compile errors or removal warnings in scope,
except errors owned by later items (springdoc: 4, Security: 5,
Hibernate types: 6, web: 7)
Status: [ ]
And four marks, because "done", "verified" and "approved" are different claims:
-
[ ]to do, or sent back -
[x]Migrator says it's done -
[v]Verifier passed it, waiting for me -
[a]I approved it
The loop
Before item 1, I tagged the Java 8 code pre-migration and saved some real API requests and responses. Then every item went through this:
- The Migrator gets the item's scope clean and marks it
[x]. - The Verifier checks the new changes and re-checks every earlier item.
- FAIL: back to
[ ], fix only the findings. Three FAILs on the same item and it stops and asks me. - PASS:
[v], and the Migrator waits for me. - I read the changes and the Verifier's notes and say go. The item becomes
[a]and the next one starts.
If a re-check finds an earlier item broke, that item goes back to [ ] and needs my go again.
Before calling it done: one final Verifier run over everything since pre-migration, a green build with all tests passing, a replay of the saved API calls, and protected endpoints hit with and without auth. Only then did anything go into git.
A red build is fine. A dirty scope isn't.
In my run, the build was red for most of the migration. Spring Boot 3 changes so much at once that nothing compiles until everything is done. On my small app, I judged only the end result. Don't copy that bit. On a big monolith it's walking blind through a forest: hundreds of errors, and no idea which ones the last change caused.
The rule I'd teach instead: the build may be red, but an item isn't done while its files have compile errors or removal warnings that it, or an earlier item, owns.
- Every error has one owner. During the jakarta item, entity files can still fail on Hibernate 6 types. Those belong to item 6, so the Verifier names the owner instead of failing item 3. A missing class breaks every file that uses it, and the owner is whoever owns the root error. An error with no owner is a gap in the plan: add an item.
- Scope the warnings. Only removal warnings, only in touched files. Otherwise the migration turns into a refactor.
- Make the compiler show everything. javac stops after 100 errors, so your scope may never even show up. Raise the limit:
<compilerArgs>
<arg>-Xmaxerrs</arg><arg>10000</arg>
<arg>-Xmaxwarns</arg><arg>10000</arg>
</compilerArgs>
On a multi-module project, run the checklist once per module, in dependency order. Within a module the owned-errors rule still applies. A module is finished only when mvn -pl <module> -am compile is fully clean, because everything downstream compiles against it.
The catch that made it worth it
One external service needed a DELETE with a body. HttpClient 4's HttpDelete can't carry one, so years ago someone wrote a workaround: a custom RestTemplate request factory that swapped in an HttpEntityEnclosingDeleteRequest for every DELETE.
Spring Framework 6 dropped HttpClient 4, so the whole client setup moved to HttpClient 5: TLS and pool limits go to a connection manager, and timeouts become Timeout values. The easy path was to port the workaround too. The Verifier pointed out that in HttpClient 5, HttpDelete takes a body natively. The override could just go.
The funny part? Spring's own request factory has handled DELETE bodies since 4.1, so that override was probably dead weight for years. The compiler would have told me the old classes were gone. It would never have told me the override wasn't needed.
Steal the setup
The Migrator's rules, in CLAUDE.md:
Target: Java 21, Spring Boot 3.3.5 (Spring Framework 6)
Build: ./mvnw clean verify
Plan: MIGRATION.md
- Work one checklist item at a time
- Never delete, skip or weaken a test
- An item is done only when its scope compiles clean
(every remaining error or removal warning owned by a later item)
- Done: mark it [x] and call migration-verifier
- FAIL: set it back to [ ] and fix only the findings
- 3 FAILs on the same item: stop and ask me
- Regression in an earlier item: send that item back to [ ]
and fix it before continuing
- PASS: mark it [v] and WAIT for me
- When I say go: mark it [a], commit on the migration branch
(a red build is fine), and start the next item
The Verifier, in .claude/agents/migration-verifier.md:
---
name: migration-verifier
description: Verifies every checked item in MIGRATION.md.
tools: Read, Grep, Glob, Bash
model: opus # or fable
---
Review like a skeptical senior engineer. Assume the change is wrong
and look for evidence. Do not modify source files or git state
(build output in target/ is fine).
- The item just marked [x]: review its changes since the last commit (git diff HEAD),
including new untracked files. Check scope, acceptance criteria,
test integrity and behaviour changes.
- Compile so every error is reported. The overall build may be red,
but this item's scope must be clean: list each compile error or
removal warning in its files with file:line and the later item
that owns it. Unowned, or owned by this or an earlier item: FAIL.
- Items marked [v] or [a]: re-check their acceptance criteria
against the current code. Anything broken is a regression.
Always flag tests that were deleted, @Disabled or weakened.
Every finding needs file:line and evidence.
End with VERDICT: PASS or FAIL.
Honest note: I only committed once, at the very end. The per-item commit in CLAUDE.md is the fix I'd make next time. It's also what lets the Verifier see just the newest item instead of everything at once.
To reuse it everywhere, move the workflow rules into a skill at ~/.claude/skills/verified-upgrade/SKILL.md, keep the Verifier in ~/.claude/agents/, and start any upgrade with /verified-upgrade. The Verifier stays a subagent because it needs its own fresh context. Commit both into a repo's .claude/ folder to share them with your team. Nothing here is Java-specific, and as a frontend person, that's the part I'm most excited about.
Lessons the hard way
- Tell the reviewer what to review. My first Verifier checked everything on every item and failed good work for being early. Per-item scope fixed that.
- Re-check every approved item. It's free regression testing. Write criteria as lasting rules ("no test deleted since the baseline").
-
git diffmisses new files. Include untracked files fromgit status. - Commit after each approved item. I didn't, and got lucky. It's a free undo button, and it keeps each review focused on one item.
-
Go through Boot 2.7 first if you can. Spring's migration guide recommends it, and
SecurityFilterChainalready works there, so the build stays green longer. - Read the Verifier's own output, not the Migrator's summary of it. The Migrator writes its brief.
-
"Read-only" needs enforcing. The Verifier has Bash. Block
src/edits and git-changing commands with a PreToolUse hook in the Verifier's own frontmatter, and let it write build output totarget/. -
Two AIs can share blind spots. The boring checks (green build, API replay, a
javaxgrep) still matter. On a big codebase, OpenRewrite is the better tool for the mechanical jakarta pass. - Your "go" is only as good as your attention. Skim the imports. Read Security, Hibernate and anything that talks to the outside world.
AI did the work. I kept the keys.
And yes, Spring Boot 4 is out, and 3.3 is already past its open-source support window. Getting from 2 to 3 was the big jump. Staying current from here (the latest 3.5 as a stepping stone, then 4) is the easy part, and that's exactly what the skill is for.
Is this the best way? Honestly, I don't know. It's my way, and it worked. If you've done it better or can spot a hole, tell me. I'd love to steal your ideas.
The agent that writes the code never grades it. And neither of them moves on without me.

Top comments (0)