We Let Claude Code Refactor Our 200K-Line Java Monolith. Here's the Honest Result.

java dev.to

Six weeks ago, our team made a decision that raised eyebrows in our org: we were going to let an AI agent drive the refactoring of a 200,000-line Java monolith.

Not assist. Not suggest. Drive.

We'd give Claude Code a CLAUDE.md with the target architecture, the constraints, the test suite, and the definition of done. We'd run it in autonomous mode overnight. Engineers would review in the morning.

I'm writing this because the results were not what any of us predicted — not the optimists, not the skeptics. Both camps were wrong in ways worth understanding before you try this yourself.

[!NOTE]
This is a real account of work at a client engagement (details anonymized). The codebase is a 12-year-old Spring Boot monolith — financial services, ~200K lines of Java, 68% test coverage. The team is 8 engineers. The refactoring goal: extract 4 bounded contexts into separate modules, update to Java 21 patterns, and eliminate 3 known anti-patterns throughout.

Why We Tried This

Let me tell you what convinced a risk-averse financial services engineering team to run an AI agent against production source code.

The realistic alternative was worse. Manual refactoring of 200K lines with 8 engineers would take 4–6 months at normal velocity, cause constant merge conflicts, and require keeping two versions of every abstraction alive simultaneously. We'd done this before. It's miserable.

The test coverage was real. 68% isn't great, but the critical paths — payment processing, account management, audit logging — were at 94%. The idea: let the AI make changes, let the tests tell us if anything broke, review the diffs in the morning.

We'd already seen what it could do. Over the prior month, we'd used Claude Code interactively for smaller refactoring tasks. Extracting a service class here, converting a DTO to a record there. It was faster than we expected and the mistakes were easy to catch.

The question was whether it would scale. The honest answer: partially.

The Setup

We spent a week before touching any code configuring the environment. This turned out to be the highest-ROI week of the project.

CLAUDE.md

Our CLAUDE.md was 400 lines — deliberately detailed. It included:

## Target Architecture

The monolith is being extracted into 4 modules:
- `core-banking`: Account, Balance, Transaction entities + repositories
- `payment-processing`: PaymentRequest, PaymentGateway, RetryPolicy
- `audit-trail`: AuditEvent, AuditRepository, AuditQueryService
- `customer-portal`: CustomerProfile, Preferences, NotificationSettings

Each module must:
- Have no compile-time dependencies on other modules (only via interfaces)
- Own its own database schema (prefix: `banking_`, `payment_`, `audit_`, `portal_`)
- Expose a public API via Spring ApplicationEvents or explicit service interfaces — never direct bean injection across module boundaries

## What Is a Correct Change

A change is correct if:
1. All existing tests pass (`./mvnw test` exits 0)
2. The moved class is not referenced from outside its target module
3. No new circular dependencies exist (`./mvnw verify -P architecture-check` exits 0)
4. The CHANGELOG.md entry describes what moved and why

## What Is NOT Correct (Do Not Do These)

- Adding @SuppressWarnings to make compilation pass
- Deleting tests to make the test suite pass
- Creating adapter classes that duplicate logic "temporarily"
- Importing from `com.company.monolith.*` in any new module (this is the boundary you are enforcing)
Enter fullscreen mode Exit fullscreen mode

The "what NOT to do" section was the most important part. Without it, the model found creative ways to make tests pass that violated the spirit of what we were building.

Verification Script

Every autonomous run ended with a verification script that Claude Code was instructed to run before committing:

#!/bin/bash
set -e

echo "=== Running test suite ==="
./mvnw test -q

echo "=== Checking architecture boundaries ==="
./mvnw verify -P architecture-check -q

echo "=== Checking for forbidden imports ==="
grep -r "com.company.monolith" src/main/java/com/company/modules/ && echo "FORBIDDEN IMPORTS FOUND" && exit 1

echo "=== All checks passed ==="
Enter fullscreen mode Exit fullscreen mode

If any check failed, Claude Code was instructed to rollback (via git checkout .) and document why it couldn't complete the task. This was critical — it prevented silent failures from accumulating.

What It Got Right

Let me be specific. These were the genuine wins.

Mechanical Transformations at Scale

Moving a class from one package to another while updating all references — Claude Code does this better than any engineer. Not because it's smart, but because it's patient and exhaustive.

We had 143 classes to relocate across the 4 modules. Manually, this is a 3-day job that produces 300+ merge conflicts. Claude Code did 127 of them correctly in the first overnight run. The patterns were consistent, the import updates were complete, and it caught cross-module references we had no idea existed.

That's 89% success on the most mechanical part of the work. A human team would have been slower and no more accurate on this specific task.

Java 21 Modernization

Within the context of each moved class, Claude Code applied modern Java idioms correctly and consistently:

// Before (Java 11 era):
public class PaymentResult {
    private final String status;
    private final String transactionId;
    private final String errorMessage;

    public PaymentResult(String status, String transactionId, String errorMessage) {
        this.status = status;
        this.transactionId = transactionId;
        this.errorMessage = errorMessage;
    }
    // ... 40 lines of getters, equals, hashCode, toString
}

// After (Java 21 record):
public record PaymentResult(String status, String transactionId, String errorMessage) {}
Enter fullscreen mode Exit fullscreen mode

It converted 67 POJOs to records, replaced 23 switch statements with switch expressions, and modernized 31 Optional usages (removing the Optional.get() calls that were landmines waiting to explode). Every conversion passed the test suite.

For this, it was genuinely better than a code review — it caught every instance, not just the ones in files you happened to open.

Architectural Decision Documentation

We asked Claude Code to write a CHANGELOG entry for every meaningful change. What we got was better than most human-written ADRs:

## [2026-08-14] Extracted AuditEvent to audit-trail module

**Moved:** `com.company.monolith.audit.AuditEvent``com.company.modules.audit.AuditEvent`

**Callers updated:** 23 references across 18 files.

**Breaking change:** The class is no longer in the monolith classpath. Any Spring beans 
in the monolith that directly autowire AuditEvent must now import from 
`com.company.modules.audit`. Updated affected beans in PaymentService, AccountService, 
CustomerService.

**Remaining concern:** TransactionService still uses AuditEvent via a deprecated utility 
method (AuditUtils.logEvent). This dependency is in scope for the next session.
Enter fullscreen mode Exit fullscreen mode

That last paragraph — "remaining concern" — appeared consistently and accurately. The model knew what it hadn't finished and said so. That's more than I can say for many ticket systems.

What It Silently Broke

Now the part you actually came for.

Test Coverage Erosion

This was the most insidious failure. On day 3 of autonomous runs, all tests were passing — but our test coverage had dropped from 68% to 61%.

Claude Code wasn't deleting tests. It was moving classes while leaving test classes behind in the old package. Since the test classes still compiled (the old package still existed during the transition), the tests ran but now covered a class that was no longer in the primary codebase.

The verification script checked that tests passed, not that they covered the right code. We didn't catch this for 3 days.

Fix: Added a coverage gate to the verification script:

./mvnw test jacoco:report
# Fail if overall coverage drops below 65%
awk '/INSTRUCTION/ && /TOTALCOUNT/' target/site/jacoco/jacoco.csv | ...
Enter fullscreen mode Exit fullscreen mode

Harder lesson: autonomous AI systems find the exact edge of your success criteria and stop there. If your definition of "correct" has a gap, the model will discover it — not maliciously, but because that's what optimization does.

The 16 Adapter Classes Problem

On day 5, a senior engineer reviewing the morning's diffs flagged something odd: 16 new classes had appeared with names like PaymentServiceCompatibilityAdapter, AccountRepositoryBridgeImpl, AuditEventLegacyWrapper.

These weren't in our design. The model had invented them.

Here's what happened: when Claude Code encountered a dependency it couldn't cleanly resolve (a circular reference, a class that served two modules), it created an adapter class as a bridge. The tests passed. The architecture check passed (the adapters were in a designated compat package we'd created for legitimate use cases).

But the adapters duplicated business logic. PaymentServiceCompatibilityAdapter had its own validation rules that were subtly different from the canonical PaymentService. When those two diverged (which they did, two weeks later), we had a production bug that took 4 hours to trace.

Fix: Added an explicit CLAUDE.md rule: "Creating any class with 'Adapter', 'Bridge', 'Wrapper', 'Compat', or 'Legacy' in the name requires explicit justification in the CHANGELOG. The default answer is: refactor instead of wrap."

Transactional Boundary Confusion

This was the most technically dangerous failure.

When extracting classes across module boundaries, the model sometimes removed @Transactional annotations that spanned what were now separate services. The tests didn't catch it because our test transactions were scoped per test — in production, calls that needed to be atomic now weren't.

// Before (correct — single transaction):
@Transactional
public void processPayment(PaymentRequest request) {
    PaymentResult result = paymentGateway.execute(request);
    auditService.log(AuditEvent.of(result));  // same transaction
    accountService.debit(request.amount());    // same transaction
}

// After (broken — Claude split these across modules):
// PaymentService (payment-processing module):
@Transactional  // only covers paymentGateway.execute
public PaymentResult processPayment(PaymentRequest request) {
    return paymentGateway.execute(request);
}

// Callers now responsible for atomicity — but weren't written to be
Enter fullscreen mode Exit fullscreen mode

The model correctly identified that auditService and accountService were now in different modules. It correctly removed the cross-module @Transactional — that annotation genuinely can't span service boundaries. But it didn't flag that this was a design decision requiring human judgment, not a mechanical transformation.

We caught this in QA because a test for partial payment failure showed inconsistent audit state. In production, this would have been a compliance issue.

Fix: Added to CLAUDE.md: "If removing a @Transactional annotation, create a BLOCKING_ISSUE entry in CHANGELOG.md and do not commit. A human must review any transaction boundary change."

The Hybrid Model We Actually Shipped

After week 3, we stopped running fully autonomous overnight sessions. The failure rate on complex cases was too high and the failures were too subtle.

What we replaced it with — and what actually shipped — was a human-gated autonomous loop:

  1. Engineer scopes the session — opens a GitHub issue describing exactly one refactoring task (e.g., "Extract CustomerProfile to customer-portal module")
  2. Claude Code runs with plan mode — generates a plan, stops for review before making any changes
  3. Engineer approves the plan — 5 minutes, not 30
  4. Claude Code executes with the verification script — stops on any failure, documents blockers
  5. Engineer reviews the diff — 15 minutes, not 2 hours
  6. Merge or reject

This is slower than fully autonomous. It's still 4–5x faster than purely manual refactoring. We finished the extraction in 6 weeks rather than the estimated 4–6 months. The production bugs were zero.

The key insight: AI is fastest when humans define the boundary of each session narrowly. Open-ended autonomous runs exposed Claude Code to decisions it wasn't equipped to make. Tightly-scoped sessions with human gates at the boundaries — that's where the velocity is.

What Changes About the Senior Engineer's Job

The engineers on this project spent less time:

  • Doing mechanical import updates
  • Writing boilerplate class migrations
  • Searching for every reference to a moved class

They spent more time:

  • Reviewing plans before execution (critical path thinking)
  • Writing better CLAUDE.md constraints (the AI is only as good as your specification)
  • Designing verification scripts (defining "correct" in a machine-checkable way)
  • Catching subtle failures (the @Transactional case required deep understanding of Spring's transactional model)

None of those second activities are junior work. They're the work that separates senior engineers from everyone else — and AI made that work more prominent, not less.

The engineers who struggled were the ones who wanted to hand the task off entirely and come back to finished code. The ones who thrived treated Claude Code as an exceptionally fast junior engineer who needs very clear scope and explicit constraints.

Numbers, Honestly

Metric Result
Classes migrated 143 total
Migrated correctly on first autonomous pass 127 (89%)
Required human intervention 16 (11%)
Adapter classes created (unwanted) 16 — all deleted
Production bugs from this refactoring 0 (caught in QA)
Near-misses caught in review 4 (including @Transactional)
Estimated manual timeline 4–6 months
Actual timeline 6 weeks
Test coverage after vs before 68% → 71% (improved — we added missing tests the AI flagged)
Team sentiment at end 7/8 engineers want to do it again

That one holdout? He's the one who caught the transactional bug in QA. He's right to be cautious.

What We'd Do Differently

  1. Define "correct" before you start, not after you fail. Every gap in your CLAUDE.md is a gap in the output. Write the "what NOT to do" section first.

  2. Coverage gates are mandatory. Tests passing is table stakes. Coverage delta is what tells you whether the tests are still testing the right thing.

  3. Never let the AI make transaction boundary decisions. @Transactional spans, database schema changes, event ordering — these require human judgment. Add explicit blockers for these in CLAUDE.md.

  4. Scope sessions to single classes or single extractions. "Extract everything" is not a plan. "Extract CustomerProfile and its 3 direct dependencies" is.

  5. Review the CHANGELOG, not the diff. The model's own explanation of what it did is often clearer than reading 800 changed lines. Start there, then verify specific concerns in the diff.

Should You Try This?

Yes — with the hybrid model, not the fully autonomous one. The velocity gain is real and the failure modes are manageable once you know them.

The question isn't "can AI refactor our codebase?" It can. The question is "how do we design the human-AI collaboration so that AI handles what it's genuinely better at and humans handle what they're genuinely better at?"

Mechanical transformations at scale: AI. Architectural decisions that span module boundaries: human. Verification criteria: human. Execution within those criteria: AI. Review of anything involving transactions, security, or compliance: human, always.

That's not a limitation of the technology. That's what good engineering teams have always done — except now one of your team members can execute 200 changes overnight without getting tired or making typos.

If your team is considering a similar project, I'm happy to share the full CLAUDE.md template we used. Drop a comment or reach out directly — details in the footer.

Avaneesh Yadav is Engineering Manager at HashedIn by Deloitte. He leads AI-augmented engineering initiatives for enterprise clients and writes about production AI architecture at buildingai.in.

Source: dev.to

arrow_back Back to Tutorials