Skip to content

What Is Legacy Code — Junior Level

Table of Contents


The one-sentence definition that changes everything

If you ask ten engineers what "legacy code" means, you will hear ten variations of the same vague idea: old code, written by someone who left, that nobody wants to touch. That definition feels right, but it is useless. It tells you nothing about what to do. You cannot act on "old." You cannot act on "someone else wrote it."

Michael Feathers, in Working Effectively with Legacy Code, gave a definition that you can actually act on:

Key idea: Legacy code is simply code without tests.

That is it. Not old code. Not bad code. Not code in a language you dislike. Code that has no automated tests around it is legacy code, and code that is covered by good tests is not — regardless of how old, ugly, or foreign it is.

This sounds almost too simple, even arbitrary, the first time you hear it. Stay with it, because once you adopt this definition, your whole relationship with the codebase changes. The question "Is this legacy?" stops being an emotional judgment ("ugh, this is gross") and becomes a factual, checkable one: Can I change this code and get told within seconds whether I broke something? If the answer is no, it is legacy code, and you now know exactly what to do about it — add the feedback.

Why the everyday definition fails you

Let us put the two definitions side by side, because the contrast is the whole point.

Property Colloquial definition ("old code I didn't write") Feathers' definition ("code without tests")
Is it actionable? No — you can't make code younger Yes — you can add tests
Does it blame people? Yes — "someone else's mess" No — it's about the code's safety net
Can brand-new code be legacy? No (it's new!) Yes — untested code is born legacy
Does it predict pain? Weakly Strongly — no tests means no feedback
Does it tell you the fix? No Yes — get it under test

Look at the row that says untested new code is born legacy. This is the most surprising and most important consequence. The pull request you merged yesterday — three hundred lines, zero tests, shipped under deadline pressure — is already legacy code. It is one day old. The person who wrote it is sitting next to you (it might be you). And yet it has the exact same problem as the twenty-year-old module everyone fears: if someone changes it, nothing will tell them whether they broke it.

Key idea: "Legacy" is a property of the code's safety, not its age or author. New, untested code and old, untested code share the same disease.

This reframing is liberating. It stops legacy work from being a moral story about lazy predecessors and turns it into a practical, neutral engineering task: find the code without feedback, and give it feedback.

A vivid analogy: rewiring a house with the power on

Imagine you are an electrician asked to add a new outlet in an old house. There are two very different situations you could walk into.

House A has a clearly labelled breaker panel. Every circuit is mapped. You flip the breaker for the room you're working in, your voltage tester confirms the wires are dead, you do your work, you flip the breaker back, and a little indicator confirms power is restored and nothing else tripped. If you make a mistake, the breaker pops immediately and safely. You work calmly.

House B has an unlabelled panel, no map, and you cannot tell which wires are live. To add the outlet, you must touch wires while suspecting they might be carrying current, and you will only find out you were wrong when something sparks, a fuse blows in another room, or worse. You work slowly, sweating, touching as little as possible, hoping.

House A is code with tests. House B is legacy code. The wiring might be identical in both houses — the difference is whether you have a way to know, quickly and safely, whether your change is okay. Tests are the breaker panel and the voltage tester. They are the thing that turns "touch it and hope" into "touch it and know."

HOUSE A (tested)                  HOUSE B (legacy)
+------------------+              +------------------+
|  [breaker panel] |              |   ??? unlabelled |
|  flip -> safe    |              |   live? dead?    |
|  tester confirms |              |   touch & pray   |
|  mistake -> pops |              |   mistake -> ???  |
+------------------+              +------------------+
   calm, fast                        slow, fearful

Edit and pray vs. cover and modify

Feathers names the two ways people change code, and the names stick because they are honest.

Edit and Pray. You read the code until you think you understand it. You make your change. You poke at the running application a bit — click around, run it once — and if nothing obviously explodes, you ship it. You are praying that there were no other places affected by your change, no edge cases you didn't exercise, no callers you didn't know about. Most of the industry, most of the time, works this way. It is not lazy; it is what the codebase allows when there is no safety net.

Cover and Modify. First you put a protective layer of tests over the area you're about to change — you "cover" it. These tests capture how the code behaves right now. Then you make your modification. After every small change, you run the tests. If a test goes red, the safety net caught your mistake in seconds, while the change is still fresh in your mind and easy to undo. You modify with confidence because the cover tells you the truth.

# EDIT AND PRAY
def apply_discount(price, customer):
    # ... 40 lines you half-understand ...
    return final_price          # change something, hope, ship

# COVER AND MODIFY
def test_loyal_customer_gets_ten_percent_off():
    assert apply_discount(100, loyal_customer()) == 90   # pin current behavior FIRST

# ...now you can change apply_discount and the test will scream if you break this case

Key idea: Legacy code forces you into "edit and pray." The entire discipline of working with legacy code is about earning the right to "cover and modify."

A first concrete example

Here is a small function. Is it legacy code? Use the definition.

// Order.java
public class Order {
    public double total(List<Item> items, boolean isMember) {
        double sum = 0;
        for (Item item : items) {
            sum += item.price() * item.quantity();
        }
        if (isMember) {
            sum = sum * 0.9;          // 10% member discount
        }
        if (sum > 100) {
            sum = sum - 5;            // $5 off orders over $100
        }
        return sum;
    }
}

The code is short and clean. It is not old. You might have written it five minutes ago. But ask the only question that matters: if I change it, will anything tell me whether I broke it? If there is no test calling total(...) and checking the result, then yes, this is legacy code. Clean, young, and legacy all at once.

Now watch how cheaply we move it out of the legacy category. We do not rewrite anything. We just add feedback:

// OrderTest.java
@Test void memberOverHundredGetsBothDiscounts() {
    List<Item> cart = List.of(new Item(60.0, 2));   // 120.00
    double result = new Order().total(cart, true);
    // 120 * 0.9 = 108, then -5 = 103
    assertEquals(103.0, result, 0.001);
}

@Test void nonMemberUnderHundredPaysFull() {
    List<Item> cart = List.of(new Item(40.0, 1));   // 40.00
    assertEquals(40.0, new Order().total(cart, false), 0.001);
}

That is the whole transformation. The function is now under test. The next person who touches total — to fix a rounding bug, to add a coupon — will be told in milliseconds if they break the member discount. We turned House B into House A by adding a breaker panel.

Fear: the real cost of legacy code

Why does legacy code matter so much in practice? Because of what it does to people. Untested code makes engineers afraid, and fear quietly damages the codebase in two specific ways.

1. They avoid touching it. When a module has no tests and a reputation for breaking, people route around it. Needed cleanup doesn't happen. The bug that should be fixed inside the messy function gets a guard clause bolted on outside it. The code rots not because anyone is careless, but because everyone is being careful in the wrong direction.

2. They copy-paste instead of changing. Rather than modify the scary function — and risk breaking its other callers — engineers copy it, tweak the copy, and call the copy from their new feature. Now there are two near-identical functions, then three. A bug fixed in one is still alive in the others. The duplication that haunts old systems is very often fear made visible.

Fear loop:
   no tests  ->  changing feels dangerous  ->  avoid / copy-paste
        ^                                              |
        |                                              v
   code gets worse  <-  duplication & untouched rot  <-+

Key idea: Legacy code is expensive less because it is hard to read and more because it makes good engineers leave it worse — out of justified fear.

Legacy code is normal, not a failure

A junior's instinct is to see legacy code as a sign that something went wrong — a project that was mismanaged, a team that cut corners. Sometimes that's true. But mostly, legacy code is the normal, healthy condition of any software that is actually being used.

Software that people depend on keeps getting changed, because the world it serves keeps changing — new regulations, new features, new platforms. Two classic observations (known as Lehman's laws of software evolution) capture this:

  • Continuing change: software that is used must keep changing, or it becomes progressively less useful.
  • Increasing complexity: as you keep changing software, its complexity grows unless you actively spend effort to reduce it.

Put those together and you get the truth every working engineer eventually learns: all successful software trends toward legacy. Only dead software — software nobody uses — stays clean and finished. So the goal is never "have no legacy code." The goal is to get good at working with it: bringing untested code under feedback, change by change, so the system you depend on stays changeable.

What "tests" actually buy you

When we say "tests," we don't mean tests as a box-ticking ritual or a coverage number to please a dashboard. We mean a very specific superpower: fast, automated feedback about whether the code still does what it did. A good test for legacy work has three properties:

  • Fast — runs in milliseconds, so you run it constantly while you work.
  • Reliable — green means safe, red means broken; it doesn't flicker for unrelated reasons.
  • Focused — when it fails, it points you near the actual problem.

A test that takes ten minutes, needs a real database, and fails randomly does not give you the breaker-panel feeling. Part of working with legacy code is learning to write tests that genuinely deliver fast, trustworthy feedback — which often means breaking the code's hidden connections to databases, clocks, and networks so it can run in isolation.

The legacy dilemma in one picture

Here is the puzzle that the rest of this section exists to solve. It is genuinely circular, and recognizing the circle is the first step:

   To change code safely  ──►  you need tests
            ▲                        │
            │                        ▼
   (...but that's risky)   ◄──  to add tests, you often
                                must first change the code
                                (break its dependencies)

To change scary code safely, you want tests. But the code is often untestable as written — it reaches out to a database, a clock, a network, hard-wired connections that a test can't supply. To get it under test, you must first change it (loosen those connections) — and changing untested code is the very thing you were trying to make safe. You need tests to change safely, but you need to change to add tests.

This is the legacy dilemma, and it is not a trick or a paradox you're meant to be stuck in. There are careful, well-known techniques for making the smallest, safest possible changes — changes so minimal and mechanical that you can do them by hand or with the IDE without needing tests yet — just enough to slip a test into place. Once one test is in, you have a foothold, and you expand from there.

What this section will teach you

This topic defined the problem. The rest of the section gives you the tools to solve it. Here is the map:

The broader craft sits alongside in ../../refactoring/ and ../../craftsmanship-disciplines/.

Mini Glossary

Term Plain-English meaning
Legacy code Code without tests — code you can't change with fast feedback about whether you broke it.
Edit and pray Changing code, eyeballing it, and hoping nothing broke. The default when there's no safety net.
Cover and modify Putting tests over the code first, then changing it, running the tests after each step.
Feedback Being told quickly and automatically whether your change preserved behavior. The thing tests provide.
Characterization test A test that captures what the code currently does, not what it should do.
Seam A place where you can change the code's behavior without editing that spot directly — the way you insert tests.
Dependency breaking Loosening the code's hard connections (DB, clock, network) so it can run, and be tested, in isolation.
The legacy dilemma You need tests to change safely, but you often must change the code to add tests.
Lehman's laws Observations that used software must keep changing, and grows more complex as it does.
Technical debt The accumulated cost of shortcuts; related to legacy code but not the same thing (lack of tests is one kind).

Review questions

  1. State Feathers' definition of legacy code in one sentence. Why is it more actionable than "old code nobody wants to touch"?
  2. A teammate says, "I just wrote this module this morning, so it can't be legacy code." Under Feathers' definition, are they right? Explain.
  3. Describe the difference between "edit and pray" and "cover and modify." Which one does legacy code push you toward, and why?
  4. In the house-rewiring analogy, what do the tests correspond to? What does it feel like to work in "House B"?
  5. Name the two specific ways that fear of untested code makes a codebase worse over time.
  6. Why is it wrong to think of legacy code as a sign of failure? Reference at least one of Lehman's laws.
  7. The function total(...) earlier was clean and brand-new. Explain why it was still legacy code, and what minimal step removed it from that category.
  8. Describe "the legacy dilemma" in your own words. Why doesn't this leave us permanently stuck?
  9. List three properties that make a test genuinely useful for legacy work (as opposed to a slow, flaky test).
  10. A function reaches out to a real database. Why might you need to change that function before you can write a fast test for it?