Every codebase eventually has legacy code — usually code you wrote two years ago that you no longer recognize. The 'rewrite from scratch' impulse is almost always wrong. The 'leave it alone' impulse is also wrong. Incremental refactoring with safety nets is the work.
Don't rewrite from scratch
Joel Spolsky's classic argument is still right: the old code embodies thousands of bug fixes you don't remember. The new code will rediscover them. Rewrites take 2-5x longer than estimated and ship with familiar bugs newly reintroduced. Avoid unless the old code is structurally unmovable.
Characterization tests first
Before changing anything, write tests that capture the CURRENT behavior — even the buggy parts. Characterization tests are about preserving behavior, not asserting correctness. They're the safety net for the next steps.
Strangler fig pattern
New functionality goes in new code that intercepts calls to the old. Over time, the old code shrinks until it can be deleted. Martin Fowler's pattern; works for any modernization where 'big bang' would be risky. Routes between old/new based on flag or path.
Extract before refactor
Extract the messy code into a clean interface before refactoring its internals. Now you can change implementation freely. This is harder than it looks because the interface is fictional — you're imagining the right boundary.
Stop adding to the legacy
New features go in the new code. The legacy code is on a diet. This requires discipline — short-term it's faster to add to the legacy. Long-term it's the only way out. Document this rule explicitly; new joiners won't infer it.