teachyou.ai academy
← All posts
Claude Code

Claude Code for Legacy Codebases: A Practical Approach

Pramod Dutta · Jun 22, 2026 · 14 min read

The codebase nobody wants to touch

Every engineering team has one: the fifteen-year-old billing module, the PHP monolith that predates the current staff, the Java service with a 4,000-line class named Manager.java that somehow touches every subsystem in the company. Nobody fully understands it anymore. The original authors have moved on, the documentation is stale or missing, and the test coverage is thin enough that every change feels like surgery without anesthesia. This is legacy code — not because the technology is old, but because the knowledge required to change it safely has leaked out of the organization.

Most advice about AI coding assistants assumes you're starting from a blank file or a small, well-tested repo. Legacy codebases are the opposite: large, tangled, under-tested, and unforgiving of mistakes. This is actually where Claude Code earns its keep. It doesn't get bored reading a 2,000-line file. It doesn't need a lunch break before tracing a call chain through six modules. Used deliberately, it can compress the "figuring out what this code even does" phase from days to hours, and it can make refactors safer by keeping the blast radius visible at every step.

This article is a practical playbook, not a marketing pitch. It covers how to point Claude Code at unfamiliar code, how to build a mental model before touching anything, how to do refactors in small verifiable slices, and where the approach breaks down. If you maintain a codebase you're a little afraid of, this is for you.

Why legacy code is a different problem than greenfield code

When you write new code with an AI assistant, correctness is mostly local: does this function do what the docstring says, do the tests pass. When you touch legacy code, correctness is relational: does this change break the three other modules that depend on a side effect nobody wrote down, does it violate an implicit invariant that only shows up during month-end batch processing, does it change behavior that a customer is unknowingly relying on.

This changes what you should ask an AI assistant to do. In greenfield work, you want generation — write me a function, scaffold this component. In legacy work, you want comprehension first, and generation second, and the comprehension step is the one people skip when they're in a hurry. Claude Code is useful for both, but the ratio should shift heavily toward exploration and question-asking before any edit gets made.

The other big difference is trust calibration. In a new codebase, if the AI writes something subtly wrong, the cost is a code review comment. In a legacy codebase, if the AI "fixes" a bug that turns out to be load-bearing behavior, the cost can be a production incident. So the practical approach below leans hard on read-before-write, small diffs, and running things to confirm behavior — not just trusting a plausible-looking explanation.

Step one: build a map before you build anything

The single biggest mistake teams make when they bring an AI tool into a legacy codebase is asking it to make a change before anyone — human or model — actually understands the surrounding code. Skip this and you get changes that are locally correct and globally wrong.

Start every unfamiliar-codebase session with pure exploration prompts, no edits:

  • "Trace how a request to /api/invoices/:id flows from the router to the database. List every file it touches."
  • "Find every place that mutates the order.status field directly instead of going through OrderStateMachine."
  • "This module has no README. Summarize what it does based on the code, and flag anything that looks inconsistent with that summary."

Claude Code can do this kind of tracing well because it can actually run grep-style searches across the whole tree, open the files that matter, and follow imports rather than guessing from filenames. Ask it to show you the trail, not just the conclusion — a claim like "billing depends on the tax service" is much more useful when it comes with the three file paths and line numbers that prove it.

A pattern worth adopting: dedicate the first session on any unfamiliar module entirely to a walking tour, and have Claude Code produce a short written map of what it found — entry points, core data structures, the two or three functions that most other code calls into. You're not asking it to write anything durable yet. You're building the same mental model a new hire would build in their first week, except compressed into an afternoon.

Reading the git history as a source of truth

Legacy code rarely explains its own reasoning, but the commit history often does. A file that looks like an obvious "simplify this" candidate might have been shaped that way by three separate bug fixes, each one added to handle a real production incident.

Before touching a suspicious-looking piece of logic, have Claude Code pull the relevant history:

git log --follow --oneline -- src/billing/tax_calculator.py
git log -p --follow -- src/billing/tax_calculator.py | head -300

Feed that output back into the conversation and ask directly: "Based on this commit history, why might this function be written this way instead of the more obvious way?" You will frequently discover that the ugly branch in the middle of a function exists because of a currency-rounding bug from three years ago, or a customer in a specific tax jurisdiction that behaves differently. That context changes what a safe refactor looks like — you're not just preserving current behavior, you're preserving the reason the behavior exists.

This is also a good way to catch code that looks dead but isn't. git blame combined with a search for where a function is called can reveal that something invoked only from a nightly cron job is still very much alive, even though nothing in the main request path touches it.

Asking better questions than "explain this code"

"Explain this file" is a weak prompt. It produces a paraphrase, which is not the same as understanding. Stronger prompts push toward the kind of judgment a senior engineer would apply during a code review:

  • "What would break if I deleted this function? Search for every call site first."
  • "Are there two different code paths in this file that appear to do the same thing slightly differently? If so, which one is actually used in production, based on what calls it?"
  • "This function takes a legacy_mode boolean. Find every caller and tell me whether any of them still pass true."
  • "If I changed this SQL query to use a JOIN instead of a subquery, what would change about null handling or duplicate rows?"

Each of these forces Claude Code to actually search the codebase and reason about consequences rather than produce a generic summary. When the answer involves a claim about "nothing else calls this," ask it to show the search it ran and the results, not just the conclusion. It's much cheaper to catch a missed call site during the exploration phase than after you've deleted the function.

It's also worth explicitly asking about the negative space: "What error handling is missing here compared to the newer modules in this repo?" Legacy code often predates conventions the rest of the codebase now follows — a try/except pattern, a validation layer, a logging standard — and naming that gap early tells you what "modernizing" this file would actually require, separate from whatever specific bug you're fixing.

Refactoring in slices, not rewrites

Once you understand a piece of legacy code, the temptation is to rewrite it properly. Resist that on the first pass. Large rewrites of poorly-tested code are exactly where things go wrong, because you lose the ability to isolate what broke when something breaks.

Instead, work in slices that are each independently safe:

  1. Add characterization tests first. Before changing behavior, write tests that capture what the code currently does — including its warts. Ask Claude Code to generate tests based on observed behavior, not based on what the function "should" do.
  2. Make one mechanical change at a time. Extract a function. Rename a variable for clarity. Replace a magic number with a named constant. Each of these should be a diff small enough to read completely.
  3. Run the test suite after every slice. Not just once at the end. If a slice breaks something, you want to know which slice, not that "the refactor" broke something.
  4. Only then, consider structural changes. Once behavior is pinned down by tests and the code is more legible, larger changes — splitting a class, introducing an interface — become much lower risk.

Here's a concrete example of the kind of "slice" refactor that's low-risk and easy to verify — pulling a magic-number-laden conditional out into a named, testable function:

# before: buried inside a 300-line process_order() function
if order.total > 10000 and order.customer.country_code not in ("US", "CA"):
    requires_manual_review = True
else:
    requires_manual_review = False

# after: extracted, named, and independently testable
def requires_manual_review(order: Order) -> bool:
    """International orders above the high-value threshold need
    manual review per the 2021 fraud policy update."""
    HIGH_VALUE_THRESHOLD = 10000
    DOMESTIC_COUNTRIES = ("US", "CA")
    is_high_value = order.total > HIGH_VALUE_THRESHOLD
    is_international = order.customer.country_code not in DOMESTIC_COUNTRIES
    return is_high_value and is_international

Nothing about the underlying logic changed. But the extracted function is now independently testable, the magic number 10000 has a name, and — crucially — you can ask Claude Code to search for every other place in the codebase that repeats this same total > 10000 check, which is a very common legacy pattern: the same business rule copy-pasted in three places instead of centralized in one. Consolidating those into a single call to requires_manual_review() is a second, separate slice — don't do it in the same commit as the extraction.

Ask Claude Code to propose the slice boundaries explicitly: "Break this refactor into the smallest set of independently-testable steps." A model that jumps straight to a full rewrite hasn't internalized the constraint; push back and ask for smaller steps if that happens.

Working with tests that don't exist yet

Many legacy codebases have gaps in test coverage precisely in the areas that need the most careful handling — the code has survived without tests because nobody dared touch it, and nobody dared touch it partly because there are no tests. Breaking that cycle is one of the highest-value things you can do before a refactor.

A practical sequence:

  • Ask Claude Code to identify the three or four most important behaviors of the function or module, based on reading the code and its call sites.
  • Have it draft characterization tests for those behaviors using realistic inputs pulled from how the function is actually called elsewhere in the codebase, not synthetic edge cases invented from nothing.
  • Run the tests against the current code to confirm they pass — a red test at this stage means the test is wrong, not the code, since you haven't changed anything yet.
  • Only after those tests are green and committed, proceed with the refactor.

This inverts the usual complaint about legacy code — "there's no way to safely change this because there are no tests" — into a solvable problem. The AI doesn't need you to already understand every edge case; it needs you to point it at the function and the places that call it, and it can infer a reasonable set of test cases from that context. You should still read every generated test and correct anything that encodes a bug as if it were correct behavior — that's the one place human judgment can't be skipped, since the model has no way to know which quirks are "the bug we're trying to fix" versus "a customer-facing behavior we must not break."

Handling the "nobody knows why this is here" problem

Every legacy codebase has code that appears to serve no purpose, but removing it feels dangerous anyway. A commented-out block. A feature flag that's been true for four years. A conditional branch for a code path that seems unreachable.

Don't ask Claude Code "can I delete this" as a yes/no question — the honest answer to that question, absent full production telemetry, is usually "probably, but I can't be certain." Instead ask it to build the case either way:

  • "Search the entire codebase, including tests, migrations, and config files, for any reference to this feature flag."
  • "Check if this code path is reachable given the current values these variables can take. Trace backward from the callers."
  • "List every file that would need to change if I removed this, and estimate what could break."

Treat the model's answer as a strong lead, not a verdict. For anything with real consequences — feature flags controlling billing behavior, permission checks, anything touching money or data retention — pair the AI's analysis with a runtime check: grep production logs or metrics for the flag name, ask a teammate who was around when it was added, or add a temporary log statement and observe real traffic before removing anything. The AI is very good at exhaustively searching static code; it cannot see what's actually happening in production right now.

Where this approach breaks down

It's worth being honest about the limits, because overconfidence here is exactly what causes incidents.

  • Implicit runtime behavior. If a legacy system relies on timing, race conditions, or external service quirks that aren't visible in the code, static analysis — human or AI — won't catch it. You need runtime observation, not just code reading.
  • Extremely large context. A million-line monorepo can't be held in one conversation. Break exploration into module-sized chunks and build up a map incrementally rather than expecting one prompt to cover the whole system.
  • Business logic that contradicts its own comments. Legacy code sometimes has comments describing intended behavior that the code no longer matches, because someone patched the behavior without updating the comment. Treat comments as a hypothesis to verify against the actual code, not a fact.
  • Anything touching compliance, security, or financial correctness. Use the AI to accelerate your understanding and draft changes, but put a human reviewer — ideally one with domain authority — on anything with regulatory or financial consequences before it ships.

None of this is a reason to avoid the approach. It's a reason to keep the loop tight: explore, hypothesize, verify against real behavior, change in small slices, test after every slice.

A simple checklist for your next legacy touch

If you want a version of this you can actually use next time you open a scary file, here's the condensed version:

  • Read and trace before you edit anything — build a map of entry points, call chains, and data structures first.
  • Pull the git history on any function you're about to change; the ugly parts often have a reason.
  • Ask "what would break if I changed this" and demand the search results, not just an assertion.
  • Write characterization tests before refactoring, using realistic inputs drawn from actual call sites.
  • Refactor in the smallest independently-testable slices you can manage, running tests after each one.
  • Verify anything with real-world consequences — feature flags, financial logic, permissions — against production behavior, not just static code.
  • Keep a human in the loop for compliance, security, and money-touching changes, no matter how confident the analysis looks.

Legacy code is intimidating because the cost of being wrong feels high and the cost of understanding feels even higher. What changes with a tool like Claude Code isn't that the stakes get lower — it's that the understanding step gets fast enough that you can actually afford to do it properly before you touch anything. Teams that skip straight to "just make the change" are taking on the same risk they always did. Teams that use the assistant to build the map first are the ones who come out the other side with code they actually understand, not just code that happens to work today.

If you want to go deeper on this workflow with hands-on exercises — exploring real repositories, practicing safe refactor patterns, and building the habit of verifying before you trust — our Claude Code Tutorial for Beginners course on teachyou.ai walks through exactly this kind of practical, project-based approach, starting from the fundamentals and building up to working confidently in codebases you didn't write.