Engineering Practice ·1 Jul 2025 ·12 min

AI Code Generation and Refactoring: A Practical Guide

Refactoring existing code is a far better use of a model than greenfield generation, because the old behaviour and its tests give you ground truth. This is the craft: context, safe transformations, characterization tests, failure modes and diff review.

Pranav Begade By Pranav Begade
AI Code Generation and Refactoring: A Practical Guide

A model is better at changing code that already exists than at writing code that does not. A generated greenfield module compiles, reads nicely, and is quietly wrong in a way nobody notices for a month. A rename applied across four hundred call sites is either correct or loudly broken, and you find out in seconds.

That asymmetry is the whole practical story. Generation has no ground truth. Refactoring has one: the code that was there before, and the tests that passed against it. Every technique below is really an attempt to give the model more ground truth and give the reviewer a smaller thing to check.

This is not about which assistant to install. It is about the mechanics of the work: how much of your repository to put in front of a model, which transformations are safe to hand over, what a bad diff looks like when it is trying to look good, and the point at which you close the suggestion and write the function yourself.

Why refactoring beats greenfield generation almost every time

Ask for a new authentication module and you get something shaped like authentication. Sensible function names, a plausible token flow. Whether it matches your session model, your error contract, your database access pattern or your logging conventions is a coin flip, and each mismatch stays invisible until something downstream depends on it.

Ask instead to convert a callback-based module to async and await, and the target is fixed. The behaviour should be identical. The tests that passed before should pass after. Anything else is a defect, and you have a mechanical way to see it.

The same holds for the work most engineers actually spend their week on: splitting a file that grew to two thousand lines, pulling a repeated block into a function, changing a signature and fixing every caller, replacing a deprecated library call, adding types to a module written before anyone cared about types. Tedious, high volume, low judgement, mostly verifiable. That is exactly the profile a model handles well and a senior engineer resents by hand.

There is a second, less obvious reason. Existing code carries your conventions. When the model can see how the rest of the repository handles errors, names variables, structures a service or validates input, its output drifts toward that style without anyone writing a style prompt. Generation from nothing has no such gravity.

How much context to give, and what to leave out

The instinct is to paste everything. It produces worse output, not better. A large context dilutes the parts that matter and invites the model to pattern match against code that has nothing to do with the task.

A useful context bundle for a refactor is small and deliberate:

  • The file being changed, in full. Not an excerpt. Truncated files are where invented helper functions come from, because the model assumes the thing it needs must exist somewhere above the cut.
  • The type or interface definitions the file depends on. If it touches a domain object, the model needs the real shape of that object, not its guess at the shape.
  • One or two real call sites. These teach the calling convention better than any description.
  • The test file, if one exists. It states the contract more precisely than prose and it tells the model what it is not allowed to break.
  • The constraint, written out. Public signature must not change. No new dependencies. Keep the existing error type. Must work on the Node version in the engines field.

What to leave out: unrelated modules, generated files, lock files, vendor directories, and long design documents that describe intent rather than behaviour. If you are tempted to include a file because it might be relevant, name the constraint from it instead. One sentence saying that the repository uses a Result type rather than thrown exceptions does more work than the three hundred lines that define it.

When output goes wrong in a way that looks like a missing fact, add one specific file rather than widening the net. Codebase-wide indexing helps a model find where something is defined. It does not decide what matters here, and it will happily surface three similar implementations and copy the deprecated one.

Which refactors are safe to automate and which need a person

The dividing line is behaviour. If the transformation is supposed to leave observable behaviour exactly as it was, a machine can do it and a test suite can check it. If the transformation is supposed to change behaviour, no test tells you whether it changed it correctly, because the tests encode the old behaviour and will now fail on purpose.

TransformationAutomate?What actually verifies it
Rename a symbol across the codebaseYes, and prefer your IDE or a codemod over a modelCompiler or linter. Watch for names in strings, config, and dynamic property access
Extract a function or componentYesExisting tests plus a read of the extracted boundary. Check what got captured by closure
Convert callbacks to promises or async and awaitYes, file by fileTests. Read the error paths closely, this is where semantics shift
Add types to untyped codeYes, with reviewType checker. Reject any output that reaches for the escape hatch type to make errors go away
Replace a deprecated API across call sitesYes, if the mapping is mechanicalTests plus one careful read of the vendor migration notes yourself
Split a large module into smaller onesPartly. Decide the seams yourself, let the model move the codeTests, import graph, and a check for circular imports
Change an algorithm for performanceNoBenchmarks and a human who understands the input distribution
Change error handling or retry policyNoNothing automatic. This is a design decision with production consequences
Alter a database access pattern or transaction boundaryNoNothing automatic. Correctness depends on concurrency you cannot see in the file
Rewrite a module nobody understands any moreNot until it has characterization testsSee the next section

A rule that holds up: the model may move code, rename code, and restate code. Deciding what the code should do stays with the person whose name is on the pull request. Teams that want a way to count whether any of this pays back should read our piece on measuring the return on AI coding tools rather than trusting a feeling of speed.

No test harness, no automated refactor

An automated refactor without tests is not a refactor. It is an untracked rewrite that happens fast.

Before you let anything touch a module in bulk, three things should be true. The suite runs on one command. It runs in a time nobody minds waiting for, because a suite people skip is a suite that does not exist. And it fails when behaviour changes, which is not the same as having high line coverage. Coverage counts lines executed. A test that calls a function and asserts nothing executes plenty of lines and protects nothing.

The cheapest way to find out whether your suite actually constrains behaviour is to break something on purpose. Invert a condition, drop a null check, return early. If the suite stays green, it will stay green through a bad refactor too.

Characterization tests for code you do not understand

The hard case is legacy code with no tests, which is usually the code most worth refactoring. You cannot write tests for intended behaviour, because nobody knows the intent any more. So write tests for actual behaviour instead, including the parts that look like bugs.

The mechanic is straightforward. Call the function with realistic inputs. Capture whatever comes back, snapshot style. Assert that it keeps coming back. You are not claiming the output is right, only that it is what today's production code does. If the function returns an empty string for a null argument where you would have expected a thrown error, pin the empty string. A refactor that quietly turns it into an exception will break a caller you have never read.

Getting inputs is the part people skip. Sampled production payloads with the sensitive fields replaced are worth far more than inputs you imagine, because real data contains the shapes that caused the defensive branches in the first place. A model is genuinely useful here: give it the function and ask it to enumerate input classes that reach each branch, then turn those into cases. Enumerating branches is mechanical, which is why it works.

Once the characterization tests are green, the refactor becomes safe in the ordinary sense. Change the code, watch the pins. Fix the bug afterwards, as a separate commit with its own test, so that the behaviour change is visible in history instead of buried inside a two thousand line diff.

The failure modes you are actually reading for

Generated code fails differently from human code. Human mistakes usually look like mistakes. These do not, which is why review needs a checklist rather than a general sense of unease.

Plausible but wrong API usage

The call has the right shape and the wrong contract. An options object with a key the library ignores. A method that exists but on a different class. A function called with arguments in an order that was correct two major versions ago. It passes the type checker when the types are loose, and it fails at runtime on a path your tests do not cover. Check anything unfamiliar against the real documentation, not against how confident the code looks.

Silently dropped edge cases

The most expensive one. A rewrite keeps the happy path perfectly and loses the empty array check, the timezone handling, the special case for the one customer whose identifiers are formatted differently. These branches often have no comment explaining why they exist, so they read like noise worth cleaning up. When a refactor deletes a conditional, find out what it was for before you accept it.

Invented helper functions

A call to formatCurrency or getTenantId that reads exactly like something your codebase would contain, and does not. Usually the tooling catches it. When the name collides with something real that does something slightly different, it does not, and that is the bad version.

Subtly changed error handling

Watch for a thrown error becoming a returned null, a caught exception being swallowed instead of rethrown, a promise rejection turned into a resolved empty value, a retry added where the operation is not idempotent, or a catch block widened until it hides the failure you needed to see. Converting callbacks to async and await is where this happens most.

Imports that do not exist

Packages that were never installed, deep paths into a module's internals, a named export that is actually a default export. Cheap to catch with a build step, which is the point: put the cheap checks in the pipeline so review time goes to the expensive ones.

Confident comments

Comments generated alongside code describe what the author of the comment believed, not what the code does. When they disagree, the comment is the one that lies for longer. Read code and comments as separate claims.

Reviewing a large generated diff without rubber-stamping it

A four hundred line diff you did not write is not reviewable in one pass. Nobody reads it line by line at the same attention they would give a colleague's twenty line change, and pretending otherwise is how this goes wrong at scale.

What works is turning one big review into several small mechanical ones:

  1. Let the machine go first. Compiler, type checker, linter, formatter, tests, dependency audit. If the diff has not passed all of them, it is not ready for a human, and reading it early wastes the only scarce resource here.
  2. Read the shape before the lines. Which files appeared, which disappeared, which grew. A refactor that was supposed to touch three files and touched eleven is answering a question nobody asked.
  3. Sort hunks by risk, not by file order. Anything touching money, permissions, personal data, migrations or external calls gets read first and slowly. Import reordering and formatting get skimmed.
  4. Diff the behaviour, not only the text. For a pure refactor the useful question is what a caller could observe differently. Signatures, return types, thrown errors, logged fields, query counts, ordering guarantees.
  5. Ask for the reasoning separately. Have the model explain why it made a specific change, then check the explanation against the code. A wrong explanation of correct code is a warning. A confident explanation of a change that is not in the diff means you are reading a different version than you think.
  6. Keep commits small on purpose. One transformation per commit. Mechanical move in one, behaviour change in another. Reviewability is a property you build during the work, not something you can add at the pull request.

The organisational version of this matters too. If review capacity is fixed and generated volume goes up, the bottleneck moves to review and quality drops quietly. Teams that extend an engineering team with dedicated developers usually feel this first, because output grows before review habits do. The fix is boring: smaller changes, clear ownership, and a rule that nobody merges a diff they cannot explain.

Migrations and codemods are the strongest case

The best use of generated code is the change that is repetitive, mechanical, and applied a hundred times. A library's breaking change across every call site. A logging format swapped everywhere. Class components converted to hooks. An internal API renamed across a monorepo.

Do it in a specific order. Take five representative call sites and do them by hand, until you know the transformation precisely, including the two variants that do not fit. Write the rule down as a transformation rather than an instruction, because replace calls of form A with form B, and when the second argument is an object, hoist it is checkable in a way that update the old calls is not. Then apply it in slices you can review, by directory or by module, not in one commit titled migrate everything.

Better still, when the pattern is regular, have the model write a codemod rather than edit the files. A script that works on an abstract syntax tree is deterministic, reviewable in one place, rerunnable after a merge conflict, and it either matches a node or does not. Reviewing sixty lines of transformation logic beats reviewing nine hundred lines of applied output. Keep a list of the sites it could not handle and do those by hand. The remainder is where the interesting cases hide.

Frontend and backend migrations in web application work follow the same pattern regardless of stack, and the tooling side of it inside a JavaScript codebase is covered separately in our rundown of AI tools for MERN stack teams.

When to throw the output away and write it yourself

Knowing when to stop is a skill, and the sunk cost of three rounds of prompting is the thing that stops people using it. Close the suggestion when:

  • You have corrected it twice and it is still wrong. Two failed attempts usually mean the task depends on context the model does not have, and a third round produces a fourth variation on the same misunderstanding.
  • You cannot explain the code you are about to merge. This is not negotiable. It is your name on the commit, and the incident review will ask you what it does.
  • The change is genuinely novel. Concurrency, a state machine with real invariants, a security boundary, anything where correctness argument matters more than shape.
  • It is short and you already know the answer. Describing a ten line function precisely takes longer than typing it.
  • The code is load bearing and rarely touched. Payment capture, permission checks, data retention. Slow and deliberate is the right speed there.
  • The output keeps growing. Each attempt adds abstraction rather than removing it. A refactor that makes the code bigger has usually misread the goal.

The other half of the skill is knowing that the throwaway attempt was not wasted. A rejected draft often shows you an edge case you had not thought about, or tells you the interface is hard to describe, which usually means it is hard to use. Take the finding, drop the code. For a wider view of how the day to day job has shifted around these tools, our post on generative AI in web development covers the ground this one deliberately does not.


Sitting on a codebase where the refactor everyone agrees on keeps getting postponed because nothing is safe to change? A Scoping Sprint ($2,300, two weeks) ends with a concrete plan for that migration, including where the tests have to go first, a prototype, and a fixed quote. Or just start a conversation.

Frequently asked

Is AI code refactoring safe to use on production code?
It is safe for transformations that are supposed to leave behaviour unchanged, and only when a test suite will catch it if behaviour does change. Renames, extractions, callback to async conversions and deprecated API swaps fall in that group. Anything that alters error handling, retry policy, transaction boundaries or an algorithm is a design decision, so no automatic check can confirm it and a person has to own it.
Why is AI better at refactoring existing code than generating new code?
Refactoring has ground truth and generation does not. When you change existing code the correct output is defined by the code that was already there and the tests that passed against it, so a wrong result usually shows up as a failing test or a compiler error within seconds. A generated greenfield module has nothing to compare against, so it can look correct, compile, and still mismatch your session model, error contract or data access pattern for weeks.
How do you measure the ROI of AI coding assistants in software development?
Not by counting accepted suggestions or lines produced, because generated volume is the input and not the result. Useful measurement looks at cycle time from first commit to merged, review time per change, defect escape rate and rework on recently touched code, compared against a period before the tools arrived. We cover the metric set and the traps in a separate post on the return from AI coding tools in enterprise engineering.
How much of my codebase should I give the model for a refactor?
Less than you expect. The file being changed in full, the type or interface definitions it depends on, one or two real call sites, the test file if it exists, and the constraints written out in plain words. Leave out unrelated modules, generated files, lock files and design documents. A large context dilutes the relevant parts and invites the model to copy patterns from code that has nothing to do with the change.
What are characterization tests and why do they matter before refactoring?
A characterization test pins what code does today rather than what it should do. You call the function with realistic inputs, capture the output, and assert it stays the same. You pin the odd results too, including behaviour that looks like a bug, because some caller may depend on it. That gives untested legacy code a safety net so a refactor can proceed, and any behaviour fix afterwards lands as its own visible commit.
What are the common failure modes in AI generated code?
Six recur. Plausible but wrong API usage, where the call has the right shape and the wrong contract. Silently dropped edge cases, usually the defensive branch with no comment explaining it. Invented helper functions that sound like your codebase. Subtly changed error handling, such as a thrown error becoming a returned null. Imports that do not exist. And confident comments that describe intent rather than the code sitting next to them.
How do you review a large AI generated diff without rubber-stamping it?
Run every machine check first so a human never reads a diff that has not compiled, type checked, linted and passed tests. Then read the shape of the change before the lines, and sort hunks by risk so money, permissions, personal data and migrations get read slowly while formatting gets skimmed. Ask what a caller could observe differently. Best of all, keep one transformation per commit so the review stays small.
Should I ask a model to write a codemod or to edit the files directly?
Write the codemod when the pattern is regular. A script that transforms an abstract syntax tree is deterministic, reviewable in one place, and rerunnable after a merge conflict, so you read sixty lines of transformation logic instead of nine hundred lines of applied output. Do five call sites by hand first to learn the exact rule, then let the script handle the rest and fix the leftovers manually.
When should you throw away the generated code and write it yourself?
When you have corrected it twice and it is still wrong, because the third attempt usually repeats the same misunderstanding. When you cannot explain what you are about to merge. When the problem is genuinely novel, such as concurrency, real invariants or a security boundary. When the function is short enough that describing it takes longer than typing it. And when each attempt adds abstraction instead of removing it.
Fixed price · $2,3002-week sprint

Building something in this space?

We turn ideas into buildable plans in 2 weeks: clickable prototype, technical plan, fixed quote. Fixed price, credited against the build.

See the Scoping Sprint

Transform your development workflow

Start a project →
Book a 15-min scoping call