What Is Legacy Code? A Clear Definition for Developers

Ask ten engineers to define legacy code and you will get ten answers, most of them about age. “It is the old PHP.” “It is the bit nobody wants to touch.” “It is whatever the last team left us.” The age framing feels right and is almost entirely wrong, because it leads teams to chase the wrong fix: a rewrite, when the real problem is that nothing tells them whether a change is safe.

The most useful definition is the bluntest one. Michael Feathers, in his book Working Effectively with Legacy Code ↗, defines legacy code as code without tests. That is the whole definition. If there are no tests, you have no fast, repeatable way to confirm that a change preserved the behaviour you cared about, so every edit becomes a small bet. Code you cannot change with confidence is legacy code, regardless of when it was written.

Why “Code Without Tests” Is the Right Definition

The test-centred definition wins because it points at the actual pain. The pain of legacy code is not that it is old. It is that you open a file, you need to change three lines, and you have no idea what else those three lines affect. You read the function, you read its callers, you hold your breath, and you ship. That fear is the symptom, and the absence of a safety net is the cause.

Tests are that safety net. With a test suite you change the three lines, run the tests, and the green bar tells you the behaviour you care about still holds. The change stops being a bet and becomes ordinary work. Without tests, the only verification available is reading the code and reasoning about it in your head, which does not scale and does not survive being tired on a Friday afternoon.

This is why a service merged last week can already be legacy. If it shipped with no tests, it has the defining property from day one. Newness buys you nothing if you still cannot change it safely.

Legacy Code Is Not the Same as Old Code

Age and legacy status are independent. Here is the matrix that most “it is the old stuff” definitions miss.

Has testsNo tests
Recently writtenHealthy codeLegacy from day one
Old (years in production)Old but not legacyClassic legacy code

The interesting cells are the off-diagonal ones. A twenty-year-old date library with thorough tests sits in “old but not legacy”: you can change it with confidence, so it is not the problem. A microservice merged last sprint with zero tests sits in “legacy from day one”: new, and already a liability.

Treating age as the definition pushes teams toward rewrites, on the logic that old equals bad equals replace. But replacing old, well-tested code throws away a working safety net to chase a clean commit history. The question is never “how old is this?” It is “can I change this with confidence?”

Legacy Code Versus Technical Debt

These two terms get used interchangeably, and they should not be. They measure different things.

Technical debt, a metaphor Ward Cunningham coined and Martin Fowler has written about extensively, is about the cost of choosing a quick solution now over a better one later ↗. It carries interest: slower future development, more bugs, harder onboarding. Crucially, debt can be deliberate and rational. Shipping a simpler design to hit a launch window, knowing you will improve it, is a sound business call.

Legacy code is about changeability, not tradeoffs. The question is binary in spirit: can you alter this code with confidence, or not?

The two overlap but neither contains the other.

Technical Debt and Legacy Code Overlap, but Differ Technical Debt Legacy Code Deliberate tradeoff, fully tested, easy to change safely No tests, clean code, but no safety net for any change Old shortcut, no tests, scary to touch

In the blue-only zone you have intentional debt with a test suite around it: scrappy, but safe to change. In the pink-only zone you have clean, well-named code that simply has no tests, so changing it is a gamble. The overlap is the classic horror story: an old shortcut, no tests, and a team that flinches when the file is opened. If you want the decision framework for the debt side specifically, Technical Debt: When to Fix It and When to Leave It covers when paying it down is worth the cost.

How to Tell If You Are Looking at Legacy Code

You do not need a tool. Three questions settle it.

  • Can I change this with confidence? If a small change makes you nervous, that nervousness is the diagnosis.
  • Is there a test that would fail if I broke the behaviour? If not, you have no safety net, and the code is legacy by Feathers’ definition.
  • Do I understand what this code is supposed to do, or only what it appears to do? Legacy code often encodes business rules nobody remembers, hidden in conditionals that look removable but are not.

If you answered “no, no, only what it appears to do”, you are in legacy territory. The good news is that the same definition that diagnoses the problem also prescribes the cure: add the tests, and the code stops being legacy.

What This Means for How You Fix It

If legacy code is defined by missing tests, the fix is not a rewrite. It is to get tests around the part you need to change, then change it. This reframes the whole effort. You are not signing up to rewrite a hundred thousand lines. You are getting exactly the slice you are about to touch under test, one slice at a time.

The mechanical first step is a characterisation test: run the existing code with representative inputs, capture whatever it actually does, and assert that captured output. The test does not say what the code should do; it pins what it currently does, so you get a loud failure the moment a change drifts. Refactoring Legacy Code Without Breaking It walks through writing these tests and then changing behaviour in safe increments.

The obstacle you hit next is that legacy code is usually too tangled to test directly: it reaches straight into the database, the network, the clock. The way through is to break only the dependencies you need, using techniques like sprout method and seams covered in Breaking Dependencies in Legacy Code. At the system level, you replace the old code gradually rather than all at once, the approach Martin Fowler describes as the strangler fig pattern ↗.

Before You Touch Anything

Resist the urge to start refactoring on day one. Map the territory first. Find the hot spots from version control history, understand the dependencies, and decide which parts are load bearing and which are safe to leave alone. How to Audit a Legacy Codebase is the step that should come before any change, and it routinely saves weeks of rework. For the higher-level, team-wide practice of living with and modernising a large legacy system over months, The Developer’s Guide to Working with Legacy Code covers the strangler fig pattern and the human side of the work.

The Definition That Actually Helps

Legacy code is code you cannot change with confidence, and the cleanest proxy for that is the absence of tests. It is not a synonym for old, and it is not a synonym for debt. Holding the definition this precisely matters because it changes the prescription: not “rewrite the old thing”, but “get a test around the bit you need to change, then change it”. Do that often enough and the legacy label quietly stops applying, one slice at a time.

If you are about to inherit an unfamiliar codebase, the most useful next move is to audit it before you write a line. Want practical engineering write-ups like this in your inbox? Subscribe to the Codably newsletter.

Frequently asked questions

What is legacy code?

Legacy code is code you are afraid to change because you cannot verify that your change is safe. Michael Feathers, in Working Effectively with Legacy Code, gives the sharpest working definition: legacy code is simply code without tests. Without tests you have no fast, repeatable way to confirm a change has not broken existing behaviour, so every edit is a gamble. Note that age is not part of the definition. Code can be legacy on the day it is merged if it shipped without tests, and well-tested code from 2008 is not legacy by this measure.

Is legacy code the same as technical debt?

No. Technical debt is a deliberate or accidental tradeoff where speed was chosen over quality, and it carries interest in the form of slower future work. Legacy code is a property of changeability: can you alter it with confidence? The two overlap but are not identical. You can have clean, debt-free code that is still legacy because nobody wrote tests for it, and you can have intentional, well-tested debt that is not legacy at all because a test suite protects every change.

Is all old code legacy code?

No. Age is not the test. A twenty-year-old library with a thorough test suite and clear documentation is not legacy in the practical sense, because you can change it with confidence. A service merged last week with no tests, no docs, and a single author who has left the company is legacy from day one. What makes code legacy is the absence of a safety net and the resulting fear of changing it, not the date in the commit history.

How do you start working with legacy code safely?

Start by getting a safety net in place before you change anything. Write characterisation tests that pin the current behaviour, even if that behaviour is wrong, so you will know immediately if a later change alters it. Then break only the dependencies you need to test the part you are about to touch, using techniques like sprout method or introducing a seam. Make the smallest change you can, run the tests, and ship. Modernise incrementally rather than attempting a full rewrite.

Should you rewrite legacy code or refactor it?

Refactor in almost every case. Full rewrites are slower, riskier, and more expensive than they look, because the old code holds years of accumulated business rules and edge cases that are easy to miss. Incremental modernisation under tests, often using the strangler fig pattern, delivers value sooner and keeps the system working throughout. Reserve a rewrite for code built on genuinely unsupported technology or so small that replacing it is trivial.

Enjoyed this article? Get more developer tips straight to your inbox.

Comments

Join the conversation. Share your experience or ask a question below.

0/1000

No comments yet. Be the first to share your thoughts.