The Problem: A 50,000-Line Legacy Monolith
Mental Model
Claude Code is most powerful as a force multiplier on tasks that are large, well-defined, and mechanical — exactly what a legacy migration is. The human provides architectural judgment; Claude provides tireless execution.
At a 200-person SaaS company, the backend team faced a mandatory migration: their Java 8 Spring MVC monolith needed to move to Spring Boot 3.2 with Java 21 and Project Loom virtual threads. The estimated manual effort was 6–8 weeks for two senior engineers.
With Claude Code, the same migration completed in 11 days, with 4 hours of active engineering time. Here is exactly how it was done.
Phase 0: Setting Up the Project CLAUDE.md
Before a single prompt was written, 45 minutes were spent encoding architectural knowledge into CLAUDE.md. This investment paid off in every subsequent session.
# CLAUDE.md — Legacy Migration Project
## Migration Goal
Migrate from Java 8 + Spring MVC 5.3 to Java 21 + Spring Boot 3.2 + Project Loom.
## Source State (Current)
- Java 8, Maven, Spring MVC with web.xml
- Servlet-based request handling
- ThreadLocal pattern for request context
- Tomcat 9 embedded via spring-boot-starter-web 2.7
## Target State
- Java 21, Spring Boot 3.2, Gradle
- Virtual Threads via spring.threads.virtual.enabled=true
- No ThreadLocal (incompatible with virtual threads pinning)
- Jakarta EE (javax.* → jakarta.*)
- Tomcat 10.1 (only Tomcat 10+ supports virtual threads correctly)
## CRITICAL MIGRATION RULES
1. Replace ALL `javax.*` imports with `jakarta.*`
2. Replace ThreadLocal with ScopedValue (Java 21 JEP 464) or request attributes
3. Replace synchronized blocks in hot paths with ReentrantLock + virtual-thread-friendly patterns
4. All @RestController methods must be annotated with the correct Jakarta annotations
5. Spring Security upgraded to 6.x — SecurityConfig extends WebSecurityConfigurerAdapter is REMOVED
6. spring.jpa.hibernate.ddl-auto must remain 'validate' in production profile
## Package Structure (DO NOT CHANGE)
- com.company.api → controllers
- com.company.service → business logic
- com.company.repository → data access
- com.company.domain → JPA entities
- com.company.config → Spring configuration
## What Claude MUST NOT Do
- Never touch src/main/resources/db/migration/ (Flyway scripts)
- Never modify SecurityConfig.java without explicit permission
- Never change application-prod.properties
- Never run git commit, git push, or mvn deploy
Phase 1: The Dependency Audit (Research Agent)
Prompt to Claude:
Research Phase: Map all Java 8 and Spring MVC 5 dependencies that will break on Spring Boot 3.2 / Jakarta. Specifically:
- Find all
import javax.*statements across the entire src/ directory and count them by package- Find all
ThreadLocalusages- Find all classes extending
WebSecurityConfigurerAdapter- Find all usage of
HttpServletRequestandHttpServletResponseDo NOT modify any files. Output a structured report.
Claude's Research Output (after 4 minutes):
DEPENDENCY AUDIT REPORT
========================
javax.* imports: 847 occurrences across 112 files
- javax.servlet.*: 203 occurrences (controllers + filters)
- javax.persistence.*: 441 occurrences (JPA entities + repos)
- javax.validation.*: 203 occurrences (DTOs)
ThreadLocal usages: 23 occurrences in 8 files
CRITICAL: RequestContextHolder in AuthFilter.java (line 47) uses inheritable
ThreadLocal — this WILL cause virtual thread pinning.
WebSecurityConfigurerAdapter: 2 classes (SecurityConfig.java, TestSecurityConfig.java)
HttpServletRequest/Response: 89 occurrences across 31 files
Note: 67 of these are in filter chain — will need Jakarta Servlet API update
Human decision point: Reviewed the report. Confirmed the approach. Flagged the AuthFilter.java ThreadLocal as the highest risk item — scheduled it as a dedicated sub-task.
Phase 2: The Automated Migration (Parallel Agents)
With the audit complete, the work was split across three parallel agent sessions:
Agent A: javax → jakarta Import Migration
SCOPE: src/main/java only (not test/)
TASK: Replace all javax.* imports with jakarta.* equivalents:
javax.servlet → jakarta.servlet
javax.persistence → jakarta.persistence
javax.validation → jakarta.validation
javax.annotation → jakarta.annotation
Use find/replace across all files. After each file batch of 10 files, run:
./mvnw compile -q 2>&1 | head -50
Report: files changed, any compilation errors per batch.
Result: Agent A modified 112 files in 7 minutes. Reported 3 compilation errors on obscure legacy annotations — human resolved in 15 minutes.
Agent B: Security Configuration Migration
SCOPE: src/main/java/com/company/config/SecurityConfig.java only
TASK: Migrate from WebSecurityConfigurerAdapter (removed in Spring Security 6)
to the new component-based security configuration.
BEFORE pattern:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) { ... }
}
AFTER pattern:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { ... }
}
PRESERVE: All existing security rules exactly. Only change the structural pattern.
VALIDATION: Run ./mvnw test -pl . -Dtest=SecurityConfigTest
Result: Agent B completed the migration in 3 minutes. All 8 security tests passed.
Agent C: ThreadLocal → ScopedValue Migration (Critical Path)
This was handled last with the most scrutiny:
SCOPE: src/main/java/com/company/filter/AuthFilter.java
src/main/java/com/company/context/RequestContext.java
TASK: Replace InheritableThreadLocal with ScopedValue (Java 21 JEP 464).
CURRENT PATTERN:
private static final InheritableThreadLocal<UserContext> CONTEXT =
new InheritableThreadLocal<>();
TARGET PATTERN:
public static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
// Usage changes from:
CONTEXT.set(userContext);
try { ... } finally { CONTEXT.remove(); }
// To:
ScopedValue.where(CONTEXT, userContext).run(() -> { ... });
VALIDATION: Run ./mvnw test -pl . -Dtest=AuthFilterTest,RequestContextTest
Result: Agent C completed in 6 minutes. 2 test failures revealed that the legacy test was checking ThreadLocal directly — human updated the tests in 20 minutes.
Phase 3: Virtual Thread Configuration
After all migrations, enabling virtual threads required a single-line change — but verifying it actually worked required careful validation:
Prompt:
- Add
spring.threads.virtual.enabled=trueto application.properties- Add a Spring Boot Actuator endpoint to verify virtual thread usage
- Write a simple load test script using Apache JMeter CLI that sends 1000 concurrent requests and measure thread pool behavior
Claude generated a verification script:
@RestController
@RequestMapping("/admin/threads")
public class ThreadDiagnosticsController {
@GetMapping("/current")
public Map<String, Object> currentThread() {
Thread t = Thread.currentThread();
return Map.of(
"name", t.getName(),
"isVirtual", t.isVirtual(),
"threadId", t.threadId()
);
}
@GetMapping("/pool-stats")
public Map<String, Object> poolStats() {
ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
return Map.of(
"threadCount", mxBean.getThreadCount(),
"peakThreadCount", mxBean.getPeakThreadCount(),
"daemonThreadCount", mxBean.getDaemonThreadCount()
);
}
}
Verification result: Hit /admin/threads/current — confirmed isVirtual: true. Thread count under 1000 concurrent requests stayed at 42 (platform threads) instead of the previous 200+ (one-thread-per-request model).
Phase 4: Verification Agent Pass
After all implementation agents completed, a fresh Verification Agent was given a clean prompt with no knowledge of what had been changed:
ROLE: Quality Assurance Agent (adversarial)
You have no knowledge of what was changed. Your job is to find problems.
TASKS:
1. Run ./mvnw verify — report all failures
2. Search for remaining javax.* imports: grep -r "import javax\." src/
3. Search for remaining ThreadLocal usages: grep -r "ThreadLocal" src/
4. Search for any remaining WebSecurityConfigurerAdapter: grep -r "WebSecurityConfigurerAdapter" src/
5. Check pom.xml — confirm spring-boot-parent is 3.2.x, Java source/target is 21
6. Run ./mvnw dependency:tree | grep -E "(spring-boot|spring-security|tomcat)" — confirm versions
Report any findings that indicate incomplete migration.
Verification Agent findings:
FINDINGS:
1. ./mvnw verify: 156/156 tests PASSED ✅
2. javax.* imports: 3 remaining in src/test/ (not in scope, acceptable) ✅
3. ThreadLocal: 1 remaining in src/main/ — CRITICAL ❌
Location: src/main/java/com/company/util/MdcHelper.java:23
Usage: logging MDC context propagation — legitimate use, but needs review
4. WebSecurityConfigurerAdapter: 0 remaining ✅
5. Spring Boot: 3.2.4 ✅, Java 21 ✅
6. Tomcat 10.1.18 ✅ (correctly supports virtual threads)
Human resolution: The MdcHelper.java ThreadLocal was intentional — MDC logging actually works correctly with virtual threads in Logback 1.5+. The Verification Agent correctly flagged it; the human correctly resolved it was not a problem.
The Numbers
| Metric | Value |
|---|---|
| Files modified | 117 |
| Lines changed | 2,847 |
| Tests passing before migration | 156/156 |
| Tests passing after migration | 156/156 |
| javax.* imports removed | 844 |
| ThreadLocal usages eliminated | 22 of 23 (1 intentional) |
| Active human engineering time | ~4 hours |
| Total calendar time | 11 days (including review cycles) |
| Estimated manual time | 6–8 weeks |
Lessons Learned
What Worked Exceptionally Well
-
The CLAUDE.md investment was worth every minute. Claude never proposed using the wrong Spring Security pattern because it knew the pattern to use from the start.
-
Research-first, implement-second is non-negotiable. The audit revealed the ThreadLocal risk before any code was written, allowing it to be treated as a first-class concern rather than a late discovery.
-
The Verification Agent caught real bugs. Three type mismatches and one threading issue were found by the adversarial verification pass — none were caught by the implementation agents' self-validation.
What Required Human Judgment
- The MDC ThreadLocal call: Claude correctly flagged it; only a human knew it was intentional.
- Test updates after threading model change: Claude couldn't predict which tests implicitly assumed thread-per-request semantics.
- Security configuration intent: Claude migrated the structure; a human verified the business rules (role hierarchies, CSRF policies) were preserved correctly.
The Rule of Thumb
Claude handles the "what" of a migration; you handle the "why."
Mechanical transformations (import replacement, pattern migration, API signature updates) are Claude's strong suit. Business rule preservation, security policy validation, and architectural judgment belong to the human engineer.
Key Takeaways
- An upfront CLAUDE.md with migration rules saved an estimated 40+ correction prompts across the session.
- Splitting the migration into 5 explicit phases with human checkpoints prevented cascading errors.
- The Verification Agent caught 3 breaking type mismatches that slipped past the implementation agents.