Lesson 6 of 9 13 minAI Builder

Claude Code in Production: Migrating a Legacy Java Monolith to Spring Boot 3 — A Real-World Case Study

A complete end-to-end walkthrough of using Claude Code to migrate a 50,000-line legacy Java Spring MVC application to Spring Boot 3.2 with Project Loom — from the initial audit to CI passing green.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

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.
Recommended Prerequisites
claude-code-masterclass-introclaude-code-agentic-workflowsclaude-code-multi-agent-orchestration

Premium outcome

From AI curiosity to agentic engineering workflows that actually ship.

Developers building agentic workflows, local AI tooling habits, and modern developer-experience systems.

What you unlock

  • A concrete workflow for research, strategy, execution, and validation with AI agents
  • Safer operating habits around rules, secrets, tool use, and repo-level context management
  • A stronger sense for where agentic coding improves speed versus where human judgment matters most

At a 200-person SaaS company, the backend team faced a mandatory migration: their Java 8 Spring MVC monolith (comprising approximately 50,000 lines of code) needed to move to Spring Boot 3.2, Java 21, and Project Loom virtual threads. The estimated manual effort was 6 to 8 weeks for two senior engineers.

With Claude Code, the same migration completed in 11 days, with only 4 hours of active engineering time. This case study details the requirements, architectural shifts, database configurations, and capacity calculations that turned this complex migration into a production success.


System Requirements

To migrate a legacy monolithic web application to a modern virtual thread-based framework, we establish the functional boundaries and performance targets for the migration lifecycle and the target application.

Functional Requirements

  • Import Migration: Replace all javax.* servlet, persistence, and validation package imports across the codebase with the modern jakarta.* namespace.
  • Thread Safety Realignment: Eliminate the use of thread-local variables (ThreadLocal) in request contexts, replacing them with virtual-thread-safe structures to prevent memory leaks and pinning issues.
  • Component-Based Security: Migrate legacy class-inheritance security configurations (WebSecurityConfigurerAdapter) to component-based filter chains.
  • Dynamic Database Versioning: Track schema modifications and database versions using dynamic metadata tables.

Non-Functional Requirements

  • Zero-Downtime Rollback: The migrated database and application schema must support immediate rollback to the legacy state without loss of active session transactions.
  • Throughput Scaling: The migrated Spring Boot server must sustain a minimum concurrent request volume of 10,000 requests/sec with average P99 API latencies less than 50 milliseconds.
  • Low Thread-Scheduling Latency: Dynamic task carrier context shifts must execute in less than 5 microseconds to ensure virtual threads achieve maximum CPU utilization.

API Design and Interface Contracts

The migration process requires declaring updated dependency schemas, updated request thread scopes, and new component-based filter interfaces.

1. Project Dependency Upgrade Schema (Maven pom.xml Specification)

Below is the interface contract declaring the target Spring Boot 3.2 dependency hierarchy and JVM compilation bounds.

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.4</version>
        <relativePath/>
    </parent>
    <groupId>com.codesprintpro</groupId>
    <artifactId>monolith-migrated</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <properties>
        <java.version>21</java.version>
        <jakarta-servlet.version>6.0.0</jakarta-servlet.version>
    </properties>
</project>

2. Request Scoped Context Contract (ScopedValue API)

To replace ThreadLocal safely without pinning virtual threads to carrier OS threads, we declare the Scoped Value static context API.

package com.codesprintpro.context;

import java.lang.ScopedValue;

public class RequestContext {
    // Declaring ScopedValue API contract (JEP 464)
    public static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();

    public static void executeInContext(UserContext userContext, Runnable runnable) {
        // Binds the value dynamically for the scope of the runnable execution
        ScopedValue.where(USER_CONTEXT, userContext).run(runnable);
    }
}

3. Migrated Security Interface Contract (SecurityFilterChain Bean)

The new API contract for declaring authentication and authorization filters in Spring Boot 3.2 is structured as a bean contract rather than class extension.

package com.codesprintpro.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/threads/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .csrf(csrf -> csrf.disable());
        return http.build();
    }
}

High-Level Architecture

The architectural transition focuses on moving from blocking platform threads (where each HTTP request consumes a heavy OS thread) to non-blocking Java Virtual Threads executed on a pool of carrier threads.

1. Platform Threads vs. Virtual Threads Scheduling Topology

In the legacy model, Tomcat maps each HTTP socket connection to a dedicated Platform Thread ($1:1$ mapping to OS kernel threads). In the migrated virtual thread model, thousands of Virtual Threads are multiplexed across a small pool of physical Carrier Threads ($M:N$ mapping via the Fork-Join pool).

graph TD
    subgraph Legacy Platform Threading
        Req1[HTTP Connection 1] --> ThreadP1[Tomcat Thread Pool: Platform Thread 1]
        Req2[HTTP Connection 2] --> ThreadP2[Tomcat Thread Pool: Platform Thread 2]
        ThreadP1 --> OS1[OS Kernel Thread 1]
        ThreadP2 --> OS2[OS Kernel Thread 2]
    end

    subgraph Migrated Project Loom Threading
        ReqV1[HTTP Connection 1] --> ThreadV1[Virtual Thread 1001]
        ReqV2[HTTP Connection 2] --> ThreadV2[Virtual Thread 1002]
        ReqV3[HTTP Connection 3] --> ThreadV3[Virtual Thread 1003]
        
        ThreadV1 & ThreadV2 & ThreadV3 -->|Multiplexed| ForkJoin[Loom Scheduler: ForkJoinPool]
        
        ForkJoin --> Carrier1[Carrier Thread 1: CPU Core A]
        ForkJoin --> Carrier2[Carrier Thread 2: CPU Core B]
        
        Carrier1 --> OSK1[OS Kernel Thread A]
        Carrier2 --> OSK2[OS Kernel Thread B]
    end

2. Spring Security 6 Filter Chain Bean Orchestration

Incoming requests traverse a unified chain of configuration filter beans, dynamically resolving authorization paths.

sequenceDiagram
    autonumber
    participant Client as User Client
    participant Proxy as DelegatingFilterProxy
    participant Chain as SecurityFilterChain Beans
    participant AuthFilter as Custom Authentication Filter
    participant Endpoint as ThreadDiagnosticsController

    Client->>Proxy: GET /admin/threads/current
    Proxy->>Chain: Delegate Request to SecurityFilterChain
    Chain->>AuthFilter: Execute authentication parsing
    AuthFilter->>AuthFilter: Bind UserContext using ScopedValue.where()
    AuthFilter-->>Chain: Authentication Successful
    Chain->>Endpoint: Route request to controller endpoint
    Endpoint-->>Client: Return current thread diagnostic metadata

Low-Level Design and Schema

To track database migrations during the upgrade and support zero-downtime rollbacks, we utilize a version-controlled Flyway schema layout in PostgreSQL.

-- Tracks migration versions and execution logs
CREATE TABLE flyway_schema_history (
    installed_rank INT PRIMARY KEY,
    version VARCHAR(50),
    description VARCHAR(200) NOT NULL,
    type VARCHAR(20) NOT NULL,
    script VARCHAR(1000) NOT NULL,
    checksum INT,
    installed_by VARCHAR(100) NOT NULL,
    installed_on TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    execution_time INT NOT NULL, -- in milliseconds
    success BOOLEAN NOT NULL
);

CREATE INDEX idx_flyway_success ON flyway_schema_history(success);

-- Active migrated user sessions tracking schema
CREATE TABLE active_spring_sessions (
    primary_id CHAR(36) PRIMARY KEY,
    session_id CHAR(36) NOT NULL UNIQUE,
    creation_time BIGINT NOT NULL,
    last_access_time BIGINT NOT NULL,
    max_inactive_interval INT NOT NULL,
    expiry_time BIGINT NOT NULL,
    principal_name VARCHAR(100),
    session_attributes BYTEA
);

CREATE INDEX idx_sessions_expiry ON active_spring_sessions(expiry_time);

Design Rationale:

  1. Flyway Historical Indexing (idx_flyway_success): Speeds up boot-up validation checks. The application query parses the index to confirm that all migration scripts ran successfully before starting the virtual thread pool.
  2. session_attributes (Binary Data Block): Relocating session attributes from JVM memory to an external database guarantees stateless request processing, which is required for horizontally scaling application containers.

Scaling Challenges and Capacity Estimation

Under highly concurrent virtual threads workloads, physical limits shifted from thread stack allocations to database connection availability.

1. Thread Stack Memory Allocation Savings

In legacy Tomcat configurations, each thread allocates a chunk of stack memory, placing a ceiling on concurrency.

  • Assumptions:

    • Legacy platform thread stack size (-Xss) = $1$ MB
    • Migrated virtual thread stack size = $2$ KB (allocated dynamically on the heap)
    • Concurrent active request connections = $10,000$
  • Calculations: $$\text{Legacy Memory Required} = 10,000 \times 1\text{ MB} = 10\text{ GB of Native Thread RAM}$$ $$\text{Migrated Memory Required} = 10,000 \times 2\text{ KB} = 20,000\text{ KB} \approx 19.5\text{ MB of Heap RAM}$$

By shifting stack allocations from native OS structures to the JVM heap, virtual threads reduce thread-related memory footprints by over 99%. This allows developers to scale concurrency bounds past OS thread ceilings.

2. Lock Pinning and Context Switch Costs

When a virtual thread executes inside a synchronized block, it pins the virtual thread to its underlying OS carrier thread. If the thread encounters an I/O block while pinned, the carrier thread stalls, raising scheduling overhead.

  • Assumptions:

    • Total threads = $1,000$ concurrent virtual threads
    • Percentage of synchronized blocking database operations = $30%$ ($300$ threads pinned)
    • CPU cores available = $8$ cores
    • Overhead of thread context switch = $10$ microseconds
  • Calculations: If all 8 carrier threads become pinned by blocking operations, the Fork-Join pool scheduler stalls. This forces the JVM to spawn temporary platform threads (oversaturation) to maintain concurrency. This triggers CPU context switches, spending precious cycles on thread scheduling: $$\text{Context Switching Loss} = 10\text{ microseconds} \times 1,000\text{ switches} = 10\text{ milliseconds of CPU latency}$$

To prevent this loss, we replaced synchronized blocks in the database and logging pipelines with ReentrantLock instances, allowing the Fork-Join scheduler to unmount the virtual thread during I/O operations.


Failure Scenarios and Resilience

High concurrency under virtual threads exposes hidden synchronization bugs and resource starvation risks.

1. Carrier Thread Pinning and Starvation

  • The Threat: A legacy JDBC driver uses synchronized blocks to coordinate connection pools. When requests scale under load, all carrier threads become pinned, stalling the Fork-Join scheduler and causing request timeouts.
  • Resilience Design:
    • Upgraded the connection pool database driver (HikariCP and PostgreSQL JDBC driver) to versions that replace synchronized blocks with Java's virtual-thread-friendly locks (ReentrantLock).
    • Added the JVM launch argument -Djdk.tracePinnedThreads=full in testing environments, allowing engineers to track down and resolve lingering carrier pinning issues.

2. Database Connection Pool Exhaustion

  • The Threat: Because virtual threads are lightweight, spawning 10,000 threads to run concurrent queries will overwhelm a connection pool sized for only 200 platform threads, causing connection lookup timeouts.
  • Resilience Design:
    • Configure a Semaphore-Based Rate Limiter at the database entry boundary.
    • Limit active query execution concurrency to match the pool size (e.g. 100 connections), queuing remaining threads within the JVM instead of overwhelming the database pool.

Architectural Trade-offs

Evaluating virtual thread migrations requires balancing framework complexity against resource efficiency.

Trade-off 1: Platform Threads vs. Virtual Threads

Metric Platform Threads (Legacy) Virtual Threads (Migrated)
Throughput Ceiling Low. Limited by physical memory and OS thread limits. High. Limited only by heap size and network card limits.
Memory Efficiency Low. Requires 1MB allocated per thread stack. High. Dynamic stack allocation (around 2KB per thread).
I/O Handling Style Blocking. Stalls execution threads. Non-blocking. Automatically suspends and reschedules.
Code Debugging Simple. Standard thread stack traces are direct. Complex. Async stack traces complicate thread diagnostics.

Trade-off 2: ScopedValue vs. ThreadLocal Context

Metric ThreadLocal (Legacy) ScopedValue (Java 21)
Mutability High. Can be modified by any method in the call stack. Immutable. Binds context values for a defined scope.
Memory Leak Risk High. Values persist if remove() is not called. Low. Automatically collected once execution scope exits.
Virtual Thread Safety Poor. Pinning risks and memory footprint bloat. High. Tailored for lightweight concurrency structures.

Staff Engineer Perspective

Operating virtual threads in production requires looking past design patterns to understand low-level scheduling mechanics.


Verbal Script

Interviewer: "Why does using ThreadLocal with Java virtual threads pose a risk, and how does ScopedValue resolve it?"

Candidate: "Using ThreadLocal with virtual threads poses two major risks: memory footprint bloat and data mutability leaks.

In legacy platform threading models, we had a small pool of threads, so the number of ThreadLocal maps was limited.

However, with virtual threads, we can easily spawn 100,000 or more concurrent threads.

If each thread maintains its own copy of a heavy ThreadLocal context, this creates massive memory overhead on the heap, leading to frequent Garbage Collection runs.

Additionally, if the thread forgets to call .remove() once the request completes, that memory remains pinned, causing memory leaks.

Java 21 introduces ScopedValue to resolve these issues.

Unlike ThreadLocal, ScopedValues are immutable.

They bind a context value to a specific request scope using ScopedValue.where().

Once the bounded execution scope completes, the binding is automatically discarded, eliminating memory leaks without requiring manual cleanup.

Furthermore, ScopedValues are optimized for virtual threads, allowing child threads to inherit context values efficiently, bypassing the expensive copying overhead of InheritableThreadLocal."


Interviewer: "What is thread pinning in Project Loom, how do you diagnose it in production, and how do you fix it?"

Candidate: "Thread pinning occurs when a virtual thread is running inside a synchronized block or method, or is calling a native system library.

During this execution, the virtual thread is locked to its physical carrier thread in the Fork-Join pool.

If the virtual thread blocks on I/O while pinned, the underlying carrier thread is also blocked, starving the JVM scheduler of CPU resources.

To diagnose thread pinning in production, I monitor thread state telemetry and configure JVM launch arguments.

Specifically, I run the JVM with the flag -Djdk.tracePinnedThreads=full.

This print-out logs a full stack trace to standard error whenever a virtual thread blocks while pinned, pinpointing the offending code.

To fix thread pinning, I replace the synchronized blocks or methods with java.util.concurrent.locks.ReentrantLock.

Unlike synchronized blocks, ReentrantLock allows the virtual thread to unmount from the carrier thread when it blocks on I/O, freeing up the carrier thread to execute other virtual threads."


Interviewer: "How does the migration of Spring Security from WebSecurityConfigurerAdapter to SecurityFilterChain beans affect application structure?"

Candidate: "The migration from subclassing WebSecurityConfigurerAdapter to declaring SecurityFilterChain beans represents a shift from configuration-by-inheritance to configuration-by-composition.

In Spring Security 5 and below, we extended WebSecurityConfigurerAdapter and overrode methods to configure HTTP security.

This inheritance model made it difficult to configure multiple security configurations or override default filters dynamically.

In Spring Boot 3.2, WebSecurityConfigurerAdapter has been removed.

We now define security configurations as standard Spring bean declarations that return a SecurityFilterChain.

This bean-driven composition allows us to decouple security configuration rules.

We can easily declare multiple SecurityFilterChain beans with different priority orders (@Order), allowing different filter rules to be evaluated for different request paths (e.g. one chain for APIs, another for administrative dashboards).

This is more flexible, easier to test in isolation, and fits cleanly with the framework's core dependency injection container."


Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.