If you’ve worked in a big codebase long enough, you know the move: this is just a quick change. Two commits later you’re three layers deep, staring at one method that somehow touches half the app, with no clear memory of how you got here.
I’ve been down that hole more times than I’d like to admit. What used to be panic is now closer to routine, not because large refactors got easier, but because I stopped improvising them. Below is the process I actually use to go deep without losing the plot.
1. Know the Terrain Before You Dig
Before touching code, I poke around the feature in its current form. Not just reading classes, I use it like a curious tester. I trigger every edge case I can think of, watch how the UI behaves, check what’s configurable, and note the little quirks that make it work.
The point isn’t to memorize every file. It’s to build a mental map of what the feature does before I start deciding how it should work. In a big system there’s almost always a hidden dependency, some config object, a global listener, a utility class three packages away, that will surprise you later if you skip this step. The half-day I spend here is the cheapest insurance I buy on the whole project.
2. Break Stuff on Purpose
Once I have a sense of how it’s supposed to work, I stress-test it. Wrong inputs. Missing data. Methods called in a weird order. I want to find the edges where it creaks or falls over. Sometimes that surfaces defensive checks already in place. Other times it exposes a hole nobody knew was there.
This step is pure gold because it tells me which behaviors are intentional and which are just accidents of implementation. That distinction is the whole game in a refactor: you’re trying to preserve the first kind and free to change the second, and the only way to tell them apart is to go looking before you start.
3. Make a Plan, and Expect to Revise It
With enough notes in hand, I outline an action plan and rank the changes from “low risk” to “might take down everything.” Then I do dry runs of the first few steps in a branch to see if the plan holds up. Spoiler: it rarely does. But that’s the point. The early run surfaces the surprises while I’m still cheap to redirect, not after I’ve built three days of work on a wrong assumption.
The plan isn’t a script I follow to the letter. It’s a direction, so that when things change, and they will, I can reorient instead of starting over.
4. Change in Small, Safe Steps
This is where discipline earns its keep. I avoid the big-bang change and chip away in small increments, verifying as I go. I’ll add assertions on the critical paths to catch my own mistakes early, while the change that caused them is still fresh and small enough to find.
I also hold the line against the urge to clean up unrelated things while I’m in there, unless they’re genuinely in the way of the goal. That’s its own trap, and I’ve written about it separately in Leave It Better Than You Found It. A large refactor is exactly the situation where “one thing led to another” does the most damage, because you’re already touching everything and every detour feels justified.
The aim is momentum without chaos. Small, verified steps keep you moving. Giant rewrites bury you under a diff nobody, including you, can fully reason about.
5. Decide When You’re Done
In a large codebase there is always something else you could improve. The skill is stopping when you’ve hit the goal you started with, not when the system finally feels clean, because it never will. Keep going past the goal and you’ll lose weeks to unrelated corners of the codebase “while you’re already in there.”
So I check progress against the original objective, often, and out loud if I have to. If I’ve hit it, I wrap up, even knowing there’s more polish on the table. This is just Parkinson’s Law in refactor form: the work expands to fill the space you give it, and a big refactor gives it an enormous amount of space. A defined finish line is the constraint that keeps the project from quietly becoming a different, larger one.
Where This Breaks
The process has failure modes too, and most of them are me overdoing one of the steps.
The exploring in steps 1 and 2 can quietly turn into procrastination. At some point “understanding the terrain” becomes a way to avoid the part where you commit to a change and risk being wrong. I time-box it now. If I can’t start forming a plan after a set amount of poking around, I’m probably stalling, not learning.
Small steps aren’t free either. A refactor branch that lives for three weeks is a branch fighting everyone else’s changes the whole time, and the merge at the end can cost more than the refactor did. Sometimes the honest move is a faster, larger cut, or landing the work in mainline in sequence behind a flag, rather than nursing a long-lived branch out of pure caution.
And sometimes the right call is not to refactor at all. If the code works and I don’t fully understand it, the cheapest improvement is often to leave it alone and write a test that pins the current behavior. That buys the next person, possibly me, the safety to change it later, without me guessing at intent today and breaking something subtle.
The Difference
Big refactors used to feel like something I survived. The shift wasn’t learning to refactor faster. It was treating one like an exploration with a map, a plan, and a finish line I defined before I started, instead of a thing I’d somehow know was done when it felt done. The map keeps you oriented. The finish line is what gets you back out.
So before the next “quick change” pulls you under, decide the one thing that has to be true for you to call it finished, and write it down. Everything you do after you’ve hit that line is a different project, whether you admit it or not.