Legacy Code: Definition

ModernLift · ·6 min read
Part 3 of 14

Legacy code is source code that is difficult to change safely — usually because it has little or no automated test coverage and the people who understood it are gone. The common definition, from Michael Feathers, is simply "code without tests": you cannot change it with confidence because you cannot prove you have not broken something. Age is not the test; changeability is.

“Legacy code” sounds like a judgment about age, but the most useful definition has nothing to do with how old the code is. It is about whether you can change it safely.

The working definition

The reference point most engineers cite comes from Michael Feathers: legacy code is code without tests. The reasoning is practical. When code has no automated tests around it, any change is a bet. You cannot prove the behavior you needed is still intact, so you either move slowly and fearfully or move fast and break things downstream.

By that definition, legacy is a property of confidence, not chronology. Code you can change and verify in minutes is not legacy, whatever its date. Code you are afraid to touch is.

A concrete example

A pricing function that takes an order and returns a discount, full of unexplained thresholds, if total > 4750, if customer.tier == 2, a stray + 0.5 nobody can account for, and no test file anywhere near it. Nobody currently on the team remembers why the thresholds are what they are. They were probably right for a promotion two years ago, and nobody has revisited them since. That function is legacy code by the working definition even though the file itself was last edited eight months ago. Before anyone can safely change it, and someone eventually always has to, it needs a characterization test: run it against a wide set of real inputs, record what it currently returns, and pin that down as the baseline. Only once that baseline exists can the thresholds be questioned with any real confidence.

How to work with it safely

Feathers’ book is essentially a catalog of techniques for getting risky code under control without first rewriting it, since rewriting it is exactly the move you have no safety net for yet.

  • Characterization tests. Before changing anything, write tests that capture what the code currently does, not what it should do. That baseline is what makes every later change checkable.
  • Find the seams. A seam is a place you can alter what a piece of code does without editing it directly, for example passing in a dependency instead of hardcoding it. Seams are how you get legacy code under test without touching its internals first.
  • Sprout new code. Add new behavior as a new, well-tested function or class called from the old code, instead of growing the old code further. The new code has real tests from day one, and the untested surface area shrinks over time rather than growing.
  • Wrap what you cannot yet touch. Put a thin wrapper around a risky piece of code you are not ready to change directly, and add new behavior around the wrapper. It contains the risk at a boundary while still letting the system evolve.

Signs code has become legacy

  • No automated test actually exercises the function’s real logic, even if a test file technically exists nearby.
  • Magic numbers or conditions with no comment explaining where they came from.
  • The commit history points to an author no longer on the team, and nobody since has touched the code with any confidence.
  • Engineers routinely copy its logic elsewhere rather than calling or changing it directly, a sign nobody trusts it enough to depend on it cleanly.

Why it matters

Most of the cost of legacy code is paid in caution. Engineers route around the risky module, copy logic rather than modify it, and add layers on the outside because changing the inside is too dangerous. That is how a codebase calcifies: not because it stops working, but because changing it stops being safe. The behavior other systems depend on is buried in there, often undocumented, and one wrong edit surfaces as an outage somewhere nobody was looking.

How it relates to modernization

Legacy code is what makes a legacy system hard to modernize. You cannot confidently re-implement behavior you cannot first characterize, and untested code does not tell you what it actually does. This is why disciplined modernization builds a safety net before changing anything, characterization tests that pin down current behavior, then refactoring or re-implementation against that net. It is also why proving behavior parity against the running system matters more than matching a specification: with legacy code, the running system is the only honest record of what it does.

Common confusion

Legacy code is often conflated with bad code. They overlap but are not the same. Bad code is poorly written. Legacy code is hard to change safely. Plenty of legacy code is competent work that simply outlived its tests and its authors, and treating the people who wrote it as the problem is both unfair and unhelpful. The code is not the enemy. The lack of a safety net is.

Frequently asked questions

Is legacy code just old code?
No. Age and quality are different axes. Well-tested, well-understood code can run for twenty years and never be legacy; untested, undocumented code can become legacy within a year of being written. What makes code legacy is that changing it is risky, not that it is old.
What is the most-cited definition of legacy code?
Michael Feathers, in "Working Effectively with Legacy Code," defines it as code without tests. Without a test harness around it, every change is a guess, because nothing tells you whether you preserved the existing behavior. That is the practical root of the risk.
How is legacy code different from a legacy system?
Legacy code is the source itself; a legacy system is the broader running thing, code plus data, runtime, integrations, and operational reality. A system can be legacy because of its runtime or its dependencies even if isolated pieces of its code are fine.
What are Michael Feathers' techniques for working with legacy code?
Four main ones. Characterization tests capture what the code currently does before you touch it. Seams are points where you can alter behavior without editing the code directly, often by passing in a dependency instead of hardcoding it. The sprout method adds new behavior as new, well-tested code called from the old code, rather than growing the old code further. The wrap method contains a risky piece of code behind a wrapper so new behavior can be added around it. All four share one goal, getting a change under test without first rewriting the thing you have no safety net for.
Does legacy code need to be rewritten?
Usually not. Most legacy code needs a safety net, not a rewrite. Once characterization tests exist, it can be refactored or extended like any other code. A rewrite is the highest-risk, most expensive option on the table and rarely the first one worth reaching for, especially since a rewrite of undocumented code risks losing behavior nobody remembered to specify.
All 14 parts of Modernization Glossary & Definitions →