DEV Community

yureki_lab
yureki_lab

Posted on

How I Split a 3,200-Line God Class Into 14 Modules With Claude Code

TL;DR

I had a 3,200-line Python class called OrderService that everyone was afraid to touch. Over five weeks, I used Claude Code to break it into 14 focused modules without a single production incident. The trick wasn't the AI writing the code — it was characterization tests first, a seam map I drew myself, and one extraction per pull request.

The Problem

Every codebase that lives long enough grows one. Ours was OrderService: a single class that handled pricing, discounts, tax, inventory reservation, payment capture, refunds, email notifications, audit logging, and — for reasons nobody remembered — CSV exports for the finance team.

The numbers, when I finally measured them:

  • 3,212 lines in one file
  • 87 methods, 31 of them private helpers called from 5+ places
  • 19 instance attributes, many mutated by multiple methods
  • Cyclomatic complexity of 41 on checkout() alone
  • Touched by 60% of all pull requests over the previous six months

That last number was the real cost. Every feature landed in this file, so every PR had merge conflicts, and every review needed someone who "knew the order code." That someone was usually me.

I'd tried splitting it by hand twice before. Both times I got about two days in, discovered some method mutated self._cart_snapshot in a way three other methods silently depended on, and rolled everything back.

This time I wanted to see if an AI coding agent could do the tedious parts while I kept control of the risky parts.

Setup: Claude Code (v2.x CLI), Python 3.12, pytest 8, a Django 5.1 app, roughly 92% line coverage on the service — which sounds great until you realize coverage tells you which lines ran, not which behaviors were asserted.

How I Solved It

The whole thing came down to four phases.

flowchart LR
    A[Characterization tests] --> B[Seam map]
    B --> C[Extract one module per PR]
    C --> D[Delete the facade]
    C -->|tests red| A

Phase 1: Pin the current behavior (even the weird parts)

Before moving a single line, I asked Claude Code to write characterization tests — tests that record what the code currently does, not what it should do.

The prompt that worked:

For each public method on OrderService, write pytest tests that capture
its CURRENT behavior, including edge cases that look like bugs.
Do not fix anything. If behavior looks wrong, add a comment
"# CHARACTERIZED: looks wrong, preserved on purpose" and assert it anyway.
Use real fixtures from tests/fixtures/, not mocks, wherever possible.
Enter fullscreen mode Exit fullscreen mode

That "don't fix anything" line was crucial. Without it, the agent kept "helpfully" asserting the correct behavior, which then failed against real code, which it then tried to fix. Exactly the spiral I wanted to avoid.

It produced 214 new tests in an afternoon. I reviewed them for about two hours and found 11 cases tagged CHARACTERIZED — real bugs, including a discount that stacked twice when a coupon was applied after a gift card. I logged those as tickets and left the behavior frozen. Refactoring and bug-fixing in the same diff is how you lose a weekend.

Phase 2: Draw the seam map myself

This is where I made the most important decision of the project: the agent does not pick the module boundaries.

I did ask Claude Code to analyze the class. This prompt gave me a genuinely useful report:

List every instance attribute on OrderService. For each one, show which
methods read it and which methods write it. Output a table.
Then group methods into clusters that share writes.
Enter fullscreen mode Exit fullscreen mode

The output was a read/write matrix that showed me the real coupling. A trimmed version:

Attribute Written by Read by
_cart_snapshot checkout, apply_coupon, recalc 14 methods
_tax_cache compute_tax checkout, refund, export_csv
_payment_intent capture, refund checkout, notify

Then I drew the boundaries on paper: pricing, tax, inventory, payments, refunds, notifications, audit, exports, and a few smaller ones — 14 in total. Some of the agent's suggested clusters were perfectly sensible on paper but would have split things our team thinks of as one concept, or merged things that change for different reasons.

An AI can tell you what's coupled. It can't tell you what your team will understand six months from now.

Phase 3: One extraction per PR, facade stays alive

Every extraction followed the same recipe, which I wrote into a short instruction file the agent read at the start of each session:

Extraction rules:
1. Extract exactly ONE module per session. Name given by me.
2. OrderService keeps every public method. It delegates to the new module.
3. No behavior changes. No renames of public methods. No "while I'm here".
4. Run the full characterization suite before and after. Paste both summaries.
5. If any test changes, stop and explain why instead of editing the test.
Enter fullscreen mode Exit fullscreen mode

Rule 2 is the facade pattern, and it's what made the whole thing safe. Callers never noticed anything was happening. After extracting tax logic, OrderService looked like this:

class OrderService:
    def __init__(self, order, *, tax_calculator=None):
        self.order = order
        self._tax = tax_calculator or TaxCalculator(order.region)

    def compute_tax(self, lines):
        # Delegates to extracted module; kept for existing callers.
        return self._tax.compute(lines)
Enter fullscreen mode Exit fullscreen mode

And the new module was small, boring, and testable on its own:

@dataclass(frozen=True)
class TaxCalculator:
    region: str

    def compute(self, lines: list[LineItem]) -> Decimal:
        rate = RATES.get(self.region, DEFAULT_RATE)
        taxable = sum(l.amount for l in lines if not l.tax_exempt)
        return (taxable * rate).quantize(Decimal("0.01"))
Enter fullscreen mode Exit fullscreen mode

Notice frozen=True and no mutable cache. The old _tax_cache turned out to save about 0.4ms per checkout, and removing it was one of the few deliberate changes I allowed — in its own PR, with a benchmark attached.

Each extraction PR averaged about 180 lines changed, which meant reviews took 15 minutes instead of an hour. My teammates could actually review them, which mattered as much as the code itself.

The war story: the hidden shared state

Extraction #6 was refunds. The agent moved the methods, the delegation looked clean, and all 214 characterization tests passed.

Then staging broke. Partial refunds on orders with a coupon were refunding the pre-discount amount.

What happened: the old refund() read self._cart_snapshot, which was set by apply_coupon() earlier in the same request — because the view called both on the same OrderService instance. The new RefundProcessor got its own fresh snapshot, built from the database, which didn't have the coupon applied yet because that transaction hadn't committed.

None of my characterization tests called apply_coupon() and refund() on the same instance. Each test built a fresh service. The tests pinned methods, not sequences.

The fix took ten minutes once I understood it — pass the snapshot explicitly instead of letting the refund module rebuild it. The lesson took longer to sink in. I went back and asked Claude Code to do something I should have done in Phase 1:

Search the codebase for every call site that invokes two or more
OrderService methods on the same instance. List the method sequences.
Then write one characterization test per unique sequence.
Enter fullscreen mode Exit fullscreen mode

That found 23 distinct sequences across views, Celery tasks, and management commands. Three of them exercised shared state in ways that would have broken in later extractions. Those tests caught two more problems before they left my laptop.

Phase 4: Delete the facade

Once all 14 modules existed, OrderService was about 260 lines of pure delegation. I had the agent migrate call sites to use the new modules directly — this was the most mechanical part and where AI speed really paid off. It updated 142 call sites across 38 files in an afternoon, in batches of roughly 20 per commit so I could review each batch.

Then I deleted OrderService. Felt great. 🚀

Final tally after five weeks:

  • 14 modules, the largest at 410 lines, the median at 190
  • checkout() complexity: 41 → 9
  • Characterization tests: 214 → 237 (plus the sequence tests)
  • 11 frozen bugs logged and later fixed in separate PRs
  • Merge conflicts in order code: dropped from "most weeks" to roughly one a month
  • Production incidents caused by the refactor: zero

Lessons Learned

1. Characterization tests are the real deliverable. 💡 The refactor was almost a side effect. Once behavior was pinned, extracting modules became low-stakes. If you only take one thing from this post, it's this: have the agent freeze current behavior before it changes anything, and explicitly tell it not to fix what it finds.

2. Test sequences, not just methods. God classes hide coupling in call order on a shared instance. Per-method tests miss it entirely. Grepping for multi-method call sites should be step one, not something you learn after staging breaks.

3. Let the agent analyze coupling; you draw the boundaries. The read/write matrix was one of the most useful outputs of the whole project. The suggested clusters were not. Module boundaries are a team-communication decision as much as a technical one, and that's still on you.

4. Small PRs are a feature for humans, not just for safety. ⚠️ An AI can happily produce a 3,000-line diff. Nobody can review that. Capping the agent to one extraction per session kept every PR reviewable and kept my teammates involved, which is how knowledge of the new structure actually spread.

5. Write the rules down once, not in every prompt. The five-rule instruction file did more to keep the agent on track than any clever phrasing. "No 'while I'm here' changes" alone probably saved me a dozen rejected PRs.

What's Next

There are two more classes in the same codebase over 1,500 lines — a reporting service and a user-permissions module. I'm going to reuse the same playbook, but this time start with the sequence-scan prompt before anything else.

I'm also experimenting with having the agent generate the read/write matrix in CI on every PR that touches a large file. If a new method starts writing to an attribute that three other clusters read, that's a smell worth flagging early — before you end up with another 3,200-line monster.

Wrap-up

God classes don't need a heroic rewrite. They need pinned behavior, boundaries a human chose, and a lot of small, boring, reviewable steps — which is exactly the kind of work an AI coding agent is great at, as long as you keep the steering wheel.

If you've got your own monster class, I'd love to hear how you're approaching it — drop it in the comments. 👇

Follow me here on Dev.to for more build logs on working with Claude Code and AI coding agents on real, messy codebases. And if you haven't tried Claude Code on a refactor yet, start with the characterization-test prompt above on your scariest file — it's the safest first step you can take.

Top comments (0)