HomeProjectsBlogContact

Working with Legacy Code: How to Change It Without Breaking It

Every developer inherits code they didn't write, without tests or documentation. How to understand an unfamiliar codebase quickly, add safety nets, refactor in small steps and avoid the big rewrite trap.

Working with Legacy Code: How to Change It Without Breaking It

Sooner or later, you'll inherit a codebase that nobody fully understands: no tests, outdated dependencies, mysterious workarounds and a comment that says // don't touch this. That's legacy code, and working with it is a core professional skill. Most of the code in the world is legacy code, and most of it is running something important.

First, understand before changing

Resist the urge to "clean it up" on day one. Weird code often exists for reasons you can't see yet.

Get it running locally

Follow the README if there is one. Note every step that's missing or wrong, then fix the README. You've already improved the project. (Writing good docs.)

Read from the entry points

Start where requests enter (routes, controllers, main functions, scheduled jobs) and follow one important flow end to end, like "user places an order." Draw it out on paper or in a diagram tool. A single traced flow teaches you more than skimming a hundred files.

Use Git as a history book

git log --follow -p path/to/file.js    # how did this file evolve?
git blame path/to/file.js              # who changed each line, and in which commit?
git log -S "calculateDiscount"         # when was this text added or removed?

Commit messages and linked tickets often explain the strangest-looking code: "workaround for supplier API returning negative stock." Now the weird line makes sense.

Ask people

If anyone who worked on it is around, a 20-minute conversation can save days. Ask about the scary parts, known bugs and the things nobody dares to change.

Add a safety net before refactoring

Changing untested code is gambling. Before modifying important behaviour, add characterization tests: tests that capture what the code currently does, not what it should do.

test('current discount behaviour for gold members over 100', () => {
  // We don't know if this is "right", but it's what production does today.
  expect(calculateDiscount({ tier: 'gold', total: 150 })).toBe(22.5)
})

These tests let you refactor with confidence: if behaviour changes unexpectedly, you'll know immediately. Integration tests around key endpoints are often the fastest way to get broad coverage on a legacy app. (Testing strategy.)

Change things in small, safe steps

  • Make the change easy, then make the easy change. First refactor so the new feature fits naturally (tests still green), then add the feature.
  • One kind of change per commit: rename in one commit, restructure in another, change behaviour in a third. It makes reviews and reverts painless.
  • Leave it a little better every time you touch a file: a clearer name, an extracted function, a deleted dead branch. (Clean code tips.)
  • Use your tools: automated refactorings in your editor (rename, extract function, move) are much safer than manual edits.

Find seams

A seam is a place where you can change behaviour without editing much code: a function parameter, an interface, a configuration value. Introducing seams, for example passing a dependency in instead of creating it inside a function, lets you test and replace parts gradually.

Avoid the big rewrite

"Let's just rewrite it from scratch" is tempting and frequently disastrous. Rewrites take longer than expected, the old system keeps changing in the meantime, and years of hidden knowledge (all those weird workarounds) get lost and rediscovered as bugs.

Prefer the strangler fig approach:

  1. Put a routing layer in front of the old system.
  2. Build new functionality, or rebuild one area at a time, in the new code.
  3. Route that area's traffic to the new code.
  4. Repeat until the old system has nothing left to do, then remove it.

Each step delivers value and can be rolled back. (Architecture choices.)

Upgrading old dependencies

Legacy projects often run on very old framework versions. Upgrade incrementally: one major version at a time, following official migration guides, with tests run after each step. Jumping several major versions at once multiplies the risk. (Dependency management.)

Keep your morale up

Legacy work can feel thankless. Remember that this code is valuable: it's earning money or serving people right now. Make progress visible (fewer bugs, faster builds, better docs), and celebrate small improvements. They add up.

FAQ

What is legacy code?

Code that's hard to change safely, often older, written by people no longer around, and usually lacking tests or documentation, but still providing real value in production.

How do I understand a large unfamiliar codebase?

Get it running, trace one important flow from entry point to database, read Git history for context, ask people who worked on it, and write down what you learn.

Should I rewrite legacy code from scratch?

Usually not. Incremental refactoring with tests, or replacing pieces gradually with the strangler fig pattern, is far less risky than a full rewrite.

Keep reading

More from the workbench

All articles →