RSS Amplifier

Code That Makes Sense · Aug 24, 2026

Reading Legacy Code Without Losing Your Mind

0
Sign in to vote or save

Attila Fejér · Code That Makes Sense

Every codebase has one module that developers route around like a sinkhole in the road. Nobody knows exactly what it does. Everybody remembers what happened to the last person who touched it1.

The usual instinct is to refactor it into something better. The instinct is right, but the timing is wrong. Refactoring without understanding produces a cleaner version of the wrong behavior, and the wrong behavior is now harder to spot because the code looks trustworthy.

So before we fix anything, we map it. In this post, we’ll look at three techniques for building that map, where to start, and when to stop. None of them need a rewrite budget, and they all fit into a regular week.

In Working Effectively with Legacy Code, Michael Feathers gives the classic definition:

Legacy code is code without tests.

It’s a great working definition, and the book belongs on the classics shelf. Another definition I like very much describes the same code from the inside2:

Legacy code is code we’re afraid to change.

The two point at the same root cause. Tests are the artifact and fear is the symptom. What’s missing underneath is understanding. A test suite nobody understands doesn’t make a module less scary. The team still walks on eggshells around it, because green checkmarks tell us the behavior didn’t change, not what the behavior is. The inverse is also true: a module we deeply understand but never covered with tests is just an afternoon of test writing away from safety3. So my working definition is the following:

Legacy code is code without understanding. Tests are how we capture that understanding so it survives the next reorg.

The beauty of this framing is that tests solve both older definitions at once. When we write them, we gain understanding. When they’re in place and their quality is good, they give us the confidence to change the code.

This reframing changes how we start. The first step in dealing with legacy code should be mapping, not fixing. And no, asking an AI assistant to summarize the module doesn’t count4. The summary is a useful starting point, but a summary we read isn’t a map we built. In my experience, the understanding that predicts what breaks only forms in the head that drew the boxes.

Three techniques do most of the work, and none of them is expensive. Each one produces something we can show a colleague.

Dependency mapping is exactly what it sounds like: we draw what calls what. Four questions drive the drawing. What does the module call? What calls the module? What data does it read? What data does it write?

The boring edges matter most. Config flags, scheduled jobs, event listeners, and that one queue everybody forgot about are the dependencies that wake us up at 3 a.m.

Let’s take an overtime-calculation module in a payroll system. The class itself looks small, but the map shows it reads the hourly-rates table, writes payslip records, and gets called by the HR API, by a nightly batch job that adds hours, and by a month-end run that exports bank transfers. The “small” class has five neighbors, and two of them only wake up after midnight.

Tools can generate dependency graphs, and they’re worth a look. But a generated graph skips the step that matters: for me, drawing is how the structure enters my head. Usually pen and paper or a whiteboard works even better than a drawing tool5.

When the module resists mapping from the outside, the Mikado Method by Ola Ellnestam and Daniel Brolund6 draws the map for us. Try the change we actually want, write down what breaks as a prerequisite, revert, and repeat with each prerequisite. The result is a dependency graph drawn by the code itself, and the revert-every-time rule keeps the code working through the whole exploration.

Characterization tests also come from Working Effectively with Legacy Code. They’re tests that document current behavior, not desired behavior. The procedure feels backward the first time: write an assertion with the value we expect, run it, let the failure tell us what the code actually does, then pin the actual value7.

Let’s see what that looks like for the overtime calculator:

We expected the Sunday night shift to pay the weekend multiplier of 1.5. The failing test told us the calculator applies the weekday multiplier to every night shift, weekends included. So 1.25 goes into the assertion, weirdness and all. If the current behavior includes a bug, we pin the bug too, on purpose, because someone downstream may depend on it8.

The understanding comes from exactly this observe-and-fix loop. We don’t study the implementation, we watch the output and pin it. Every pinned assertion is an example of what the code does, and at this point we don’t care about how it does it.

A characterization test doesn’t claim the code is right. It claims the code is understood. And that’s exactly what refactoring needs. Refactoring means changing the structure of the code without changing its externally observable behavior, and capturing the current behavior so it cannot drift is exactly what characterization tests are excellent at. They’re the safety net for every later refactoring, whether we type it ourselves or let an AI assistant propose it9. Without them, “the tests still pass” means nothing, because there were no tests describing what mattered.

The strangler fig pattern, named by Martin Fowler, grows new code around old code until the old code can be removed. We covered the full pattern in Sustainable Refactoring. Today’s job is smaller: find the entry points.

Entry points are the seams10 where calls enter the module: an API boundary, a message handler, a facade, a job trigger. We mark each one on the dependency map. Every entry point is a place where new code could wrap old code and where a characterization test harness can attach. Instrumentation at the same seam tells us what production actually sends in.

We aren’t strangling anything yet. We’re noting where the fig could take root, so that when the refactoring finally starts, it starts at a seam instead of in the middle of a well-structured 900-line method.

Understanding is an investment, so we put it where it compounds. Start with the module we change most often. The frozen horror in the corner can stay frozen until a change request finally defrosts it, but a map of code we never touch is decoration. Git already knows where the churn is:

The top of that list is where mapping pays back within weeks11.

The second rule is a timebox: 2 hours per module, maximum. If we can’t map a module in 2 hours, the module is too big, and that’s a finding, not a failure. The places where the map refuses to fit on one page are the places where the module wants to be split. That’s the simplification principle doing its job: when a problem resists understanding as a whole, we break it into pieces that don’t.

  • Legacy code is code without understanding, and the first step should be mapping, not fixing.

  • Three techniques build the map: dependency mapping (draw what calls what), characterization tests (tests that document current behavior, not desired behavior), and strangler fig entry points (mark where new code can wrap old code).

  • Start with the module we change most often. That’s where understanding has the highest ROI.

  • Timebox the mapping: 2 hours max. A module we can’t map in 2 hours is too big, and that’s itself a finding.

Refactoring starts with the first box we draw.

Pick the module you touched most recently. Spend 30 minutes drawing its dependencies on paper: what it calls, what calls it, what data it reads, what data it writes. Pin the drawing next to your monitor. You’ll refer to it more often than you expect.

Media attributions:

  • Cover image by the author (generated with Gemini)

1

They're fine. They're in management now.

3

And it's worth spending that afternoon future-proofing that understanding and building a safety net for new joiners.

4

The assistant has read our code the way we all read terms and conditions.

5

They have underrated upsides: they never need a plugin update or run out of battery.

6

The method is named after the pick-up-sticks game: the whole skill is removing one stick without disturbing the rest.

7

A close cousin is the golden master technique: pin an entire output (for example, a report or a JSON response) instead of one value. Libraries like ApprovalTests automate the pinning.

9

The net only catches us if the tests are reliable. Non-deterministic code (clocks, randomness, hidden shared state, current date and time) makes characterization tests flaky, so it’s worth extra effort to make them deterministic. Sometimes that means an obvious refactoring first, because code that wasn't designed to be testable is most often not testable.

10

"Seam" is Feathers's term of art: a place where we can change the program's behavior without editing it in that place.

No posts

Read the original on codethatmakessense.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.