Legacy modernisation as a narrative
What it is
Every senior loop eventually asks about an old system. The question is not really about the system, it is about whether you can work on something you did not build without contempt for the people who did.
The arc that reads as seasoned, and it is a genuine method rather than a rhetorical posture:
1. UNDERSTAND BEFORE JUDGING
The code is the way it is for reasons that were once good.
Find them before you change anything.
2. CHARACTERISATION TESTS
Capture what the system actually does, including the parts
that are wrong, before you touch it. You cannot refactor
without a definition of "unchanged".
3. FIND THE SEAMS
The places where you can insert new behaviour without
modifying the old code. Seams are what make incremental
change possible at all.
4. STRANGLER INCREMENTS, WITH VALUE AT EVERY STEP
Route slices of traffic or functionality to the new
implementation, one at a time, each independently valuable
and independently revertible.
5. CELEBRATE DELETION
The migration is not done when the new thing works. It is
done when the old thing is gone, and if you do not make
deletion a visible milestone it never happens.
What this is confused with: the rewrite. A rewrite is the intuitive answer, it is almost always wrong, and the reason is not sentiment: the old system contains years of accumulated corrections you cannot see and cannot enumerate, and a rewrite discards all of them at once and rediscovers them in production.
Also confused: modernisation and technology upgrade. Moving a system from one framework to another without changing its boundaries produces the same system with newer dependencies and a year of disruption. The value of modernisation is in the seams you create, not the framework you land on.
The problem it solves
Contempt for legacy code is a junior tell, and it produces a specific and predictable failure.
"This code is a mess, we should rewrite it."
Six months in:
- 70% of the functionality is reimplemented
- the remaining 30% is the part nobody understands, which is
also the part that handles the exceptions that make the
business work
- the old system is still running, still being changed by
people fixing bugs, so the target moved
- the team has shipped no user-visible value in six months
- the sponsor's patience has expired
Outcome: the rewrite is cancelled, or it ships and immediately
produces incidents in exactly the cases the old code handled
and nobody documented.
And the reason it fails is structural. The old system's ugliness is largely accumulated knowledge:
if (country == "BR" && orderType == "MARKETPLACE") {
taxBase = subtotal; // not subtotal + shipping
}
Reads as: an arbitrary special case, evidence of a mess.
Actually is: a tax rule, discovered in production in 2019 after
an audit, that cost the company a penalty. There is a Jira
ticket from six years ago and nobody currently employed
remembers.
A rewrite removes it. Production rediscovers it.
The honest framing: a legacy system is a specification that has been tested against reality for years, written in a language you find unpleasant.
Mechanics
1. Understand before judging
Before proposing anything, be able to answer:
- what does this system do that nothing else does?
- who depends on it, including the ones not in the
architecture diagram (the batch job, the report, the
partner's nightly pull, the spreadsheet someone maintains)
- what are its actual traffic, data volume and error rates?
- what changed most recently, and why? (git log is a
reasonable proxy for where the pain is)
- what are the five weirdest pieces of code in it, and what
is the story behind each?
The last one is the highest-value exercise. For each oddity,
find the commit, the ticket, or the person. Some will be
genuine mistakes. Many will be a rule you did not know
existed.
Talk to whoever is left, early, and ask a specific question rather than a general one:
Not: "so how does this thing work?"
But: "what is the part of this system that you're most afraid
of changing, and why?"
That question produces the risk map in about ten minutes, from
someone whose knowledge is otherwise unextractable.
And say out loud, to the people who built it, that the constraints were different. It costs nothing, it is usually true, and the alternative is that the people whose help you need spend the project defending themselves.
2. Characterisation tests
A characterisation test does not assert what the code should do. It records what it does.
# Not a correctness test. A description of current behaviour,
# including behaviour that is probably wrong, so that a
# refactor can be proven not to change anything.
@pytest.mark.parametrize("case", load_recorded_cases("prod_sample_2024_03.jsonl"))
def test_pricing_matches_recorded_behaviour(case):
result = legacy_price(case["input"])
assert result == case["recorded_output"], (
f"behaviour changed for {case['id']}; if this is intentional, "
f"update the fixture and say why in the commit message"
)
Generating them from production is the technique that makes this practical:
1. Capture a sample of real inputs and outputs in production
(log them, or mirror traffic). A few thousand cases covering
the distribution beats a hundred hand-written ones.
2. Stratify the sample so rare paths are represented: sample by
country, order type, currency, error class, not uniformly by
volume. Uniform sampling gives you 5,000 copies of the
common case.
3. Freeze them as fixtures.
4. Now refactor. Any behavioural change shows up as a diff.
The uncomfortable part, and it is the point: some of the recorded behaviour is wrong. Record it anyway. Fixing a bug and refactoring are two changes, and doing them together means you cannot tell which one broke production. Record the wrong behaviour, ship the refactor, then fix the bug as its own change with its own test.
Where behaviour cannot be captured offline, run a comparison in production:
Old path serves the response. New path computes in parallel.
A job diffs them and reports mismatches by category.
This is the same mechanism as shadow traffic and it is the
strongest available evidence, because it exercises the real
input distribution including the cases nobody thought to
sample.
3. Seams
A seam is a place where you can change behaviour without editing the code around it. Legacy code is hard to change mainly because it has none.
Common seams, easiest first:
ROUTING SEAM a proxy, gateway or router in front. Send
some requests elsewhere. Requires no change
to the old code at all, which is why it is
first.
INTERFACE SEAM extract an interface around a dependency,
inject an implementation. The classic
refactoring seam.
EVENT SEAM the old system already emits something (a
database write, a log line, a message). Read
it and build alongside, without touching it.
CDC on the legacy database is the strongest
version and requires zero legacy changes.
DATA SEAM dual-write or replicate the data so a new
system can be built on it before any traffic
moves.
FEATURE FLAG SEAM a branch inside the old code. Requires
editing it, which is why it is last, and it
is the one people reach for first.
The ordering matters because each seam's cost is the amount of legacy code you must modify to create it, and modifying legacy code is the risky operation you are trying to minimise.
Michael Feathers's definition is the operative one: legacy code is code without tests, and the problem is that to add tests you must change the code, and to change the code safely you need tests. Seams are the way out of that loop.
4. Strangler increments
The pattern (Martin Fowler's Strangler Fig): grow the new
system around the old one, moving one capability at a time,
until the old one can be removed.
The discipline that makes it work:
EACH INCREMENT IS INDEPENDENTLY VALUABLE.
Not "phase 1: build the framework". If the project is
cancelled after increment 3, increments 1 to 3 must have
been worth doing on their own.
EACH INCREMENT IS INDEPENDENTLY REVERTIBLE.
A flag or a route weight, flipped back in seconds.
SLICE BY BUSINESS CAPABILITY, NOT BY LAYER.
"Move the database first, then the service layer" leaves you
with two half-systems and no working slice.
"Move refunds end to end" gives you a complete, testable,
revertible capability.
START WITH THE SLICE THAT IS HIGH VALUE AND LOW RISK, not the
hardest one. You need a demonstrated success before you have
the credibility to attempt the frightening part.
THE ORDER IS A JUDGEMENT AND SHOULD BE WRITTEN DOWN, with the
reasoning, because it will be questioned by everyone who
arrives later.
Sequencing by value delivered at every step is the difference between a migration that survives a reorganisation and one that does not, and reorganisations happen on an 18-month timescale while migrations take longer.
5. Celebrate deletion
The failure: the new system works, traffic has moved, and the
old system is still running because turning it off is nobody's
priority and slightly scary.
Two years later you are operating both, paying for both,
patching both, and every new engineer has to learn both.
The discipline:
- the migration plan's LAST milestone is deletion, named,
with a date, from the beginning
- measure and publish residual traffic to the old path
weekly, so "nearly zero" becomes visible as "0.4%, which is
the reporting job and one partner"
- track and chase the last callers by name. The tail is
always a small number of identifiable dependents.
- make deletion a visible celebration: the announcement, the
lines-removed number, the retired dashboards, the
infrastructure cost that stops.
The last one is not sentiment. It is how you make the NEXT
migration finishable, because the team learns that these
projects end.
"Lines deleted" and "systems retired" belong in the same status update as "features shipped", and a lead who never reports them is running an organisation where nothing is ever finished.
A worked example: an order-pricing engine nobody would touch
A 12-year-old pricing service. 41,000 lines, one language version behind support, three people who had ever changed it, two of whom had left. Every product change involving price took a quarter, and the standing proposal, made twice before and cancelled twice, was a rewrite.
Phase 0: understand, three weeks.
Findings:
- 41,000 lines, of which git blame showed 68% unchanged in
5 years, and 31 files accounting for 80% of all changes
- 14 consumers, of which 5 were not in the architecture
diagram: two batch reports, a partner's nightly pull, a
finance spreadsheet, and an internal admin tool
- the five weirdest code sections, investigated:
2 were genuine mistakes nobody had cleaned up
3 were rules with documented origins: a Brazilian tax
base rule from a 2019 audit, a rounding rule for one
currency required by a payment processor, and a
grandfathered discount for 40 accounts on a contract
signed in 2015
The Brazilian rule alone would have been rediscovered in
production by a rewrite, with a penalty attached.
The question that produced the risk map:
Asked of the one remaining original engineer: "what part of
this are you most afraid of changing?"
Answer, in about eight minutes: the promotional-stacking
logic, because the order of operations was determined
empirically over two years of finance complaints and there is
no specification. "If you change the order, the numbers move
by fractions of a cent and finance notices in the monthly
close, six weeks later."
That is a risk nobody would have found by reading the code,
and it set the migration order: promotional stacking went
LAST.
Phase 1: characterisation, four weeks.
Captured 90 days of production inputs and outputs, stratified:
by country (18), order type (6), currency (11), and error
class, rather than by volume.
-> 22,000 cases, of which the top 3 countries would have
supplied 94% under uniform sampling and did supply 31%
under stratified sampling.
Test suite runtime: 40 seconds.
Immediate finding: 3 of the 22,000 cases produced
non-deterministic output, because a discount tiebreak used a
hash-map iteration order. That is a real bug, it had been
producing occasional penny differences for years, and it was
recorded as-is rather than fixed, then fixed separately two
weeks later with its own test.
Recording the bug rather than fixing it in the same change is the discipline, and it is what let the team later prove that the refactor changed nothing.
Phase 2: seams, three weeks.
ROUTING SEAM: the pricing service was already behind an
internal gateway. A route rule keyed on (country, orderType)
could send a slice to a new service. Zero legacy code
changed.
EVENT SEAM: the legacy service wrote every priced order to a
table. CDC on that table gave the new implementation a
comparison stream without touching the legacy code at all.
Those two were sufficient. No feature flags were added inside
the legacy code base, which had been the previous attempts'
starting point and the reason both had stalled: editing the old
code required understanding it first, which was the thing they
did not have.
Phase 3: strangler increments, five quarters.
Order chosen, and written down with reasoning:
1. SHIPPING COST calculation. (Q1)
High value: it changed 4x a year and each change took 6
weeks. Low risk: self-contained, few interactions.
Delivered on its own: shipping changes went from 6 weeks
to 3 days. That number funded everything after it.
2. TAX. (Q2)
High value: a new market launch was blocked on it. Medium
risk: the Brazilian rule, now understood and explicitly
ported with a comment linking the 2019 ticket.
3. BASE PRICE AND CURRENCY. (Q3)
4. VOLUME AND CONTRACT DISCOUNTS. (Q4)
Including the 40 grandfathered accounts, ported as data
rather than as code.
5. PROMOTIONAL STACKING. (Q5)
Last, deliberately. Ran in shadow for 8 weeks with a
penny-level diff report to finance BEFORE any traffic
moved, which was the condition finance asked for and
which the team offered rather than waited for.
Each increment: route a slice, compare in shadow, ramp, keep
the old path revertible for 30 days, then remove the route.
Increment 1 delivering a six-weeks-to-three-days improvement in its own quarter is what made increments 2 to 5 possible, because the project stopped being a cost and started being a thing that had already paid for itself.
The shadow comparison, and what it caught:
Across the five increments, shadow comparison found 31
behavioural differences before any traffic moved.
19 were bugs in the new implementation
9 were bugs in the OLD implementation that the new one had
accidentally fixed
3 were genuine ambiguities requiring a product decision
The 9 are the interesting category: each one had to be a
deliberate decision, because "the new system is more correct"
still changes what a customer is charged. Two were fixed in
both systems first, so that the migration remained
behaviour-preserving; seven were shipped as intentional
changes with finance sign-off and a customer-communication
plan for one of them.
"The new system is more correct" is still a behaviour change, and treating it as a free improvement is how a migration produces an incident it did not need to.
Phase 4: deletion.
After increment 5, residual traffic to the legacy service:
week 1 4.1%
week 4 0.9%
week 8 0.4% <- and stuck there
Chased by name:
0.3% the finance spreadsheet, pulling a legacy endpoint
nightly. Owner found, migrated in 2 days.
0.1% the partner nightly pull. 6 weeks of notice, then
migrated.
<0.01% an admin tool nobody had used in 14 months. Deleted.
Legacy service decommissioned 11 weeks after the last
increment, which was on the plan from the beginning as a named
milestone with a date.
Reported:
41,000 lines deleted
2 database instances and 6 EC2 instances retired
$3,100/month of infrastructure stopped
the on-call runbook shortened by 9 pages
The 0.4 percent that would not fall was three identifiable callers, which is the normal shape of a migration tail, and none of them would have been found without publishing the residual number weekly.
And the honest accounting:
Total elapsed: 6 quarters, roughly 2.5 engineers throughout.
The original rewrite proposal had estimated 2 quarters, which
is the usual ratio.
The team shipped user-visible value in 5 of the 6 quarters,
which is why it was never cancelled, and both previous
attempts had been cancelled in quarter 2 with nothing shipped.
Production evidence
Martin Fowler's Strangler Fig Application (2004) is the canonical description of incremental replacement around a legacy system, with the explicit argument that a big-bang rewrite carries risk that grows with the size of the system while incremental replacement delivers value continuously.
Michael Feathers's Working Effectively with Legacy Code defines legacy code as code without tests, names the change-to-test / test-to-change deadlock, and introduces seams as the mechanism for breaking it. Characterisation tests are his term and his method.
Joel Spolsky's "Things You Should Never Do, Part I" (2000), written about Netscape's decision to rewrite its browser, is the standard reference for the argument that the old code's ugliness encodes accumulated bug fixes that a rewrite discards, and Netscape's outcome is the canonical case study.
GitHub's Scientist library implements the run-both-and-compare pattern for exactly this situation, and its documented use for refactoring critical paths is the tooling form of the shadow comparison described above.
Change data capture as a seam (Debezium and equivalents) is widely used specifically to build new systems alongside legacy databases without modifying the legacy application, which is what makes it the lowest-legacy-change seam available.
Published incremental-migration accounts from Shopify, Etsy, Stripe and others consistently describe the same structure: slice by capability rather than by layer, ship value per increment, compare in production before moving traffic, and treat decommissioning as an explicit tracked milestone.
The debate
Is a rewrite ever right? Yes, in narrow conditions: the system is small enough to be fully understood, its behaviour is genuinely specified elsewhere, the platform is being discontinued, or the domain has changed so fundamentally that the old behaviour is not worth preserving. The test is whether you can enumerate what it does; if the answer requires archaeology, incremental replacement is the only approach whose risk you can bound.
Should you fix bugs found during characterisation? Not in the same change. Record the wrong behaviour, ship the refactor, then fix the bug separately with its own test, because combining them means a production problem cannot be attributed to one or the other. The counter-argument, that shipping known-wrong behaviour feels bad, is real and is answered by the separate fix arriving two weeks later.
Is "the new system is more correct" a good outcome? It is a behaviour change and must be treated as one. Nine of 31 differences found in shadow comparison in one migration were old-system bugs the new one had accidentally fixed, and each required a decision, because changing what a customer is charged is a product event regardless of which value is more defensible.
Slice by capability or by layer? By capability, always. Layer-first migrations produce two half-systems with no working slice, no independently deliverable value, and nothing to revert to. The pull toward layer-first is strong because it looks tidier on an architecture diagram.
Does the strangler approach take longer? In elapsed time, usually yes, and in delivered value it is ahead almost immediately. The relevant comparison is not against the rewrite's estimate but against the rewrite's outcome, and in the worked example both previous rewrite attempts were cancelled in their second quarter with nothing shipped.
Is celebrating deletion sentimental? It is a control. Without a named deletion milestone with a date and a weekly residual-traffic number, the old system runs indefinitely, and you pay for both, patch both, and onboard every new engineer onto both. Publishing lines deleted and systems retired alongside features shipped is how a team learns that these projects end.
Follow-up Q&A
"Why is a rewrite usually wrong?"
Because the old system's ugliness is largely accumulated knowledge you cannot enumerate. A special case that reads as evidence of a mess is often a tax rule discovered after an audit, a rounding requirement from a payment processor, or a grandfathered contract term, each with a ticket from years ago and nobody currently employed who remembers. A rewrite discards all of them simultaneously and rediscovers them in production. The structural failure is also predictable: the last 30 percent is the part nobody understands, the old system keeps changing so the target moves, and no user-visible value ships for months, which is why the sponsor cancels it.
"What is a characterisation test and why record known-wrong behaviour?"
It records what the code currently does rather than what it should do, so that a refactor can be proven not to change anything. You record the wrong behaviour because fixing a bug and refactoring are two changes, and doing them together means a production problem cannot be attributed to either. In one migration, three of 22,000 captured cases were non-deterministic because a discount tiebreak used hash-map iteration order; that was recorded as-is, the refactor shipped provably behaviour-preserving, and the bug was fixed two weeks later as its own change with its own test.
"How do you generate characterisation tests at scale?"
Capture real production inputs and outputs, and stratify the sample rather than sampling by volume. Sample by country, order type, currency and error class, because uniform sampling gives you thousands of copies of the common case: in one instance the top three countries would have been 94 percent of a uniform sample and were 31 percent of a stratified one. Where behaviour cannot be captured offline, run both implementations in production and diff the results, which is the strongest evidence available because it exercises the real input distribution including cases nobody thought to sample.
"What is a seam, and which ones do you prefer?"
A place where you can change behaviour without editing the code around it. Prefer them in order of how much legacy code they require you to modify, since modifying legacy code is the risky operation. A routing seam at a gateway requires zero legacy changes. An event seam, especially change data capture on the legacy database, also requires zero. An interface extraction requires some. A feature flag inside the old code requires the most, and it is what people reach for first, which is why previous attempts stall: editing the old code requires understanding it, which is the thing you do not yet have.
"How do you sequence a strangler migration?"
By business capability rather than by layer, so each increment is a complete, testable, revertible slice, and starting with something high value and low risk rather than the hardest part. In one case shipping-cost calculation went first: it changed four times a year and each change took six weeks, so moving it took that to three days and delivered a result in its own quarter. That number funded the remaining four increments. The frightening part, promotional stacking whose operation order had been determined empirically over two years of finance complaints, went last and ran in shadow for eight weeks with a penny-level diff report before any traffic moved.
"How do you make sure the old system actually gets deleted?"
Put deletion on the plan as the last named milestone with a date, from the beginning, and publish residual traffic to the old path weekly so "nearly zero" becomes "0.4 percent, which is the finance spreadsheet, one partner, and an admin tool nobody has used in 14 months." The tail is always a small number of identifiable callers, and chasing them by name is a two-week job once they are visible. Then report the deletion: lines removed, instances retired, monthly cost stopped, runbook pages deleted. That is not sentiment, it is how the team learns these projects end, which is what makes the next one finishable.
Common misconceptions
"This code is a mess, we should rewrite it." The mess is largely accumulated corrections you cannot enumerate, and a rewrite rediscovers them in production.
"Fix the bugs while you refactor." Then you cannot attribute a production problem to either change. Record the current behaviour, refactor, fix separately.
"The new system being more correct is a bonus." It is a behaviour change, and changing what a customer is charged is a product event regardless of which value is more defensible.
"Migrate the database first, then the services." Layer-first leaves two half-systems, no deliverable slice, and nothing to revert to. Slice by capability.
"Start with the hardest part while there is momentum." You need a demonstrated success before you have the credibility for the frightening part, and momentum is what an early delivered increment creates.
"The migration is done when the new system works." It is done when the old one is gone. Without a dated deletion milestone and a published residual-traffic number, you operate both indefinitely.
Interview delivery note
Say this verbatim: "The old code is the way it is for reasons that were once good, so I want to understand it before I judge it. A special case that reads as a mess is usually a tax rule from an audit or a contract term, and a rewrite discards years of accumulated corrections you cannot enumerate and rediscovers them in production." Contempt for legacy code is a junior tell; respect plus a method is the senior one, and this sentence carries both.
The senior-versus-staff separator is sequencing by value delivered per increment and saying why. A senior engineer describes the strangler pattern. A staff engineer says the first increment was shipping-cost calculation because it changed four times a year at six weeks a change, so moving it took that to three days and delivered a result in its own quarter, which funded the remaining four increments, and that both previous rewrite attempts had been cancelled in their second quarter with nothing shipped. Migrations outlive reorganisations only if each step is independently worth having done.
The second signal is treating an accidental correctness improvement as a behaviour change. Saying "shadow comparison found 31 differences, nine of which were old-system bugs the new implementation had accidentally fixed, and each of those needed a deliberate decision because changing what a customer is charged is a product event" shows you understand that behaviour preservation is the property you are buying, and that correctness improvements have to be shipped on purpose.
Further reading
- Martin Fowler, "Strangler Fig Application," for incremental replacement and its risk argument.
- Michael Feathers, Working Effectively with Legacy Code, for seams, characterisation tests and the test-change deadlock.
- Joel Spolsky, "Things You Should Never Do, Part I," for the Netscape rewrite as a case study in discarded accumulated knowledge.
- GitHub's Scientist library, for the run-both-and-compare pattern applied to refactoring critical paths.
- The shadow traffic and expand and contract pages, which are the deployment mechanics each strangler increment relies on.