Using AI to Safely Modify Legacy Code
Practical techniques for using AI coding assistants on legacy and brownfield codebases — giving enough context, preventing over-modernization, and validating changes that can't break.
Legacy code is where AI coding assistants are simultaneously most useful and most dangerous. Useful because understanding a tangled, undocumented module is exactly the kind of tedious cognitive work AI is good at. Dangerous because AI optimizes for code that looks right — idiomatic, typed, clean — and legacy code often contains load-bearing strangeness that looks wrong but isn’t.
The techniques that work well on a greenfield codebase will fail you on a twenty-year-old billing system. This is a different game, and it needs a different playbook.
Why legacy code breaks AI’s default assumptions
AI coding assistants build a model of your code from context. On a new project, that model forms cleanly: types match their documentation, function names signal intent, patterns are consistent. Legacy code violates most of these signals:
No types, or wrong types. A JavaScript file with function process(data) gives the model almost nothing to reason about. It will infer something — probably something plausible — and then generate code that’s correct for its inference but wrong for your actual data shape.
Behavior that lives in the call site, not the function. Many legacy functions have callers that compensate for their quirks: the caller always passes a non-null value because the function crashes on null, or the caller strips a trailing slash before calling because the function can’t handle it. The function looks broken; the system works. An AI reading only the function will try to “fix” it, breaking the callers that were compensating.
Intentional workarounds. A conditional that looks redundant. A timeout that seems too long. A retry loop that checks a condition twice. These often exist because of a specific bug, a third-party API quirk, or an infrastructure constraint from five years ago. They have no comment because the person who added them assumed the context was obvious. AI will remove them.
Understanding that these failure modes exist changes how you use AI on legacy code.
Add a safety net before you change anything
The single most important step before using AI to modify legacy code is to write characterization tests — tests that document what the code does now, whether or not that’s what it should do.
Ask AI to help you write these. The prompt looks like this:
I'm about to modify processInvoice() in src/billing/invoice.js.
Before I change anything, help me write tests that capture its
current behavior — including edge cases that look wrong, like
null inputs and empty arrays. These tests define the contract
that must be preserved. Don't try to improve the function.
Just document what it actually does.
AI is good at this task because it requires reading and understanding, not opining about correct design. The output is a set of tests you can run after every change. When a test breaks, you know exactly what behavior shifted.
// Characterization test — documents current behavior, not intended behavior
describe('processInvoice (legacy behavior)', () => {
it('returns 0 for null items instead of throwing', () => {
// This looks like a bug but callers depend on it
expect(processInvoice(null)).toBe(0);
});
it('silently ignores items with no price field', () => {
const result = processInvoice([{ qty: 2 }, { qty: 1, price: 10 }]);
expect(result).toBe(10); // only the priced item counts
});
it('applies a 5% surcharge on invoices over 1000', () => {
expect(processInvoice([{ qty: 1, price: 1001 }])).toBe(1051.05);
});
});
These tests will feel uncomfortable — they’re asserting behavior you might want to change. That’s exactly the point. Changing them should be a deliberate choice, not an accident.
Give AI the call site, not just the function
When AI only sees the function you want to change, it’s reasoning with half the information. Paste in the callers too, or at minimum describe what they do.
I need to change how validateAddress() handles PO boxes.
Here's the function: [paste validateAddress]
Here are the two places that call it:
1. src/checkout/shipping.js line 47 — checks the return value and
shows an error if it's false
2. src/admin/bulk-import.js line 112 — calls it in a loop and
collects failures into an array
The current return type is boolean. Any change to the return type
will break both callers.
Without this context, AI might reasonably decide to return a structured result object instead of a boolean — an improvement in isolation that breaks everything downstream. With it, the constraint is explicit.
Prevent over-modernization with negative constraints
AI has a strong prior toward modern patterns. It will add TypeScript types to a JavaScript file if you don’t tell it not to. It will replace a callback-based API with Promises. It will swap a for loop for Array.reduce. Each of these changes looks like an improvement and introduces a subtle risk in code you don’t fully understand.
The fix is explicit negative constraints in your prompt:
Change the calculation logic in computeDiscount() to handle
the new tiered rate table in rates.js.
Constraints:
- Do NOT add TypeScript types or JSDoc
- Do NOT convert callbacks to Promises
- Do NOT rename any variables or parameters
- Do NOT change the function signature
- Change only the calculation logic between lines 34 and 58
The line range constraint is particularly useful. AI is much less likely to accidentally change error handling or logging code that’s outside the specified range if you name the range explicitly.
This kind of constraint prompt feels verbose. It’s worth it. The cost of an unnecessary cleanup change that you have to debug later is much higher than writing two extra lines of negative constraints.
Use AI to understand before you modify
For truly opaque code — something with no comments, no tests, and no obvious intent — run an understanding pass before a modification pass. They’re separate tasks, and mixing them produces worse results on both.
Understanding pass:
Read this function carefully. Tell me:
1. What does it do, in plain English?
2. What are the preconditions — what must be true about the inputs for it to work correctly?
3. What side effects does it have, if any?
4. Are there any parts that look intentionally strange that might be compensating for something external?
This gives you a model of what you’re working with. Then the modification prompt can reference that model:
Based on what we just established — that this function processes
invoices in two passes to avoid rounding errors accumulating —
I need to add a third pass that applies the regional tax rate.
The tax rate comes from rates[invoice.region] in the rates map
we already load. Don't change the two-pass structure.
Asking AI to think out loud about strange code before changing it is one of the highest-leverage moves on a legacy codebase. The explanation it produces also serves as documentation you can commit alongside the change.
The incremental cycle
Legacy code breaks when you make large changes. The same principle applies to AI-assisted changes: small, tested steps.
A workable rhythm:
- Understand: ask AI to explain the target code
- Constrain: add characterization tests and define scope constraints
- Modify: ask AI for a single, bounded change
- Verify: run the characterization tests; run the full suite
- Commit: commit the working change before proceeding
- Repeat: next change starts from a known-good state
Each commit becomes a rollback point. If AI produces a change that passes tests locally but turns out to have a subtle behavioral problem days later, you have a clean revert target. The discipline of committing each verified step is the same discipline that makes any safe refactoring work — AI just does the refactoring faster, which makes the discipline more, not less, important.
For large refactors, this cycle might run fifty times before you’re done. That’s the correct pace for code that can’t break. The Task Decomposition for AI Coding Agents pattern applies here too: each step should be small enough that verifying it is fast and unambiguous.
Rejecting changes that look right but aren’t
Get in the habit of scrutinizing AI-generated changes before accepting them, especially these patterns:
Removed code it didn’t understand. If AI’s diff removes a conditional branch or a fallback, read that removed code carefully. The fact that it looks unreachable or redundant is not sufficient reason to delete it. Understand what it was doing first.
Added imports or dependencies. Any new import in a legacy file is a risk — you’re adding a new dependency surface you have to verify. Check every added import, even utility functions from the same repo.
Scope creep into adjacent functions. Ask AI to change one function; it edits the helper that function calls because it noticed something to fix there too. Those touches are unreviewed changes to code you didn’t ask about. Either revert them explicitly or add them to your understanding-and-verification cycle.
The instinct to say “looks fine, ship it” is stronger with AI-generated code because it’s fluent and well-formatted. Resist it more on legacy code, not less.
The underlying principle
Legacy code has survived because it works. The strange parts are strange for a reason. AI can help you understand it, test it, and modify it safely — but only when you give it the context it needs to respect the implicit contracts that keep the whole thing running.
The overhead of characterization tests, explicit constraints, and incremental steps isn’t bureaucracy. It’s the cost of making changes safely in a codebase where a surprising amount depends on behavior you don’t fully understand yet. Cutting those steps doesn’t save time; it defers it to the debugging session.
For the prompting discipline that keeps AI from drifting during a long refactoring session, see Managing Context in Long AI Coding Sessions.