Safe refactoring changes code structure while preserving intended observable behavior. Begin by identifying callers, side effects, data contracts, and operational constraints. Add characterization tests around important current behavior, including surprising cases that consumers rely on. Improve one boundary at a time, keep changes reversible, and compare outputs or production signals. Separate deliberate behavior changes so reviewers can evaluate them explicitly.
Who this is for: Developers changing valuable older code whose behavior, dependencies, and operational assumptions are only partly documented.
- Treat current code, tests, data, logs, callers, and user workflows as evidence when documentation is incomplete.
- Create a protective boundary and change one observable dimension at a time with focused verification.
- Distinguish preserved behavior from intentional correction, then remove temporary compatibility code after adoption.
Discover behavior before improving structure
Legacy code is code with insufficient confidence for the change being attempted, not simply code of a certain age or style. Trace entry points, callers, outputs, side effects, stored formats, schedules, configuration, and failure handling. Read incidents and support reports. Observe representative production inputs safely. A strange branch may encode a contract, workaround, migration state, or obsolete assumption, and those possibilities require different treatment.
Write an explicit behavior inventory. Separate product requirements from accidental implementation details and unresolved questions. Include empty and malformed input, ordering, rounding, timezone, retries, permissions, partial state, and downstream effects. When a current behavior appears wrong, do not silently change it inside a structural patch. Confirm the desired correction, document compatibility impact, and give it its own acceptance evidence.
Build a confidence boundary
Characterization tests record meaningful current outcomes without claiming that every outcome is ideal. Test through the narrowest stable entry point that captures the refactoring risk. Use real parsing or persistence in an integration test when those interactions matter. Golden files can help with large deterministic outputs, but review their important fields and avoid snapshots so broad that accidental changes disappear in noise.
Introduce a boundary around unstable dependencies such as time, files, database access, or a remote client when doing so makes behavior observable and controllable. Keep the interface shaped around what the application needs rather than copying a vendor API. A boundary is valuable when it supports the next small change; building a broad new framework before learning the code adds another unproven system.
Change in small reversible steps
Make transformations that reviewers can reason about: rename a concept, extract one pure decision, replace one dependency call, or route one caller through the boundary. Run focused tests after each meaningful step. Avoid combining formatting, dependency upgrades, schema changes, and business corrections in one large diff. Mechanical changes can be checked separately from semantic ones, reducing the places where behavior might have shifted.
For high-risk replacements, run old and new implementations on the same sampled input and compare normalized outputs without producing duplicate side effects. A compatibility adapter can move callers gradually. Feature flags may control exposure, but they add states and cannot reverse all writes. Define discrepancy handling, resource cost, privacy limits, and a date to remove comparison or adapter code.
Verify the system and finish the transition
A green unit suite does not prove that deployment configuration, stored data, concurrency, or external contracts remain correct. Run integration and end-to-end checks selected from the real blast radius. Compare logs, metrics, outputs, and performance where applicable. Roll out gradually when consequences justify it, with a stop condition and an understood return path. Watch rare errors that sampled characterization data may not contain.
Once callers use the new path and evidence remains stable, remove the old implementation, adapter, temporary flag, duplicate metrics, and obsolete tests. Update names and documentation to reflect the resulting model. A refactor is incomplete while two paths remain available without purpose. Record newly discovered invariants near their owning tests or specifications so the next change begins with stronger evidence.
Extract an invoice total calculator
A controller mixes database reads, tax rules, discounts, rounding, record updates, and email delivery in one long function.
- Inventory current invoice examples, currencies, rounding points, discount order, tax exceptions, stored totals, and notification behavior.
- Add integration characterization cases from representative sanitized records, including prior incidents and failure paths.
- Extract a pure calculation input and result while leaving persistence and messaging behavior unchanged around it.
- Run old and extracted calculations against sampled records, investigate every difference, and add justified cases to the suite.
- Move callers, monitor invoice adjustments, then remove the old calculation branch and temporary comparison instrumentation.
Legacy refactoring safety map
Complete this map before and during a behavior-preserving change.
- Behavior evidence: entry points, callers, examples, tests, incidents, logs, data formats, side effects, and open questions.
- Risk boundary: invariant, dependency, state, concurrency, failure path, external contract, and operational constraint.
- Protection: characterization unit, integration case, end-to-end journey, sampled comparison, metric, and manual proof.
- Steps: smallest transformation, reversible point, compatibility adapter, review scope, rollout, stop condition, and owner.
- Completion: callers moved, discrepancies resolved, old path removed, temporary controls deleted, documentation updated, and evidence retained.
Common mistakes
- Rewriting the whole module from inferred intent before documenting behavior that current callers depend on.
- Changing business rules inside a structural cleanup and making reviewers guess which output differences are intentional.
- Keeping adapters, duplicate implementations, and comparison logging permanently after every caller has migrated.
Try one
A nightly import script has no tests and occasionally creates duplicate customer records. Propose a safe refactoring sequence without silently redefining duplicate behavior.
A strong answer captures sanitized inputs and current outputs, traces scheduling, parsing, identity matching, database writes, retries, and partial failures, then adds integration characterization tests. It introduces a clear importer boundary and stable idempotency rule only after product confirmation. Changes proceed in small steps with sampled comparison, concurrency coverage, duplicate monitoring, rollback limits, and removal of the old path after verified adoption.
Sources
- GitHub test workflow guidanceGitHub's documented workflow for running repeatable Node.js tests in continuous integration.
- GitHub pull request review guidanceGitHub's documentation for review states, comments, and protected review workflows.
- TypeScript function handbookThe TypeScript handbook chapter on function types, overloads, unknown values, and assignability.