Eighteen months in, the rebuild is "about 70% done." The legacy system still serves every production request. The team maintains both, ships features into both, and carries a pager for both. Nobody can say what remains, because the remaining work is whatever behavior the old system has that the new one hasn't discovered yet.
This is the standard failure mode, and it is worth being precise about why it happens: the decision on when to rebuild vs refactor a .NET backend is almost always argued on the wrong axis. Teams debate code quality — how bad the codebase is, how much of it is untested, how far behind the runtime has fallen. Those are real problems and they are nearly irrelevant to this decision, because a bad codebase is fixable incrementally. What determines whether the incremental path exists at all is something else entirely, and it is rarely discussed in the meeting where the decision gets made.
This article gives you that missing axis: a test for whether an incremental path is available, four signals that genuinely justify a rewrite (and the four impostors they get confused with), the asymmetry that should govern the decision when the evidence is ambiguous, and the .NET implementation of the strangler path — including the routing mistake that quietly makes the old system permanent.
Why the Rebuild Debate Is Argued on the Wrong Axis
"This code is unmaintainable" is a statement about how the code reads. "We cannot change this system incrementally" is a statement about its structure. They feel like the same complaint and they lead to opposite decisions. Ugly code with clean boundaries can be replaced a piece at a time by anyone with patience. Clean code with no boundaries has to be replaced all at once, or not at all.
Michael Feathers gave the useful vocabulary for this in Working Effectively with Legacy Code (Prentice Hall, 2004): a seam is a place where you can alter a program's behavior without editing in that place. An interface you can supply a different implementation for is a seam. A route the reverse proxy can send elsewhere is a seam. A queue you can attach a second consumer to is a seam. A 4,000-line stored procedure that the UI calls directly, and that writes to eleven tables three other systems also write to, is not — there is nowhere to stand that isn't inside it.
That distinction is the whole decision. Where a seam exists, incremental replacement works even when the code behind it is genuinely terrible, because you can divert one cohort of traffic, watch it, and put it back. Where no seam exists and none can be manufactured, incremental replacement has no mechanism — every "incremental" step is really a big-bang cutover of whatever is behind the missing boundary. Most stalled rebuilds are teams attempting incremental delivery through a boundary that was never there.
The Seam Test: When to Rebuild vs Refactor a .NET Backend
Before arguing about the destination, establish whether there is a road. Ask three questions, in order, about the specific capability you want to replace — not about the system as a whole, which is the second most common mistake in this decision.
- Can behavior be diverted at a boundary you already control? A route, an interface registration, a message subscription, a feature-flagged dispatch point. If yes, a seam exists and the incremental path is open.
- If not, can one be manufactured — and what does that cost? Extracting an interface over a legacy service, putting YARP in front of a monolith and owning the routing table, introducing an anti-corruption layer over a schema you don't want to inherit. Time-box the estimate. Under about four weeks, build it — this is the one piece of work that is not wasted under either decision, because a rebuild needs the same seam to cut over safely.
- If a seam can't be built cheaply, what is blocking it? The answer to that question — not the code quality, not the runtime version — is what determines whether a rebuild is justified. Only four answers actually justify one.
Four Signals That Justify a Rebuild — and Four That Don't
A signal earns a place on this list by meeting one bar: refactoring cannot reach it incrementally, no matter how disciplined the team. Everything else — however painful — is work, not justification. Each of the four has a near-identical-sounding impostor that gets it invoked wrongly.
| Signal (justifies a rebuild) | Why refactoring can't reach it | Impostor it's confused with |
|---|---|---|
| No seam, and none can be built | Incremental delivery needs somewhere to divert behavior to. Without one, every step is a big-bang in disguise. | "The code is ugly" |
| The runtime blocks a capability the business needs | No amount of restructuring gives a platform a feature it doesn't have — and when migration cost equals rebuild cost, you're choosing between two rebuilds. | "It's .NET Framework" |
| The data model contradicts the business model | Refactoring code around a schema that encodes the wrong invariants relocates the contradiction; it doesn't resolve it. | "The schema is messy" |
| Correct behavior is both unknown and unobservable | Every safe refactoring technique needs a definition of "unchanged." With no spec and no production signal, there's nothing to preserve against. | "We have no tests" |
Why the Impostors Are Not Signals
Ugly code is the loudest and weakest argument in the room. It's real, it's demoralizing, and it is precisely what incremental refactoring is for. Behind a seam, code quality is a local property you can improve one component at a time, with production traffic proving each step.
"It's .NET Framework" is a version, not a constraint. .NET Framework 4.8 is serviced as a component of the Windows operating system and follows its host OS lifecycle rather than a standalone end-of-support date (Microsoft .NET Framework lifecycle FAQ), so "unsupported" is usually not the accurate word for what's bothering the team. The signal is a specific capability the platform can't provide — Linux containers, a library that no longer ships a compatible target, a scaling profile the hosting model can't reach. Name the capability. If you can't, this is a modernization project to sequence, not a rebuild to justify.
"The schema is messy" is not the same as a schema that is wrong. Messy means denormalized, badly named, full of dead columns — all survivable, all fixable behind an anti-corruption layer. Wrong means the schema asserts something the business has stopped believing: one address per customer when customers now have many; a single-currency amount column in a business that went multi-currency; a status enum that can't express a state the business routinely occupies. Code written over a wrong model spends its life compensating, and no refactoring removes a contradiction that lives one layer down.
"We have no tests" is a starting condition, not a verdict. Feathers' entire method is characterization testing: pin current behavior — whatever it is, including the bugs — then change code against that pin. It only fails when behavior is also unobservable: no logs, no reproducible inputs, no way to run the thing outside production. That combination is rare, and it's the actual signal.
The Cost-of-Being-Wrong Asymmetry
Evidence is usually ambiguous, so the tiebreaker matters more than the analysis. These two decisions do not have symmetric downside, and the burden of proof should sit accordingly.
Being wrong about a refactor costs time. You spend six weeks discovering the seam is in the wrong place, move it, and keep the working software you had the whole way through — every increment shipped, every increment reversible. Being wrong about a rebuild costs the roadmap. The organization now runs two systems, pays dual maintenance, and cannot stop, because the half-built replacement is worth nothing until it is finished and the original is worth nothing to a team that has mentally abandoned it. The escape hatch closes early and quietly.
So the standard should be explicitly uneven: refactor is the default, and rebuild has to be argued for. Not because rebuilds are always wrong — one of the four signals above is sometimes genuinely present — but because the cost of a wrong rebuild is an order of magnitude larger than the cost of a wrong refactor, and decisions with asymmetric downside should be made with asymmetric evidence requirements. When a team can't articulate which of the four signals applies, the honest reading is that none does.
This is also the point where an outside opinion is worth what it costs, because the argument inside the room is rarely settled on the merits: the people who wrote the system defend it and the people who inherited it want to replace it, and both are reasoning from the same evidence. A structured backend architecture review exists in part to make that call on evidence rather than tenure.
The Strangler Path in .NET: Where Teams Put the Seam Wrong
Martin Fowler named the pattern in the early 2000s — originally "Strangler Application," later renamed "Strangler Fig Application" — and it's since become the default approach to incremental replacement. Microsoft documents it in the Azure Architecture Center, and Sam Newman devotes much of Monolith to Microservices (O'Reilly, 2019) to it and to branch-by-abstraction. Worth noting that Microsoft's own guidance lists "requests to the back-end system can't be intercepted" first among the conditions where the pattern doesn't apply — which is the seam test, stated as a precondition. The pattern is well understood. The seam placement is where it goes wrong, and it usually goes wrong like this:
// The branch-in-the-controller anti-pattern: the migration decision lives
// at every call site, and both engines are compile-time dependencies here.
[HttpGet("{id:guid}")]
public async Task<ActionResult<StatementDto>> GetStatement(Guid id, CancellationToken ct)
{
if (_config.GetValue<bool>("Features:UseNewStatementEngine"))
{
return Ok(await _newEngine.BuildAsync(id, ct));
}
return Ok(await _legacyEngine.BuildAsync(id, ct));
}
Four things are wrong here, and only the first is obvious. The toggle is duplicated across every endpoint that touches statements, so there is no single place to flip. It's all-or-nothing — no per-tenant, per-cohort rollout, so the first flip is the whole cutover. There is no way to compare the two outputs, so correctness is discovered by customers. And most consequentially: because the controller holds a compile-time reference to both engines, the legacy code can never be deleted, which is the only event that ends a migration. A strangler that can't remove the old system is just a permanent second system.
Move the seam behind a single interface, and give it a shadow mode — the mechanism that turns "we think the rebuild is correct" into evidence from production traffic:
/// <summary>
/// Single seam for statement generation. Diverts one cohort at a time from the
/// legacy engine to the rebuilt one. Callers receive the legacy result until a
/// cohort is promoted; in shadow mode the rebuilt engine runs alongside and its
/// divergences are recorded, never returned.
/// </summary>
public sealed class StatementEngineRouter : IStatementEngine
{
private readonly IStatementEngine _legacy;
private readonly IStatementEngine _rebuilt;
private readonly IMigrationCohorts _cohorts;
private readonly ILogger<StatementEngineRouter> _logger;
public StatementEngineRouter(
IStatementEngine legacy,
IStatementEngine rebuilt,
IMigrationCohorts cohorts,
ILogger<StatementEngineRouter> logger)
=> (_legacy, _rebuilt, _cohorts, _logger) = (legacy, rebuilt, cohorts, logger);
public async Task<Statement> BuildAsync(Guid accountId, CancellationToken ct)
{
var mode = await _cohorts.ResolveAsync(accountId, ct);
if (mode == MigrationMode.Promoted)
{
return await _rebuilt.BuildAsync(accountId, ct);
}
var legacy = await _legacy.BuildAsync(accountId, ct);
if (mode == MigrationMode.Shadow)
{
// Deliberately not awaited: the shadow path must never be able to
// fail, slow, or cancel the request the caller is waiting on.
_ = CompareInBackgroundAsync(accountId, legacy);
}
return legacy;
}
private async Task CompareInBackgroundAsync(Guid accountId, Statement legacy)
{
// Own timeout — the caller's token is already gone by the time this runs.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var rebuilt = await _rebuilt.BuildAsync(accountId, cts.Token);
var divergences = StatementComparer.Diff(legacy, rebuilt);
if (divergences.Count > 0)
{
_logger.LogWarning(
"Shadow divergence for {AccountId}: {Count} field(s) — {Fields}",
accountId,
divergences.Count,
string.Join(", ", divergences.Select(d => d.Field)));
}
}
catch (Exception ex)
{
// Swallowed by design. A shadow failure is a signal about the rebuild,
// not an incident on the live path.
_logger.LogError(ex, "Shadow evaluation failed for {AccountId}", accountId);
}
}
}
What changed is not the branching — it's what the branching enables. The migration
decision now lives in exactly one class, so promoting a cohort is a data change rather
than a deploy. IMigrationCohorts makes rollout granular, so the blast
radius of a wrong answer is one cohort. Shadow mode produces a divergence rate, which
is the only honest completion metric a rebuild has. And callers depend on
IStatementEngine alone — when the divergence rate reaches zero and every
cohort is promoted, the legacy implementation is deleted by removing one DI
registration.
The tradeoff is explicit: shadow mode doubles the work on the shadowed path, and fire-and-forget execution is only safe on reads with no side effects. Write paths need the rebuilt implementation pointed at an isolated store, or shadowed offline against replayed traffic — never fired blind alongside a live write. Applying this pattern to a write path without that isolation is how a "safe" migration double-charges someone.
What to Ship in the First 90 Days
Both paths have a first-90-days deliverable, and neither of them is "the new system." On the refactor path: the seam, the characterization tests that pin current behavior at that seam, and one non-trivial capability actually promoted through it. If a real cohort isn't running on rebuilt code by day 90, the approach hasn't been validated — only the plan has.
On the rebuild path the deliverable is the same seam, for a reason worth stating plainly: a rebuild still has to cut over, and the seam is what makes cutover reversible. A rebuild that reaches month 12 without one has spent a year building something that can only be deployed by big bang. Then: the new data model, validated against a full copy of production data, with every record the old model can express and the new one can't written down explicitly. Discovering those at cutover is what turns a rebuild into an outage.
Notice both lists start with the same item. That's the practical consequence of the seam test — the first increment is identical either way, which means the decision does not have to be made before work starts. It has to be made before the second increment, with substantially better information than the meeting had.
| Condition | Path | Why |
|---|---|---|
| A seam exists at the boundary you care about | Strangle incrementally | Traffic diverts per cohort; rollback is a data change |
| No seam, but one is buildable in ~4 weeks | Build the seam, then decide | The only work that isn't wasted under either decision |
| Data model contradicts the business model | Rebuild the model, strangle the code | Only the schema needs replacing wholesale, not the application |
| Runtime blocks a named capability; migration ≈ rebuild cost | Rebuild, scoped to that capability | Scope to what's blocked — not to everything sharing the solution file |
| Behavior unknown and unobservable | Rebuild from specification | Nothing to characterize, so nothing to refactor against |
| Any impostor, alone or in combination | Refactor | All of them are reachable incrementally behind a seam |
What This Framework Doesn't Cover
The seam test is a technical instrument, and some rebuild decisions are not technical. A system whose original authors have all left, whose domain nobody currently understands, and which the business needs to change monthly can be a legitimate rebuild on organizational grounds even with a perfectly serviceable seam — because the constraint is knowledge, not structure. This framework will say "refactor" in that situation and it will be technically right and practically wrong. The same applies to acquisition-driven consolidation and contractual platform requirements: those are decisions with technical consequences, not technical decisions.
It also assumes the problem is understood. "Rebuild it" is a frequent response to a system that is merely slow, and performance problems are diagnosable — usually to a specific constraint, often in one layer. If the trigger for this conversation was latency rather than changeability, that's a bottleneck diagnosis first, and the database-vs-application-layer attribution question specifically. Rebuilding a system to fix a missing index is an expensive way to buy an index.
Finally, it says nothing about sequencing across multiple systems. Which of your four legacy components to address first is a portfolio question — driven by cost of being wrong, coupling, and business dependency — and the seam test only tells you how to treat each one once it comes up.
Frequently Asked Questions
- How long does a typical .NET rebuild take?
- Longer than the estimate, for a structural reason rather than a discipline one: the estimate is built from the features the team knows about, while the delivered system also has to reproduce years of accumulated edge-case behavior nobody documented. That undocumented behavior is invisible during planning and non-negotiable at cutover, because it is what real customers depend on. The more useful question is not how long the rebuild takes but how long the organization must run both systems, since that overlap — dual maintenance, dual on-call, dual bug fixes — is the cost that actually breaks roadmaps. A strangler-fig approach that promotes one route at a time keeps that overlap bounded and continuously shrinking, instead of open-ended until a single cutover date that keeps moving.
- Can we keep shipping features during a rebuild?
- Only if every feature is built once, not twice. The failure mode in a parallel rebuild is that new work lands in the legacy system because that is where production traffic is, and then has to be reimplemented in the new one before cutover — so the target keeps moving and the rebuild never converges. Two rules keep this in check: route all new work in the affected domain into the new implementation from day one, behind the same seam that serves the old one, and treat any feature that has to be written twice as a signal that the seam is in the wrong place. If neither rule is workable, the honest plan is a feature freeze for the duration, stated openly to the business rather than discovered by them in month nine.
- What's the smallest unit we can rewrite first?
- The smallest unit that has a seam in front of it and an owner behind it — usually a read path rather than a write path. Read paths are the right starting point because they can be run in shadow mode: the new implementation executes alongside the old one, its output is compared, and its result is discarded until the divergence rate reaches zero. That gives you correctness evidence from production traffic before anything depends on the new code. Write paths cannot be shadowed safely without side-effect isolation, so they should follow once the read side has proved the new data model reproduces real behavior. Choosing by business importance instead of by seam availability is the common mistake — it front-loads the riskiest work at the moment your rollback story is weakest.
- When should we walk away from a refactor mid-flight?
- When two consecutive increments fail to reduce the surface area of the legacy system. A healthy strangler run shows a monotonic decline in something countable — routes still served by the old path, tables still written by legacy code, endpoints still holding a compile-time reference to the old assembly. If that number is flat or rising across two increments, the seam is in the wrong place or the data model is fighting you, and continuing costs more than restarting the decision. Define that metric and the threshold before the first increment ships, while nobody is invested in the answer. Deciding mid-flight without a pre-agreed stop rule reliably produces the worst outcome available: both systems in production indefinitely, with neither fully owned.
When to Bring in External Help
The seam test is straightforward to apply and hard to apply to your own system, because the people best qualified to answer it are the people with the strongest prior commitments to an answer. Teams that have lived with a codebase for years know exactly where the boundaries are — and have usually stopped being able to see which of the four signals is genuinely present versus which frustration is doing the talking.
A two-week Discovery Sprint runs the seam test against the specific capability you're arguing about, prices the seam if one has to be built, and delivers the rebuild-or-refactor call as a ranked recommendation with the evidence attached — before an eighteen-month commitment gets made on instinct. For what the incremental path looks like in practice, the retail catalog automation case study replaced a multi-day manual operation by layering an event-driven pipeline over the existing ERP, with no rip-and-replace at any point.