Refactoring strategy: characterisation tests, strangler fig, branch by abstraction, parallel change, feature flags, small pull requests, measuring progress, and when to stop.
I treat a large refactoring as a series of small, behaviour-preserving steps merged to main continuously, not a big-bang branch. First I pin current behaviour with characterisation tests. Then I pick a technique: strangler fig to route traffic piece by piece to a new implementation, branch by abstraction to put an interface in front of the old code and swap implementations behind it, or parallel change (expand, migrate, contract) for APIs and database schemas. Feature flags and shadow comparisons let old and new paths run side by side. I prioritise hotspots (code that changes often and hurts), keep refactoring commits separate from feature commits, and define an end state so it does not drag on forever.
"I treat a large refactoring as many small, behaviour-preserving steps merged to main continuously, never a long-lived branch or a feature freeze. First I choose where: hotspots, meaning code that changes often and causes pain (Git churn Γ complexity, incident history), because refactoring stable code returns little. I define done: target structure, measures of success, and what's explicitly out of scope. Then I build a safety net: characterisation tests that pin current behaviour (approval/snapshot tests built from production samples) and contract tests for APIs. I pick the technique by scope. Branch by abstraction puts an interface in front of the old component and swaps implementations behind it. Strangler fig routes traffic piece by piece from a legacy app to new code, often through a YARP reverse proxy. Parallel change (expand β migrate β contract) handles APIs, database columns and message schemas. Old and new paths run side by side behind feature flags, with shadow comparisons logging any differences before we switch. Refactoring PRs stay separate from feature PRs, progress is visible, and the work ends when the old path and its flag are deleted."
| Step | What I do |
|---|---|
| 1. Why and where | Hotspot analysis (churn Γ complexity), incidents, lead time for changes in that area |
| 2. Define done | Target structure, success measures (defects, lead time, build time), explicit non-goals |
| 3. Safety net | Characterisation/approval tests (e.g. Verify), contract tests, observability on the area |
| 4. Choose technique | Branch by abstraction / strangler fig / parallel change / Mikado |
| 5. Run side by side | Feature flags (Microsoft.FeatureManagement), shadow comparison, gradual rollout |
| 6. Small PRs | Merged daily; refactoring separate from behaviour changes |
| 7. Switch and delete | Remove old path and flag once proven; update docs/ADRs |
| 8. Measure | Compare before/after metrics; stop when the pain is gone |
| Scope | Technique | How |
|---|---|---|
| Method/class | IDE refactorings + unit tests | Extract, move, rename in small commits |
| Internal component | Branch by abstraction | Interface in front of old code β new implementation behind it β flag β switch β delete old |
| Public API, DB column, message field | Parallel change (expand/contract) | Add new alongside old β migrate consumers/data β remove old later |
| Whole legacy app/subsystem | Strangler fig | Proxy (YARP) routes endpoints one by one to the new system |
| Tangled change with unclear order | Mikado method | Try the goal, note what breaks, revert, solve prerequisites first |
| Practice | Why |
|---|---|
| Reserve a steady share of capacity (e.g. [20%]) | Avoids a separate "refactoring project" that gets cancelled |
| Trunk-based development + flags | No merge hell; everything always shippable |
| Feature work routed through the new structure first | Each feature advances the migration |
| Visible progress (endpoints migrated, % traffic) | Keeps stakeholder support |
| Clear exit criteria | Prevents endless "two implementations" limbo |
Our [12-year-old] pricing engine lived in a [4,000-line] class: every pricing change took [weeks] and regularly caused regressions, and Git hotspot analysis showed it was both the most-changed and most-defective file. We captured [about 600] real pricing requests from production as approval tests, introduced IPriceCalculator in front of the old class (branch by abstraction), and built a rules-based calculator behind a feature flag. A shadow decorator returned the legacy result while running the new one and logging any mismatch. We fixed differences as they appeared, ramped shadow traffic to [100%], and after [three weeks] with zero mismatches switched over and deleted the old class and the flag. Feature work continued throughout: new pricing rules were built only in the new engine, which pulled the migration forward.
Checks ability to change production systems safely at scale.
Green flags
Red flags
| Technique | Source |
|---|---|
| Refactoring (behaviour-preserving changes) | Martin Fowler, Refactoring |
| Characterisation tests, seams | Michael Feathers, Working Effectively with Legacy Code |
| Strangler fig | Martin Fowler (2004) |
| Branch by abstraction | Paul Hammant; Jez Humble |
| Parallel change (expand/contract) | Danilo Sato / Fowler bliki |
| Mikado method | Ola EllnestΓ₯m & Daniel Brolund |
| Hotspots (churn Γ complexity) | Adam Tornhill, Your Code as a Crime Scene |
# Most frequently changed files in the last year
git log --since="12 months ago" --format=format: --name-only | grep -v '^$' | sort | uniq -c | sort -rn | head -20
Combine with size/complexity (lines, cyclomatic complexity) and defect history. High churn + high complexity + many bugs = refactor first. Tools like CodeScene automate this.
| Phase | Change | Deploys |
|---|---|---|
| Expand | Add DeliveryState column; write to both old and new |
1 |
| Migrate | Backfill; switch reads to the new column | 2 |
| Contract | Stop writing the old column | 3 |
| Clean up | Drop the old column (after a safe period) | 4 |
Every deploy is backward compatible, so rollback is always possible. The same pattern works for API fields and message schemas.
{
"ReverseProxy": {
"Routes": {
"orders": { "ClusterId": "new", "Match": { "Path": "/orders/{**rest}" } },
"legacy": { "ClusterId": "legacy", "Match": { "Path": "{**catch-all}" }, "Order": 1000 }
},
"Clusters": {
"new": { "Destinations": { "d1": { "Address": "https://orders-new.internal/" } } },
"legacy": { "Destinations": { "d1": { "Address": "https://legacy.internal/" } } }
}
}
}
Routes move to the new cluster one at a time; the catch-all keeps everything else on the legacy app until it can be retired. Microsoft's migration guidance for ASP.NET Framework β ASP.NET Core uses this approach (with System.Web adapters for shared session/auth).
// Branch by abstraction with a shadow comparison.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
IPriceCalculator calculator = new ShadowPriceCalculator(
primary: new LegacyPriceCalculator(),
candidate: new RulesPriceCalculator(),
logger: NullLogger<ShadowPriceCalculator>.Instance);
Console.WriteLine(calculator.Price(new PriceRequest("SKU-1", 3, "GOLD")));
public sealed record PriceRequest(string Sku, int Quantity, string Tier);
public interface IPriceCalculator { decimal Price(PriceRequest request); }
public sealed class LegacyPriceCalculator : IPriceCalculator
{
public decimal Price(PriceRequest r) => r.Quantity * 10m * (r.Tier == "GOLD" ? 0.9m : 1m);
}
public sealed class RulesPriceCalculator : IPriceCalculator
{
private static readonly Dictionary<string, decimal> TierDiscount = new() { ["GOLD"] = 0.10m };
public decimal Price(PriceRequest r) =>
r.Quantity * 10m * (1 - TierDiscount.GetValueOrDefault(r.Tier));
}
// Returns the legacy result (behaviour unchanged) and logs any difference.
public sealed class ShadowPriceCalculator(
IPriceCalculator primary, IPriceCalculator candidate, ILogger<ShadowPriceCalculator> logger) : IPriceCalculator
{
public decimal Price(PriceRequest request)
{
var expected = primary.Price(request);
try
{
var actual = candidate.Price(request);
if (actual != expected)
logger.LogWarning("Price mismatch for {Sku}: legacy {Legacy}, new {New}", request.Sku, expected, actual);
}
catch (Exception ex)
{
logger.LogError(ex, "New calculator failed for {Sku}", request.Sku);
}
return expected;
}
}
Requires Microsoft.Extensions.Logging.Abstractions. In production, register the shadow decorator behind a feature flag and sample traffic if the candidate is expensive.
What happens: a branch diverges for weeks or months. Why it is wrong: painful merges; the work is often abandoned. Do instead: merge small steps daily using branch by abstraction and flags.
What happens: "we'll test it at the end". Why it is wrong: you can't tell whether behaviour changed. Do instead: characterisation tests first.
What happens: a refactoring PR also fixes bugs and adds features. Why it is wrong: reviewers can't verify either; rollbacks remove both. Do instead: separate refactoring and feature PRs.
What happens: effort spent on stable, ugly code. Why it is wrong: little return; risk without benefit. Do instead: target hotspots.
What happens: both implementations and the flag remain for months. Why it is wrong: double maintenance; confusing behaviour. Do instead: exit criteria; delete old code and flags.
What happens: "no features for a quarter while we clean up". Why it is wrong: loses business support; big-bang risk. Do instead: steady capacity share and features built on the new path.
What happens: column renamed and code updated in the same release. Why it is wrong: rollbacks or rolling deploys break. Do instead: expand/contract across several deploys.
What happens: the candidate path sends emails or charges cards. Why it is wrong: duplicate side effects in production. Do instead: make the shadow path side-effect free or compare only computed outputs.
What happens: characterisation tests reveal a bug and it's "fixed" during the refactor. Why it is wrong: behaviour change hidden in a refactoring; surprises consumers. Do instead: log it, decide separately, fix in its own change.
What happens: the refactoring keeps expanding in scope. Why it is wrong: never finishes; trust erodes. Do instead: target structure, success metrics and non-goals agreed upfront.
Large refactorings fail from lost momentum and risk, not from technical difficulty. I make them boring: small steps, always shippable, measured progress and a clear finish line.
| Refactoring scope | Technique |
|---|---|
| Method or class | IDE refactorings + unit tests |
| Internal component | Branch by abstraction + shadow comparison |
| Public API or schema | Parallel change (expand/contract) |
| Whole legacy app | Strangler fig with YARP |
| Tangled dependencies | Mikado method to find the order |
Technical debt is the future cost of a shortcut, and the interest is the extra effort or risk it adds every time we work near it. I make it visible in a debt register with each item's impact, how often we pay its interest (change frequency, incidents, delays) and a rough cost to fix. I prioritise by interest, not by how ugly the code is: debt in a hotspot that slows every feature beats a messy module nobody touches. I get time by explaining it in business terms (lead time, incident risk, cost), by reserving a steady share of capacity, and by pairing debt fixes with features that touch the same area.
Evolutionary architecture (Ford, Parsons and Kua) is architecture designed to support guided, incremental change, rather than a fixed upfront design. The key tool is fitness functions: automated checks that protect the characteristics you care about, such as dependency rules (architecture tests), latency budgets, or startup time, run in CI or production. Around that, I keep boundaries modular (often a modular monolith first), make decisions reversible where possible and defer irreversible ones to the last responsible moment, record decisions in ADRs, and rely on continuous delivery, feature flags and observability so each change is small and safe.
Over-engineering is complexity that today's requirements do not justify. I detect it through symptoms with numbers behind them: a one-field change touching many files, interfaces with one implementation, configuration options nobody sets, patterns like CQRS or event sourcing without the problems they solve, and microservices that always deploy together. I reverse it incrementally rather than with a rewrite: inline single-use abstractions, merge layers that only pass data through, consolidate services that share a release cycle, and delete unused options, each step behind tests. I also address why it happened, because otherwise it grows back.
How to tell it (STAR)
Our pricing engine was a [4,000-line] class nobody wanted to touch. I captured [600] real pricing cases as tests, put an interface in front of the old code, and ran a new calculator alongside it in shadow mode, logging every difference. After [three] weeks with no differences we switched over and deleted the old code, without pausing feature work.
Last updated 31 Oct 2026.