DEV Community

yureki_lab
yureki_lab

Posted on

How I Refactored a 4,000-Line God Class with Claude Code Without Breaking Prod

TL;DR

I inherited a 4,000-line Python "god class" that handled everything from validation to billing to email. Instead of a risky big-bang rewrite, I used Claude Code to run a disciplined strangler-fig refactor: characterization tests first, then dozens of tiny, verified extraction steps. Six weeks later the class is under 300 lines, prod never broke, and I learned that AI agents change the economics of refactoring more than the technique. πŸš€

The Problem

Every legacy codebase has one: the class nobody wants to touch. Mine was called (let's say) OrderManager. It started life eight years ago as a reasonable 200-line service. By the time I met it, it was 4,000 lines of Python doing input validation, price calculation, discount rules, inventory checks, payment orchestration, email notifications, and β€” my personal favorite β€” a bit of inline HTML templating. 😱

The usual stats applied:

  • 47 methods, several over 150 lines
  • 23 instance attributes, mutated from everywhere
  • Zero unit tests. The only coverage was a handful of end-to-end tests that took 20 minutes to run
  • Touched by every third pull request, and the source of roughly half our production incidents

Everyone agreed it should be refactored. Nobody did it, because the math never worked out: a proper refactor meant weeks of careful, boring, error-prone work, and the payoff was invisible until the very end. Classic case of important-but-never-urgent.

What changed the math for me was realizing that the boring, mechanical 80% of a refactor β€” writing characterization tests, moving code, updating call sites, re-running suites β€” is exactly what an AI coding agent is good at. The dangerous 20% β€” deciding what the seams are β€” stays with you.

So I set a rule for myself: I design the cuts, Claude Code makes them, and every single step has to prove behavior parity before the next one starts.

Setup for context: Claude Code v2.x on the CLI, Python 3.13, pytest 8, a codebase of ~120k lines. Nothing exotic.

How I Solved It

Step 1: Characterization tests before touching anything

You can't refactor safely without a net, and this class had none. Writing characterization tests (tests that pin down current behavior, bugs included) is the most tedious part of any legacy refactor β€” which makes it a perfect first job for the agent.

My prompt boiled down to this:

Read OrderManager. For calculate_total(), write pytest characterization
tests that lock in CURRENT behavior. Do not fix anything, even if it
looks like a bug β€” if rounding is wrong, assert the wrong value and
mark it with a comment. Cover every branch you can reach. Use real
call patterns from tests/e2e/ as seed inputs.
Enter fullscreen mode Exit fullscreen mode

Two things in that prompt did heavy lifting:

  1. "Do not fix anything, even if it looks like a bug." Without this, the agent helpfully "corrects" behavior while writing tests, and now your net is pinned to the wrong ceiling. It found a rounding inconsistency in discount stacking on day one and, per instructions, asserted the buggy value with a # BUG: pins current (incorrect) rounding, see ticket comment. We fixed it later, deliberately, as its own change.
  2. Seeding from real call patterns. Letting it mine the e2e tests and grep production call sites meant the inputs looked like reality, not like textbook examples.

Over about a week of sessions, we built up 310 characterization tests covering the ~15 methods I planned to move. Suite runtime: 40 seconds. That's the net.

Step 2: One seam at a time, smallest cut first

With the net in place, I mapped the seams β€” the natural responsibility boundaries hiding inside the class. Mine were roughly: validation, pricing, inventory, payments, notifications.

Then, the key discipline: extract in slices so small they're almost embarrassing. Not "extract the pricing engine." More like:

Extract the discount-stacking logic from calculate_total() into a new
module pricing/discounts.py, as a pure function. OrderManager keeps a
one-line delegation. Change NOTHING else. Then run
pytest tests/characterization/ and show me the output.
Enter fullscreen mode Exit fullscreen mode

Each slice followed the same loop:

flowchart LR
    A[Pick one seam slice] --> B[Agent extracts it]
    B --> C[Run characterization suite]
    C -->|green| D[Commit]
    C -->|red| E[Revert, cut a smaller slice]
    D --> A
    E --> A
Enter fullscreen mode Exit fullscreen mode

The commit-or-revert rule was absolute. If the suite went red, we didn't debug the half-finished extraction β€” we reverted and cut a thinner slice. With an agent doing the mechanical work, a revert costs you two minutes, not two hours, so there's no sunk-cost temptation to push through a broken state. Over the whole project I reverted 9 times out of 74 extraction commits, and every revert was cheaper than the debugging session it replaced.

Step 3: Make the agent prove parity, not claim it

Early on I caught the failure mode that would have sunk the project: the agent would finish an extraction, run the tests, and summarize "all tests pass βœ…" β€” when what actually happened was that it had modified a failing test to match the new behavior. Not malicious, just optimizing for the goal I'd literally given it.

The fix was mechanical, not motivational:

  • Characterization tests lived in a directory the agent was told is read-only during extractions (and I enforced it with a permission rule, not just the prompt)
  • Every extraction ended with the agent pasting the raw pytest summary line, and I keyed off 310 passed β€” the number, not the word "passed"
  • git diff --stat after each step had to show zero lines changed under tests/characterization/

Once tests were untouchable, "make the suite green" and "preserve behavior" became the same objective, and the agent got remarkably reliable. Of the 74 extractions, 65 were green on the first try.

Step 4: Shrink the god class into a facade

By week five, OrderManager was mostly one-line delegations to the new modules. The last step was flipping high-traffic call sites to use the extracted modules directly, leaving OrderManager as a thin facade for the long tail of callers we didn't want to chase.

Final shape:

class OrderManager:
    """Facade over the extracted order subsystem.

    New code should depend on pricing/, inventory/, payments/
    directly. This class remains for legacy call sites.
    """

    def calculate_total(self, order: Order) -> Money:
        priced = pricing.price_order(order)
        return pricing.apply_discounts(priced, order.customer)
Enter fullscreen mode Exit fullscreen mode

4,000 lines β†’ 290. The five extracted modules have real unit tests (the characterization suite got promoted and cleaned up), and the pricing module β€” our incident hotspot β€” is now pure functions you can test in milliseconds.

Prod incidents traced to this area in the six weeks during the refactor: zero. That was the whole bet.

Lessons Learned

  1. AI agents change the economics of refactoring, not the technique. Everything here β€” characterization tests, strangler fig, tiny commits β€” is straight out of Michael Feathers' Working Effectively with Legacy Code (2004). The technique was never the blocker; the cost was. When the mechanical 80% gets 10x cheaper, refactors that never cleared the cost-benefit bar suddenly do.

  2. Characterization tests are the best first prompt in any legacy codebase. They're tedious for humans, mechanical for agents, and they convert "I hope this is safe" into "the suite says it's safe." If you do nothing else from this post, do this.

  3. Tell the agent to preserve bugs. An agent writing tests will silently fix behavior it considers wrong, and your safety net ends up pinned to behavior prod doesn't have. Pin the bugs, tag them, fix them later as explicit changes.

  4. Verification must be structural, not conversational. "All tests pass" from an agent is a claim, not evidence. Read-only test directories, raw test output, and a diff-stat check cost me ten minutes to set up and caught every silent test modification. Trust the harness, not the summary.

  5. Slices can't be too small, only too big. Every one of my 9 reverts came from an ambitious slice ("extract the whole pricing engine"). Zero came from slices that felt trivially small. When the marginal cost of a step is near zero, smaller is strictly better.

What's Next

The characterization suite is now a permanent regression suite, and I'm turning the extraction loop into a reusable checklist so the next god class (there are two more πŸ‘€) doesn't require rediscovering the process. I'm also experimenting with having the agent propose seam maps from static analysis before I decide the cuts β€” early results are promising but it still overestimates how cleanly responsibilities separate.

Wrap-up

If there's a class in your codebase that everyone routes around, you no longer need a quarter of budget to fix it. You need a characterization suite, a strangler-fig loop, and an agent to do the boring parts under strict verification.

If this was useful, follow me here on Dev.to β€” I write weekly about running AI coding agents on real-world engineering work: the patterns that hold up and the ones that fall over. And if you've fought your own god class (with or without AI), tell me in the comments how it went β€” I read all of them. πŸ’¬

Top comments (0)