In this guide
On this page ⌄
- Key takeaways
- Before you start: map the blast radius
- Pin behavior with characterization tests
- Pattern 1: Branch by abstraction
- Pattern 2: Strangler fig
- Pattern 3: Expand and contract for interfaces
- Pattern 4: Feature flags for the switch
- Pattern 5: Shadow and parallel runs
- Verifying each step
- Planning a refactor a busy team can survive
- Refactoring with coding agents
Refactoring a small project is a matter of taste and an afternoon. Refactoring a large codebase is a project management problem disguised as a coding problem. The code you want to change is imported by hundreds of files, owned by several teams, exercised by production traffic you cannot fully reproduce, and edited by other people every day while you work. A long-lived branch that rewrites it will drift, conflict, and eventually land as a single enormous change that nobody can review.
The approaches that work at scale share one idea: never be in a state where the system is half-refactored and broken. Each step ships to main, keeps behavior identical or deliberately changed, and can be undone. This guide covers the main patterns (strangler fig, branch by abstraction, parallel change, feature flags and shadow runs), how to verify each step, and how to plan a refactor so it survives contact with a busy team.
Key takeaways
- Do impact analysis first. Know every caller and consumer of what you plan to change before you write code.
- Pin current behavior with characterization tests before changing structure, especially in legacy code.
- Prefer many small merges to main over one long-lived branch. Each merge should leave the system working.
- Use branch by abstraction for internal components, strangler fig for whole subsystems or services, and expand-contract for interfaces and schemas.
- Put risky switches behind flags so rollout and rollback are configuration changes, not deploys.
- Verify with tests, parallel runs and production metrics, and define in advance what "safe to remove the old path" means.
Before you start: map the blast radius
The most common refactoring failure is surprise: a caller nobody knew about, a cron job that imports a private function, a downstream service that parses a field you renamed. Spend the first day finding those.
# Text references, excluding tests
git grep -n "LegacyPricing" -- ':!**/*.test.*' ':!**/__tests__/**'
# Who imports the module (TypeScript example)
npx madge --depends src/pricing/legacy.ts src/
# Go: which packages depend on this one
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | grep 'internal/pricing'
Also check outside the repository: other services that call this API, analytics jobs that read this table, mobile clients pinned to old response shapes. Write the list down. It becomes your checklist for the end of the refactor, when you need to prove nothing still uses the old path. The full method is in impact analysis before refactoring.
Pin behavior with characterization tests
A characterization test records what the code currently does, not what it should do. It is how you make sure a structural change has not altered behavior by accident.
# tests/test_pricing_characterization.py
import json, pathlib
from pricing.legacy import quote
CASES = json.loads(pathlib.Path('tests/fixtures/pricing_cases.json').read_text())
def test_quotes_match_recorded_outputs():
for case in CASES:
assert quote(**case['input']) == case['expected'], case['name']
Generate the fixture file by running the current code over a wide set of inputs, ideally sampled from real (anonymized) production requests. If the current output includes a bug, keep it in the fixture and mark it. Fixing bugs and restructuring code in the same step is how refactors become impossible to verify. Fix the bug in a separate, clearly labeled change.
Pattern 1: Branch by abstraction
Branch by abstraction lets you replace an internal component while everyone keeps merging to main. Despite the name, it involves no version control branch.
- Introduce an abstraction in front of the existing implementation. All callers go through it. Merge.
- Move callers onto the abstraction, a batch at a time. Merge each batch.
- Build the new implementation behind the same abstraction. Merge it, unused.
- Switch the abstraction to the new implementation, ideally behind a flag. Merge.
- Delete the old implementation once you are confident. Merge.
// Step 1: the abstraction
export interface PriceCalculator {
quote(input: QuoteInput): Promise<Quote>
}
// Existing code wrapped, behavior unchanged
export class LegacyPriceCalculator implements PriceCalculator {
quote(input: QuoteInput) {
return legacyQuote(input)
}
}
// Step 4: the switch
export function priceCalculator(flags: Flags): PriceCalculator {
return flags.isOn('pricing.v2') ? new PriceCalculatorV2() : new LegacyPriceCalculator()
}
Every step is independently reviewable and reversible. At any point you can stop and the codebase is in a coherent state.
Pattern 2: Strangler fig
The strangler fig pattern, named by Martin Fowler after a vine that gradually replaces its host tree, applies the same idea to a whole subsystem or service. You put a routing layer in front of the old system and move functionality to the new one route by route.
# Route migrated endpoints to the new service, everything else to the monolith
location /api/invoices/ {
proxy_pass http://billing-v2;
}
location / {
proxy_pass http://monolith;
}
The routing layer can be a reverse proxy, an API gateway, or a dispatch function inside the application. The key properties are that each route moves independently, moving back is a configuration change, and the old system keeps running untouched until its last route is gone. Start with low-risk, read-only endpoints to prove the plumbing, then work toward the critical paths.
Pattern 3: Expand and contract for interfaces
When the thing you are changing is an interface (a function signature, an API field, a database column), use expand and contract, also called parallel change.
- Expand: add the new form alongside the old. Write to both if it is data.
- Migrate: move consumers to the new form, one at a time.
- Contract: remove the old form once nothing uses it.
For a database column rename, that looks like:
-- Expand
ALTER TABLE orders ADD COLUMN customer_ref text;
-- Application writes both customer_id and customer_ref, reads customer_id
-- Backfill in batches
UPDATE orders SET customer_ref = customer_id::text
WHERE customer_ref IS NULL AND id BETWEEN 1 AND 100000;
-- Switch reads to customer_ref (deploy), keep dual writes
-- Contract, after verifying no reads of customer_id remain
ALTER TABLE orders DROP COLUMN customer_id;
Each phase is a separate deploy. During rollout, old and new application versions run side by side, so every intermediate schema must work with both.
Pattern 4: Feature flags for the switch
Flags turn the riskiest moment of a refactor, the switch from old to new, into something you can do gradually and undo in seconds.
- Roll out to internal users first, then a small percentage of traffic, then everyone.
- Use a kill switch that defaults to the old path if the flag service is unavailable.
- Log which path served each request so you can compare error rates per path.
- Give each refactor flag an owner and a removal date. Old flags are debt, and a flag nobody remembers is a branch nobody tests.
Pattern 5: Shadow and parallel runs
For logic where correctness is critical and hard to test exhaustively, such as pricing, permissions or financial calculations, run both implementations and compare.
async function quote(input: QuoteInput): Promise<Quote> {
const legacy = await legacyCalc.quote(input)
if (flags.isOn('pricing.v2.shadow')) {
v2Calc.quote(input)
.then((v2) => {
if (!deepEqual(legacy, v2)) metrics.increment('pricing.v2.mismatch')
if (!deepEqual(legacy, v2)) log.warn('pricing mismatch', { input, legacy, v2 })
})
.catch((err) => log.error('pricing v2 shadow failed', { err }))
}
return legacy
}
The user always gets the legacy result. The new path runs in the background, and mismatches are logged for investigation. Only side-effect-free code should be shadowed this way; never shadow code that sends emails, charges cards or writes to shared state. When the mismatch rate is zero (or every remaining mismatch is a known, intended bug fix) across representative traffic, you have strong evidence the switch is safe.
Verifying each step
Decide the verification for each step before you write it. A useful checklist:
- Characterization tests pass unchanged.
- Type checker and linter pass across the whole repository, not only the touched package.
- The full list of callers from your impact analysis has been checked off.
- Dashboards for error rate and latency on the affected paths show no change after deploy.
- Shadow mismatch rate is zero or fully explained.
- Rollback has been tested at least once, by actually flipping the flag back.
For the final deletion, require positive evidence that the old path is unused: zero traffic on the old route for an agreed period, no references in the codebase, and no calls in logs. The dead code guide covers how to gather that evidence.
Planning a refactor a busy team can survive
Technical patterns are half the job. The other half is keeping the refactor alive while features keep shipping.
- Write a one-page plan listing the steps, each with its verification and rollback. Store it with the code or as an ADR.
- Make every step small enough to review in one sitting. A reviewer who cannot hold the change in their head will approve it without understanding it.
- Announce migration rules. Once the abstraction exists, add a lint rule that blocks new imports of the old implementation so the migration does not move backward.
- Track progress visibly, for example the count of remaining callers of the old API, so the team can see the end approaching.
- Timebox the cleanup. The last ten percent of a migration is where refactors stall. Put the deletion step on the calendar.
A lint rule for that third point, using ESLint:
{
"rules": {
"no-restricted-imports": ["error", {
"paths": [{ "name": "src/pricing/legacy", "message": "Use PriceCalculator from src/pricing instead." }]
}]
}
}
Refactoring with coding agents
Agents are well suited to the mechanical middle of these patterns: moving a batch of callers onto a new abstraction, updating imports, writing characterization fixtures. They are poorly suited to deciding the plan or judging when it is safe to switch. Give them one step at a time, with the verification attached, and review each result. Prompting coding agents for refactors covers task framing in detail. Agents also need the same blast radius information you do; OpenVisio exposes it over MCP with tools such as get_dependents and trace_calls, so an agent can check callers before editing instead of guessing from one file.
Safe refactoring in a large codebase is not about courage or a heroic weekend. It is about never leaving main broken, keeping every step reversible, and collecting evidence before each switch.

