How to deliver high-quality software with AI-native development / Keep the codebase easy to work in
Refactoring with agents without changing behaviour
Refactoring is the work agents are best at and the work where their failure mode is most dangerous: a change that looks like restructuring and alters what the software does without anyone noticing. The protection is a behaviour guarantee established before the refactor starts, plus a discipline of never mixing restructuring with a functional change.
Published September 1, 2026. Editorial.
Key takeaways
- Refactoring means behaviour does not change. If behaviour changes, it is a rewrite and it needs the review a rewrite deserves.
- Capture the current behaviour first, including the parts that are wrong, then refactor against that record.
- Never mix a refactor with a functional change in one diff. Separating them is what makes both reviewable.
- Agents are unusually good at mechanical refactors and unusually confident about semantic ones. Treat the two differently.
Refactoring is where AI-native development gives the clearest benefit. Work that used to be too tedious to justify, such as renaming a concept across four hundred files or splitting a module that grew badly, is now an afternoon.
It is also where the most expensive mistakes happen, because a refactor touches a lot of code, produces a diff nobody reads line by line, and is assumed by everyone to be safe.
The definition matters here
A refactor changes structure and leaves behaviour identical. That is the agreement, and it is the reason a refactor can get a lighter review than a feature.
When an agent refactors, it will frequently improve things while it is there: tighten a validation, fix an obvious bug, remove a branch that looks unreachable, modernise an old pattern. Every one of those is a behaviour change, and each is delivered inside a diff that everyone has agreed is safe to skim.
The unreachable branch is the most common example. It usually turns out to be reachable for one customer, or in one region, or on the first of the month. The bug fix is the other one: it is often a fix, and it is also a change to what the system does, which is exactly what a refactor is supposed to not do.
So the first rule defines what counts as a refactor. A refactor either preserves behaviour or it is a different kind of change and gets a different level of review.
Capture behaviour before you touch anything
The protection is a record of what the system currently does, made before the refactor and agreed as the reference. Not what it should do: what it does, including the parts that are wrong.
That distinction is the one teams get wrong. If your baseline is the specification, then any place the current code deviates from the specification will produce a difference during the refactor, and you will not be able to tell an intentional fix from an accidental change. Take the baseline from the running system.
Practically, this means characterisation tests over the existing behaviour of whatever you are about to restructure, generated from the current implementation and run against it before any change. This is the one case where tests derived from the implementation are the right tool, which is a deliberate exception to the rule in test strategy when the AI writes the tests too. There the risk is that tests agree with wrong code. Here agreement with the current code is exactly the property you want, because you are trying to detect change rather than incorrectness.
Where the code is hard to test directly, record real inputs and outputs from production or from a replay, and compare before and after. The comparison is what matters, not the mechanism.
Two kinds of refactor, two levels of care
Mechanical. Rename, move, extract, change a signature, apply a pattern uniformly. Agents do this well, faster and more consistently than people, and a compiler or type checker catches most mistakes. This is where the value is, and it can proceed with a behaviour baseline and normal gates.
Semantic. Redesign an abstraction, change how state flows, replace a data structure with one that has different performance or ordering characteristics, merge two concepts into one. Here the agent's confidence is not matched by its ability to know what depended on the old behaviour. These need a human deciding the target design first, then the agent doing the mechanical parts of getting there.
The mistake is treating the second as the first because both are called refactoring in the ticket. If the change alters what happens when something goes wrong, or the order things happen in, or what a caller can rely on, it is semantic.
Sequence for a large refactor
Six steps, in this order, and the order provides most of the safety.
One, fix the target. Write down the end state in a few lines. Without it, six sessions will move toward six different designs, which is the inconsistency problem in keeping one codebase when everything is generated.
Two, baseline the behaviour. As above, from the running system.
Three, add the new alongside the old. Both paths exist and the old one is still in use. Nothing has changed for users yet.
Four, move callers in small batches. Each batch is its own change, separately reviewable and revertible, and behind a flag where the risk warrants it.
Five, wait. Let each batch run in production long enough to see the failure modes that only appear under real load and real data.
Six, delete the old path. This is the step teams skip, and skipping it leaves both paths in the codebase forever, which is worse than not having started.
Do not mix in a functional change
The single most useful discipline in this whole page: a diff either restructures or changes behaviour, never both.
When they are mixed, a reviewer cannot tell which lines are the mechanical noise and which are the decision, so the decision goes unreviewed. It also makes a rollback impossible to reason about, because reverting the refactor also reverts the fix, and reverting the fix may not be safe.
If an agent notices a genuine bug during a refactor, and it will, the right action is to record it and fix it in a separate change, before or after. Yes, this is slower in the moment. It is the difference between a refactor you can revert and one you cannot.
Proving it in production, not only in tests
For a refactor of anything important, the strongest evidence is a comparison under real traffic rather than a passing test run.
The technique is to run both paths for a period and compare their outputs. The old path serves the response, the new path runs alongside, and any disagreement is recorded rather than shown to a user. After enough real traffic, the disagreement log tells you exactly where the two implementations differ, on the inputs that actually occur rather than the ones someone imagined.
It sounds expensive and it usually is not, because it is temporary and it only needs to cover the operation being changed. What it gives you is the ability to find the difference that no test would have caught: the customer whose data has a format nobody knew existed, the encoding that only appears in one region, the ordering that mattered to a dependent system nobody thought to ask about.
Where a full comparison is impractical, a cheaper version works: log a hash of the result from both paths and compare the hashes. You lose the detail of what differed and you keep the signal that something did, which is enough to stop a migration before it reaches everyone.
When not to refactor at all
Two cases.
If nobody can say what the code currently does, the first job is understanding, not restructuring. Refactoring code whose behaviour you cannot characterise is a rewrite with no specification, and it will produce a system that is cleaner and subtly different. That situation has its own approach in what it takes to fix a vibe-coded app.
And if the code is stable, rarely touched, and works, leave it. Cheap refactoring creates a temptation to tidy things that were not costing anything, and every change carries risk. Spend the capacity where the change rate is high, because that is where consistency and clarity actually reduce cost.
A useful way to decide is to look at the last six months of changes and rank the files by how often they were touched. The top of that list is where refactoring is worth it, because every future change gets cheaper. The bottom of it is code that has kept working without attention, and the expected value of restructuring it is negative: you are taking risk on code that does not need changing. This is also a good use of an agent, since producing that ranking from version control history takes it a minute and is tedious by hand.
Where this connects
Safe refactoring is what makes consistency work possible rather than theoretical, and it depends on the behaviour evidence discussed in test strategy and the release controls in progressive delivery. The main guide has the full model, and this is a large part of what a custom software development engagement involves on an existing system.
Best for
- Mechanical work: renames, moves, extractions, signature changes, applying one pattern uniformly
- Code that changes often, where consistency and clarity reduce the cost of the next change
- Situations where a behaviour baseline can be captured from the running system first
Avoid if
- Nobody can say what the code currently does, in which case understanding comes first
- The code is stable, rarely touched, and working
- The change would alter error behaviour, ordering, or anything a caller relies on, which makes it semantic rather than mechanical
Check before you decide
- Confirm the behaviour baseline was taken from the running system, not from the specification
- Confirm the diff contains no functional change mixed in with the restructuring
- Confirm the old path is scheduled for deletion, with an owner, rather than left in place indefinitely
Common questions
What makes AI refactoring risky if the tools are good at it?
The failure is hidden inside work everyone treats as safe. Agents improve things while restructuring: tightening a validation, fixing an obvious bug, removing an apparently unreachable branch. Each is a behaviour change delivered inside a large diff that reviewers skim precisely because it was labelled a refactor.
How do we guarantee a refactor did not change behaviour?
Capture what the system currently does before touching it, using characterisation tests over the existing implementation or recorded real inputs and outputs, then compare after. The baseline must come from the running system rather than the specification, or you cannot tell an intentional fix from an accidental change.
Is it ever right to write tests from the implementation?
Yes, in exactly this case. Normally tests derived from the code prove only that the code agrees with itself, but for a refactor that agreement is the property you want: you are trying to detect change rather than incorrectness. Outside a refactor baseline, derive expectations from the specification instead.
What is the difference between a mechanical and a semantic refactor?
Mechanical work is renaming, moving, extracting and applying a pattern uniformly, where a compiler or type checker catches most mistakes and agents excel. Semantic work redesigns abstractions, changes how state flows, or alters ordering and error behaviour. The second needs a human to decide the target design before an agent executes the steps.
Should a refactor and a bug fix go in the same change?
No. When they are mixed, a reviewer cannot separate mechanical noise from the actual decision, so the decision goes unreviewed, and a rollback becomes impossible to reason about because reverting the restructuring also reverts the fix. Record the bug and fix it in its own change before or after.
What is the safe sequence for a large refactor?
Fix the target design in writing, baseline the current behaviour, add the new path alongside the old, move callers in small separately revertible batches, let each batch run in production long enough to expose real-load failures, then delete the old path. The final deletion is the step teams skip, which leaves both paths forever.
Why does the old code path have to be deleted?
Because leaving both is worse than never starting. Two paths mean two behaviours to reason about, two places for a future change to go, and an ambiguity about which one runs for whom. Give the deletion an owner and a date at the moment the migration begins, or it will not happen.
When should we not refactor at all?
When nobody can characterise what the code currently does, and when the code is stable, rarely touched and working. In the first case you would be rewriting without a specification; in the second you are taking risk on code that costs nothing today. Spend refactoring capacity where the change rate is high.
How large should each step of an agent-driven refactor be?
Small enough to review and revert on its own. Batch the caller migrations rather than converting everything in one diff, and put risky batches behind a flag. The cost of a large refactor going wrong is that reverting it takes every unrelated improvement in the same change with it.
Who decides the target design of a refactor?
A person, before generation starts, written down in a few lines. Without a fixed target, several agent sessions move toward several reasonable designs and the refactor produces a new style rather than a cleaner codebase. Executing the steps toward that target is exactly the work to delegate.
Related reading
When to rewrite vs refactor legacy code (and why the big rewrite is usually wrong)
The full rewrite feels clean and honest, and it is almost always the wrong instinct. Here is when a rewrite is genuinely justified and how to replace old code piece by piece instead.
What to do when the AI writes the wrong abstraction
A wrong abstraction does not fail a test or break a build. It makes the next twenty changes harder without anyone noticing, and by the time someone does, removing it has become a project of its own.
How to take over a codebase you did not write
Someone hands you a working system and leaves. The instinct is to read it. The better first step is to find out what it guarantees, because the code will tell you what it does and never what it was supposed to do.
More in Keep the codebase easy to work in
Keeping one codebase when everything is generated
Every agent session starts without memory of the last one. Given the same problem twice it will produce two reasonable solutions that differ, and a year of that turns one codebase into a collection of different styles. This page covers why this drift (the slow spread of different styles) is the default, which conventions are worth enforcing mechanically, and how to keep a system a person can still understand as a whole.
Documentation an agent and a new hire can both use
Documentation used to be read occasionally by people. It is now read constantly by agents, which changes what is worth writing. Short, current, and in the repository beats comprehensive and out of date, because a wrong document does not just mislead a new hire once, it is loaded into every session and reproduced at volume.