Refactoring Without Breaking Things
Refactoring has a definition: changing the internal structure of code without changing its observable behavior. That last part is the part most people skip.
“I refactored it” frequently means “I rewrote it, changed some behavior along the way, and now there’s a bug I didn’t expect.” That’s not refactoring. That’s editing. The difference matters because editing without a safety net is how regressions happen, and how “quick cleanups” turn into multi-day debugging sessions.
Safe refactoring is a skill with a method. The method is: have tests, make small moves, verify after each one.
The prerequisite: tests
You cannot safely refactor code you can’t verify. Tests are the only thing that tells you whether your structural change preserved behavior. Without them, every refactoring is guesswork.
Before touching anything, ask: if I break the behavior of this code, will something fail? If the answer is no, add tests first. Not because you’re planning to break it, but because you need the confidence that you haven’t. The tests don’t need to cover every edge case - they need to cover the behavior you’re about to rearrange.
This is also why “I’ll add tests after the refactoring” is backwards. The tests are the before state. They’re what you’re preserving.
Small, reversible steps
The cardinal rule of refactoring is that each step should leave the code in a working state. Not “working except for this thing I’m in the middle of” - actually working. Tests pass. The application runs.
This sounds slow. It’s the opposite. Small steps mean each change is easy to verify, easy to understand, and easy to revert if something goes wrong. Large, sweeping changes are fast to write and slow to debug. The developer who does it in five careful commits rarely gets stuck. The one who rewrites a module over two days in a single branch regularly does.
A few moves are fundamental enough to appear in almost every codebase. They’re worth knowing by name.
Extract function
The most common refactoring: take a block of code and pull it into a named function. The name becomes documentation.
// Before
function processOrder(order: Order) {
const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * 0.2;
const shipping = order.items.length > 5 ? 0 : 5.99;
const total = subtotal + tax + shipping;
sendConfirmationEmail(order.customerId, total);
updateInventory(order.items);
}
// After
function calculateTotal(order: Order): number {
const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * 0.2;
const shipping = order.items.length > 5 ? 0 : 5.99;
return subtotal + tax + shipping;
}
function processOrder(order: Order) {
const total = calculateTotal(order);
sendConfirmationEmail(order.customerId, total);
updateInventory(order.items);
}
The behavior is identical. calculateTotal is now independently testable. processOrder is now readable at a glance.
The signal that an extract is due: you’re writing a comment to explain what a block of code does. The comment should be the function name instead.
Rename
Renaming is the cheapest, highest-leverage refactoring there is. A function called handleData that is actually parseUserProfile misleads everyone who reads it. Modern editors make renames mechanical - let them.
Rename when a name no longer matches the thing it names, when you understand a concept better than you did when you named it, or when you’ve seen three people confused by the same name in code review.
Inline
The inverse of extract. If a function is so small that reading it inline is clearer than jumping to its definition, inline it. Abstractions have a cost - the cognitive jump to another function - and that cost has to earn its keep.
// Before: unnecessary indirection
function isEven(n: number): boolean {
return n % 2 === 0;
}
if (isEven(count)) { ... }
// After: inline when the expression is already clear
if (count % 2 === 0) { ... }
Move
When a function uses more data from another class than its own, it belongs in the other class. When a module has grown to contain things that aren’t really related, split it. Moving is the refactoring that improves structural design rather than local readability.
It’s also the riskiest of the small moves, because it changes how things are found. Make sure imports update correctly, and make sure nothing else depended on the old location.
When to refactor
The best time to refactor is when you’re already working in the area. You’ve opened a file to add a feature or fix a bug. You see something that makes the work harder. Clean it up before you start, or as you go - not as a separate project.
The worst time to refactor is a dedicated refactoring sprint with no concrete goal. “Improve the codebase” is not a goal. It doesn’t have success criteria, it doesn’t have boundaries, and it’s easy to drift from refactoring (structure only) into editing (behavior too). Refactoring sprints frequently produce code that’s different, not better, and introduce regressions in code that was working.
Refactor when:
- You’re adding a feature and the current structure makes it harder than it should be
- You’re fixing a bug and the code is too tangled to be confident in the fix
- You’re reading code and you have to hold too much in your head to understand it
Don’t refactor when:
- You’re near a deadline and the code works
- You don’t have tests and can’t quickly add them
- The scope is unclear and keeps expanding
The refactoring trap
There’s a version of “refactoring” that’s really procrastination - cleaning up code instead of writing the hard feature, improving structure instead of solving the difficult problem. It feels productive because you’re making changes, but nothing ships.
The test: would a product manager notice? Legitimate refactoring makes future features faster. Refactoring as avoidance just moves things around.
Refactoring is a means to an end. The end is software that’s easier to change. If a refactoring doesn’t serve that - if you’re renaming things because they’re not named the way you would have named them, or reorganizing code whose current organization is perfectly workable - it’s not worth doing.
Clean code is not the goal. Useful code that can be maintained and extended is the goal. Clean code is one way to get there.