Module Refactor Framework
A step-by-step framework for sequencing a module refactor safely and knowing what to test at each stage.
The core idea is to separate behavior-preserving changes from behavior-changing ones, make each step small and reversible, and build the safety net before touching anything.
Stage 0: Define the goal and boundaries
Write down why you’re refactoring (testability, performance, coupling, readability), what’s in scope, and what “done” looks like. Decide explicitly whether behavior is allowed to change. If it is, plan that as a separate phase after the structural work.
Test: nothing yet, but agree on success metrics (e.g. latency, test coverage, number of consumers on the new API).
Stage 1: Map the territory
- List the module’s public surface: exported functions, classes, types, events, config, side effects.
- Find every consumer (static search, call graphs, runtime logs/tracing for dynamic usage).
- Identify hidden contracts: ordering assumptions, error types, mutation of inputs, caching, timing, global state.
- Note the module’s own dependencies (DB, network, clock, filesystem).
Test: none, but this map determines what the next stage must pin down.
Stage 2: Build the safety net
Before changing structure, lock in current behavior.
- Characterization tests: call the existing code with representative and edge-case inputs and assert whatever it currently returns, bugs included. You’re recording behavior, not judging it.
- Contract/integration tests at the module boundary, since these survive internal rewrites.
- Golden-master or snapshot tests for complex outputs.
- Fill coverage gaps on the risky paths: error handling, retries, concurrency, null/empty inputs.
- Check test quality by mutating the code or deliberately breaking something and confirming tests fail.
Gate: tests are green and reliable (no flakiness) on the unmodified code. Don’t proceed otherwise.
Stage 3: Create seams
Make the code changeable without changing what it does.
- Wrap external dependencies behind interfaces (clock, I/O, network).
- Extract pure logic from side-effecting code.
- Introduce a facade or adapter in front of the module so consumers depend on a stable entry point.
Test: the full characterization suite after every individual extraction. These are pure refactors, so no test should change. If one does, you’ve changed behavior, so stop and reassess.
Stage 4: Restructure internally in small steps
Use mechanical, well-understood moves: rename, extract function, move function, inline, split class. Rules of thumb:
- One kind of change per commit (never mix a move with a logic change).
- Every commit builds and passes tests.
- Keep commits revertible independently.
- Order from leaves inward: low-dependency pieces first, high-fan-in pieces last.
Test: the fast unit and contract suites on every commit. Run the slower integration suite at least at the end of each logical group. Add new unit tests for newly extracted pieces as they appear.
Stage 5: Introduce the new design in parallel (expand)
If the public API or data model is changing, don’t swap in place. Use the expand → migrate → contract pattern:
- Expand: add the new API/implementation alongside the old one. The old one can delegate to the new, or vice versa.
- Migrate: move consumers over incrementally.
- Contract: delete the old path once nothing uses it.
For high-risk logic, consider shadow mode: run both implementations, serve the old result, and log any differences.
Get Bolaji Adesina’s stories in your inbox
Join Medium for free to get updates from this writer.
Test:
- Equivalence tests: run the same inputs through old and new and compare outputs.
- Compatibility tests for any data format, schema, or serialized state the two versions share.
- For data migrations, verify both forward and backward compatibility so you can roll back.
Stage 6: Migrate consumers
Move callers in small batches, ordered by risk: internal/low-traffic first, critical paths last.
- Gate behind a feature flag or config switch where feasible so rollback is a toggle, not a deploy.
- Roll out progressively (canary → percentage → full).
Test:
- Each consumer’s own test suite after its migration.
- End-to-end tests on the critical user flows.
- In production, watch error rates, latency, resource usage, and business metrics, and compare the old and new paths side by side.
Stage 7: Contract and clean up
- Remove the old implementation, flags, adapters, and shims.
- Delete characterization tests that only pinned legacy quirks you no longer support, and replace them with intentional behavior tests.
- Update docs, ownership, and dependency diagrams.
Test: full suite, plus a search confirming zero remaining references to the removed code. Wait through a full cycle of your riskiest workload (month-end jobs, weekly batch runs) before deleting the rollback path.








