In this guide
On this page ⌄
Michael Feathers defined legacy code, in Working Effectively with Legacy Code, as code without tests. A broader working definition is code you need to change but do not understand well enough to change safely. The original authors are gone or busy, the comments describe an earlier version, the tests are thin, and the code does things that look wrong but might be load-bearing. Every long-lived system has some of it, and sooner or later a ticket lands in the middle of it.
The good news is that legacy code yields to a specific set of techniques. You can recover why code exists from version control. You can pin down what it currently does with characterization tests before you touch it. You can find seams, places where behavior can be changed or observed without editing the code around it. This post walks through each, in the order you would use them on a real ticket. For general reading technique, see how to read an unfamiliar codebase.
Key takeaways
- Treat legacy code as evidence, not as a mistake. Odd code often encodes a bug fix, a customer requirement or an incident.
- Use git archaeology first:
git log -L,git blame -w -C, pickaxe search (-S,-G) and linked tickets explain why code is the way it is. - Before changing anything, write characterization tests that capture current behavior, including behavior that looks wrong.
- Find seams (parameters, interfaces, overridable methods, config, module boundaries) where you can isolate the code for testing.
- Change in small, reversible steps. Keep refactoring commits separate from behavior changes.
- Write down what you learn. The next person to touch this code will be in the same position you were.
Mindset: the code is evidence
The most expensive mistake with legacy code is deleting something because it looks pointless. Consider:
if (order.getCountry().equals("DE") && order.getTotal().compareTo(new BigDecimal("0.01")) == 0) {
return;
}
It looks like garbage. It might be a workaround for a payment provider's test transactions, or a fix for a 2019 incident where one-cent orders flooded the German tax export. Until you know, it stays. Chesterton's fence applies: do not remove a fence until you know why it was put up.
So the first job is not to judge the code. It is to find out what it does and why.
Git archaeology: recovering the why
Version control is the best documentation a legacy system has, because it records every change with a timestamp, an author and usually a message. Learn these commands well.
Who last touched each line, ignoring noise
# Blame, ignoring whitespace changes and detecting moved/copied lines
git blame -w -C -C -M path/to/Billing.java
# Blame a range of lines only
git blame -L 120,160 path/to/Billing.java
-w ignores whitespace, -M detects lines moved within a file, and -C (repeat for more effort) detects lines copied or moved from other files. Without these, blame often points at a formatting commit or a file move instead of the real author.
Large reformatting commits ruin blame for everyone. Git supports a file listing commits to skip:
# .git-blame-ignore-revs contains one commit hash per line
git config blame.ignoreRevsFile .git-blame-ignore-revs
GitHub's blame view also honors a .git-blame-ignore-revs file at the repo root.
The full history of one function
# Every commit that changed lines 120-160, with the diff
git log -L 120,160:path/to/Billing.java
# Or by function name (works for many languages via the funcname pattern)
git log -L :calculateTax:path/to/Billing.java
git log -L is the most underused command in Git. It shows the evolution of a specific region, commit by commit, so you can see the moment the odd condition was added and read the message that came with it.
When was a string added or removed
# Commits where the number of occurrences of a string changed (pickaxe)
git log -S "0.01" --oneline -- src/billing/
# Commits where a regex appears in the changed lines
git log -G "country.*DE" --oneline -p -- src/billing/
-S finds commits that added or removed a literal string, ideal for "when did this magic number appear". -G matches a regex against the diff lines.
Follow a file through renames
git log --follow --oneline -- src/billing/TaxCalculator.java
Read the surrounding context
A commit message like "fix" is not useless. Look at the date, the other files changed in the same commit, the PR it was merged in, and any ticket ID in the branch name or message. Search your issue tracker and chat history for that date and file name. Someone usually discussed it.
# Everything else that changed in the same commit
git show --stat <hash>
# Commits touching this file around that date
git log --since=2019-03-01 --until=2019-04-15 --oneline -- src/billing/
Map what is actually used
Before investing in a region, check whether it is still alive. Legacy systems carry dead features.
- Static references: does anything call this function or import this module? Use "find references" or a call graph.
- Runtime evidence: logs, metrics, APM traces, or a temporary counter. If an endpoint has had zero requests in 90 days of access logs, that is strong evidence.
- Feature flags and config: code behind a flag that is off everywhere is effectively dead.
Dead code still needs careful removal, but it does not need to be understood deeply. The techniques are covered in finding dead code. For live code, it helps to know how far a change will spread before you start; see impact analysis before refactoring.
Characterization tests: pin down current behavior
A characterization test does not check what the code should do. It records what the code does now, so that any change you make is either intentional or caught. Feathers' recipe:
- Put the code under test (call it from a test harness).
- Write an assertion you know will fail.
- Let the failure tell you the actual behavior.
- Change the assertion to match the actual behavior.
- Repeat for more inputs, especially edge cases.
# test_tax_characterization.py
import pytest
from billing.tax import calculate_tax
@pytest.mark.parametrize("country,total,expected", [
("DE", "100.00", "19.00"),
("DE", "0.01", "0.00"), # odd: one-cent orders are untaxed. Pinned, not endorsed.
("FR", "100.00", "20.00"),
("US", "100.00", "0.00"), # US tax handled elsewhere?
("XX", "100.00", "0.00"), # unknown country returns 0 rather than raising
])
def test_calculate_tax_current_behavior(country, total, expected):
assert str(calculate_tax(country, total)) == expected
Comment the surprising cases. These comments become your list of questions for product or domain experts, and they stop the next person from "fixing" a pinned behavior without checking.
For code with large or complex outputs (reports, rendered documents, API responses), use approval or snapshot testing: capture the full output once, store it, and diff against it on every run. Tools like ApprovalTests, Jest snapshots, or pytest plugins such as syrupy make this cheap. Snapshot tests are blunt, but for a legacy region that is exactly right: you want to know if anything changed at all.
Finding inputs worth testing
- Production logs and recorded requests are the best source of realistic inputs.
- Branch conditions in the code tell you the boundaries: each
ifsuggests at least two test cases. - Coverage tools show which branches your characterization tests have not reached yet. Aim for the code you are about to change to be well covered, not the whole module.
Seams: getting the code into a test harness
The hardest part of testing legacy code is often just calling it. It reads the clock, talks to the database, calls a payment API, reads global config. A seam, in Feathers' terms, is a place where you can alter behavior without editing in that place. Common seams:
| Seam | How it works | Example |
|---|---|---|
| Parameter | Pass the dependency in instead of creating it inside | def charge(order, clock=SystemClock()) |
| Interface / object | Code depends on an interface; tests supply a fake | PaymentGateway with a FakeGateway |
| Subclass and override | Override the method that does I/O in a test subclass | TestableBilling extends Billing { sendInvoice() {} } |
| Module / link | Replace a module at import or link time | jest.mock('./mailer'), Python monkeypatch |
| Config / environment | Behavior selected by config | Point the DB URL at a test database |
The smallest safe edit to create a seam is often "extract and override" or "parameterize with a default". For example:
// Before: impossible to test without the real clock and the real DB
export async function expireTrials() {
const now = new Date()
const trials = await db.query('SELECT * FROM trials WHERE ends_at < $1', [now])
// ...
}
// After: same default behavior, but tests can inject both
export async function expireTrials(now = new Date(), database = db) {
const trials = await database.query('SELECT * FROM trials WHERE ends_at < $1', [now])
// ...
}
This edit changes no behavior for existing callers, which is the point: you make the code testable with the least risky change possible, then write characterization tests, then make the real change.
Make the change in small steps
With tests pinning current behavior and a seam to isolate the code, the actual change becomes routine engineering:
- Refactor first, separately. Rename, extract functions, untangle conditionals, with characterization tests passing at every step. Commit these as pure refactors.
- Then change behavior. Update the specific characterization test whose behavior you are intentionally changing, with a comment explaining why. Every other test should stay green.
- Keep commits small and labeled. "refactor: extract tax rules" and "fix: tax unknown countries as error" should be different commits so reviewers and future archaeologists can tell them apart.
- Ship behind a flag when risk is high. A feature flag lets you roll the new behavior out gradually and roll back without a deploy.
For larger changes, such as replacing a subsystem, the patterns in safe refactoring in large codebases (strangler fig, branch by abstraction) apply.
Leave notes for the next person
Everything you just learned will be lost unless you write it down. Useful places:
- Comments next to the strange code, explaining why it exists, with a link to the commit or ticket:
// One-cent DE orders are provider test transactions; see INC-482. - Characterization test comments, marking pinned-but-questionable behavior.
- A short doc for the module: what it does, its known oddities, who to ask.
- Commit messages that say why, not just what.
Getting a picture of the region first
Archaeology and tests work at the level of a function or a file. Before diving in, it helps to see the region from above: which modules the legacy code depends on, which depend on it, and how big the blast radius of a change is. A generated architecture map of the repository answers that quickly. OpenVisio's Explore view shows the module graph and file tree for a repo, and the narrator can explain the area you are looking at, which gives you a set of hypotheses to check with git log -L and characterization tests.
Legacy code is not a moral failing of the people before you. It is the normal state of software that has been useful for a long time. Read it as evidence, pin its behavior, find the seams, and change it one small, tested step at a time.

