Lessons from rewriting tangled controllers without freezing the team or breaking production.
The scariest part of refactoring a legacy controller is usually not the code. It's the parts of the business that nobody documented because "everyone knows how that works" — right up until you touch it and discover that no, actually, nobody knows how that works, including the person who wrote it six years ago.
I've been in a few of these refactors now, and the thing that separates the smooth ones from the ones that turn into multi-week firefights is almost never raw engineering ability. It's discipline about the order of operations.
Characterize before you change
Rule number one: do not touch the behavior until you've captured it.
That means writing characterization tests — tests that lock in what the current code actually does, including the weird edge cases, not what the docs claim it does. You run the old code, capture the outputs for a representative set of inputs, and pin them as assertions.
These tests aren't pretty. They often assert on things that are clearly wrong (there's a reason we're refactoring). That's the point. You need a tripwire that will go off the second you accidentally change user-visible behavior, so that when the legal team asks "did this migration affect invoice totals," you have an actual answer.
I've skipped this step exactly once. I won't do it again.
Separate policy from plumbing
Legacy controllers usually suffer from the same disease: business logic and HTTP concerns are tangled together. Validation, authorization, response shaping, feature flags, telemetry, and actual domain rules all live in the same six-hundred-line method.
The refactor I reach for first is pulling the business rules into a plain domain service that knows nothing about requests, responses, or frameworks. The controller becomes a thin adapter — parse input, call the service, shape the output. Once that seam exists, the domain becomes easy to test without spinning up the framework, and suddenly a lot of "impossible" test scenarios become three-line unit tests.
This isn't about clean architecture dogma. It's about making the part that matters independently verifiable.
Ship in vertical slices
The last hard-earned lesson: do not rewrite all ten controllers in one PR.
Pick one workflow. Refactor it. Deploy it. Watch production metrics for a week. Only then move to the next one. This is slower than a big-bang rewrite on paper, and faster in practice, because when something does regress — and something will — you can narrow the cause to a single slice instead of auditing five thousand lines of diff.
Small steps let you keep shipping other features in parallel. Big-bang rewrites freeze the team. I've been on the frozen-team side of that trade, and nobody enjoys it.
Patience is the whole job
Refactoring legacy code is mostly an exercise in patience. The code got this way because of a hundred small decisions, made by real people solving real problems, often under real pressure. Replacing it requires the same kind of care — probably more — to avoid reintroducing the same problems in a prettier wrapper.
Go slow. Write the tests. Ship the slice. Your future self, and your on-call rotation, will thank you.