Refactoring with AI: Safe, Systematic Code Improvement Without Breaking What Works

Refactoring is one of the most undervalued engineering disciplines. The definition is precise: changing the internal structure of code without changing its external behavior. Not rewriting. Not adding features. Not fixing bugs. Restructuring — making the next change cheaper without breaking what works today.

AI changes refactoring in two important ways. First, it can scan existing code and identify smells faster and more consistently than manual review. Second, it can apply well-known refactoring techniques — Extract Method, Introduce Parameter Object, Replace Conditional with Polymorphism — in seconds, producing the refactored version alongside an explanation of why the change improves the code. The risk is also real: AI can introduce subtle behavior changes while claiming to preserve behavior. The discipline is knowing how to use it safely.

The Safe Refactoring Workflow

Refactoring without tests is not refactoring — it's gambling. Before asking AI to refactor anything, establish the safety net:

  1. Lock the behavior with tests first. If the code has no tests, write characterization tests before touching anything. Ask AI: "Write tests that characterize the current behavior of this function — including edge cases and expected outputs. Do not suggest improvements yet." These tests are your regression guard.
  2. One refactoring at a time. Each AI prompt should apply one named technique. Not "improve this code" — "apply Extract Method to this function". Mixing multiple refactorings in one step makes it impossible to know which change introduced a problem.
  3. Run tests after each step. Green tests confirm behavior is preserved. Red tests — before any other change — mean the refactoring introduced a regression. Revert immediately; don't continue on a broken foundation.
  4. Verify the refactoring claim. After AI produces the refactored code, ask: "Identify any place where this refactored version might behave differently from the original — including error handling, edge cases with null or empty values, and exception propagation." This forces a second pass before you accept the output.

Step 0: The Code Smell Audit

Before applying any technique, use AI to produce a prioritized smell inventory. This tells you where to spend refactoring effort and in what order:

"Audit this code for refactoring opportunities. For each smell found: 1. Name the smell (Long Method, Feature Envy, Data Clump, etc.) 2. Point to the specific lines or sections affected 3. Name the refactoring technique that resolves it 4. Estimate the impact on readability and changeability (low/medium/high) Sort results by impact, highest first. Do not generate the refactored code yet — I want to review the plan before applying changes."

The phrase "do not generate the refactored code yet" is deliberate. AI that generates refactored code immediately skips the analysis step that lets you decide whether each refactoring is worth its cost. You want to own the prioritization, not inherit it.

Extract Method: The Highest-Frequency Refactoring

Extract Method is applied more often than any other technique. The trigger: a block of code inside a function that could be described with a name different from the function's name. That block is doing a secondary job and belongs in its own function.

// Before: a long function with several embedded jobs function generateMonthlyReport(userId, month, year) { // Fetch user const user = db.users.findOne({ id: userId }); if (!user) throw new UserNotFoundError(userId); // Build date range const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month, 0, 23, 59, 59); // Fetch transactions const transactions = db.transactions.find({ userId, date: { $gte: startDate, $lte: endDate }, }); // Calculate summary const totalIn = transactions.filter(t => t.type === 'credit').reduce((s, t) => s + t.amount, 0); const totalOut = transactions.filter(t => t.type === 'debit').reduce((s, t) => s + t.amount, 0); const balance = totalIn - totalOut; // Format output return { user: { id: user.id, name: user.name }, period: { month, year }, summary: { totalIn, totalOut, balance }, transactions: transactions.map(t => ({ id: t.id, date: t.date, amount: t.amount, type: t.type })), }; }
// Prompt: "Apply Extract Method to this function. Each comment block is a hint // for an extraction. Name each extracted function by its intent, not by what // the comment says. Preserve behavior exactly." // After: function generateMonthlyReport(userId, month, year) { const user = fetchUserOrThrow(userId); const period = buildMonthDateRange(month, year); const transactions = fetchUserTransactionsInPeriod(userId, period); const summary = calculateTransactionSummary(transactions); return formatReportOutput(user, { month, year }, summary, transactions); } function fetchUserOrThrow(userId) { const user = db.users.findOne({ id: userId }); if (!user) throw new UserNotFoundError(userId); return user; } function buildMonthDateRange(month, year) { return { startDate: new Date(year, month - 1, 1), endDate: new Date(year, month, 0, 23, 59, 59), }; } function fetchUserTransactionsInPeriod(userId, { startDate, endDate }) { return db.transactions.find({ userId, date: { $gte: startDate, $lte: endDate } }); } function calculateTransactionSummary(transactions) { const totalIn = transactions.filter(t => t.type === 'credit').reduce((s, t) => s + t.amount, 0); const totalOut = transactions.filter(t => t.type === 'debit').reduce((s, t) => s + t.amount, 0); return { totalIn, totalOut, balance: totalIn - totalOut }; } function formatReportOutput(user, period, summary, transactions) { return { user: { id: user.id, name: user.name }, period, summary, transactions: transactions.map(t => ({ id: t.id, date: t.date, amount: t.amount, type: t.type })), }; }

Introduce Parameter Object: Eliminating Data Clumps

Data Clump is the smell of parameters that always travel together. When you see the same three or four parameters in multiple function signatures, they belong in a named object. AI identifies clumps and generates the object and all the callers in one step:

// Smell: the same parameter group appears in every function function searchFlights(origin, destination, departureDate, returnDate) { ... } function estimateFlightCost(origin, destination, departureDate, returnDate) { ... } function bookFlight(origin, destination, departureDate, returnDate, passengerId) { ... } function findAlternativeFlights(origin, destination, departureDate, returnDate) { ... } // Prompt: "Apply Introduce Parameter Object. Identify the data clump, // create a named value object for it, and update all function signatures. // The object should validate its own invariants in the constructor." class FlightRoute { constructor({ origin, destination, departureDate, returnDate }) { if (!origin || !destination) throw new Error('Origin and destination are required'); if (returnDate && returnDate < departureDate) throw new Error('Return must be after departure'); this.origin = origin; this.destination = destination; this.departureDate = departureDate; this.returnDate = returnDate; } get isRoundTrip() { return !!this.returnDate; } } // Updated signatures — callers become readable function searchFlights(route: FlightRoute) { ... } function estimateFlightCost(route: FlightRoute) { ... } function bookFlight(route: FlightRoute, passengerId: string) { ... } function findAlternativeFlights(route: FlightRoute) { ... }

Replace Conditional with Polymorphism

When the same switch or type-checking conditional appears in multiple methods of the same class, it's a sign that the type should drive behavior through inheritance or composition instead of branching. This refactoring is the bridge between a code smell and a design pattern (Strategy or State):

// Smell: repeated type dispatch across multiple methods class Shipping { calculateCost(order) { switch (order.shippingType) { case 'standard': return order.weight * 2.5; case 'express': return order.weight * 5.0 + 10; case 'overnight': return order.weight * 8.0 + 25; } } estimateDeliveryDays(order) { switch (order.shippingType) { case 'standard': return 7; case 'express': return 3; case 'overnight': return 1; } } getCarrierCode(order) { switch (order.shippingType) { case 'standard': return 'STD-01'; case 'express': return 'EXP-02'; case 'overnight': return 'OVN-03'; } } } // Prompt: "Replace the repeated type conditionals with polymorphism. // Each shipping type should be a class that encapsulates its own rules. // The Shipping class should delegate to the appropriate type." class StandardShipping { calculateCost(order) { return order.weight * 2.5; } estimateDeliveryDays() { return 7; } get carrierCode() { return 'STD-01'; } } class ExpressShipping { calculateCost(order) { return order.weight * 5.0 + 10; } estimateDeliveryDays() { return 3; } get carrierCode() { return 'EXP-02'; } } class OvernightShipping { calculateCost(order) { return order.weight * 8.0 + 25; } estimateDeliveryDays() { return 1; } get carrierCode() { return 'OVN-03'; } } class Shipping { private static types = { standard: new StandardShipping(), express: new ExpressShipping(), overnight: new OvernightShipping(), }; static for(type) { const shipping = Shipping.types[type]; if (!shipping) throw new UnsupportedShippingTypeError(type); return shipping; } } // Usage: const shipping = Shipping.for(order.shippingType); const cost = shipping.calculateCost(order); const days = shipping.estimateDeliveryDays();

Extract Class: When a Class Does Too Much

Extract Method addresses long functions. Extract Class addresses the next level: a class whose fields and methods naturally cluster into two or more cohesive groups. The tell: if you can describe parts of a class independently, those parts belong in separate classes.

// Smell: User class manages both identity and address concerns class User { // Identity fields id: string; email: string; passwordHash: string; lastLoginAt: Date; // Address fields street: string; city: string; state: string; postalCode: string; country: string; // Identity methods verifyPassword(plain) { ... } updateEmail(newEmail) { ... } // Address methods formatFullAddress() { return `${this.street}, ${this.city} - ${this.state}`; } isInternational() { return this.country !== 'BR'; } toShippingLabel() { ... } } // Prompt: "Apply Extract Class. Identify the two cohesive clusters of fields // and methods. Extract one into a new class. The original class should hold // a reference to the extracted class." class Address { constructor({ street, city, state, postalCode, country }) { this.street = street; this.city = city; this.state = state; this.postalCode = postalCode; this.country = country; } formatFull() { return `${this.street}, ${this.city} - ${this.state}`; } isInternational() { return this.country !== 'BR'; } toShippingLabel() { ... } } class User { id: string; email: string; passwordHash: string; lastLoginAt: Date; address: Address; // composed, not inherited verifyPassword(plain) { ... } updateEmail(newEmail) { ... } }

Inline Method and Variable: Removing Unnecessary Indirection

Refactoring is not only about adding structure — sometimes the right move is removing it. Inline Method and Inline Variable collapse unnecessary indirection that adds cognitive load without adding clarity:

// Smell: a single-use variable that adds no clarity function isEligibleForDiscount(user) { const yearsAsCustomer = user.createdAt ? (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24 * 365) : 0; const hasLongHistory = yearsAsCustomer >= 3; return hasLongHistory; } // Prompt: "Apply Inline Variable where the intermediate variable adds no clarity. // Keep variables only when they give a name to a non-obvious computation." function isEligibleForDiscount(user) { const yearsAsCustomer = user.createdAt ? (Date.now() - user.createdAt.getTime()) / MS_PER_YEAR : 0; return yearsAsCustomer >= LOYALTY_THRESHOLD_YEARS; } // Smell: a method whose body is as clear as its name class Order { getTotalItems() { return this.items.length; } // ...used only once and the body is obvious at the call site if (order.getTotalItems() === 0) ... // After inline: if (order.items.length === 0) ...

Move Method: Fixing Feature Envy

Feature Envy is the smell of a method that uses more data from another class than from its own. The method is in the wrong class. Moving it is often the simplest refactoring with the highest readability payoff:

// Smell: OrderService.calculateShipping touches only Order fields class OrderService { calculateShipping(order) { // This method only knows about Order. It belongs in Order. const baseRate = order.weight * 2.5; const fragile = order.isFragile ? baseRate * 0.20 : 0; const insurance = order.totalValue > 500 ? order.totalValue * 0.01 : 0; return baseRate + fragile + insurance; } } // Prompt: "Apply Move Method. Identify which class this method belongs to // based on the data it accesses. Move it there. Update the caller." class Order { // ...existing fields... calculateShipping() { const baseRate = this.weight * 2.5; const fragile = this.isFragile ? baseRate * 0.20 : 0; const insurance = this.totalValue > 500 ? this.totalValue * 0.01 : 0; return baseRate + fragile + insurance; } } // OrderService no longer needs the method: class OrderService { processOrder(order) { const shippingCost = order.calculateShipping(); // natural // ... } }

The Characterization Test Prompt

When refactoring code that has no tests, the first step is always generating characterization tests — tests that document current behavior, not intended behavior. These lock what the code does before you change how it does it:

"Write characterization tests for this function. The goal is NOT to test that the behavior is correct — it is to document and lock the current behavior so that any refactoring that changes the output is caught immediately. For each test: - Use a descriptive name that states WHAT the function returns for WHICH input - Cover: typical inputs, boundary values, empty/null inputs, error conditions - Do NOT improve or fix the behavior — test it as-is, even if it seems wrong After writing the tests, list any behaviors that seem suspicious or incorrect so I can decide whether to fix them separately before refactoring."

The Behavior-Preservation Check

After AI generates a refactored version, always run this prompt before accepting:

"Compare the original and refactored versions carefully. List every case where they might behave differently: 1. Return values: are all return paths equivalent? 2. Exceptions: does the refactored version throw in any case the original doesn't, or swallow any exception the original propagates? 3. Side effects: any difference in what gets written, logged, or emitted? 4. Null/undefined handling: does either version handle missing values differently? 5. Short-circuit evaluation: does any extracted function change when evaluation stops? 6. Mutation: does any extraction change whether an object is mutated in place? For each difference found, state whether it is an intentional improvement or an unintentional behavior change."

Where AI Refactoring Goes Wrong

Silent behavior changes. The most dangerous AI refactoring mistake is changing behavior while claiming to preserve it. Common culprits: reordering operations that have side effects, changing error propagation in extracted functions, losing a null guard that was buried in a conditional. The behavior-preservation check prompt catches most of these, but it requires tests to confirm.

Over-extraction. AI tends to extract aggressively. A three-line block becomes a named function. A two-step calculation becomes two separate functions. The result is code where following the logic requires jumping between ten short functions instead of reading thirty sequential lines. Extract when the extracted unit has a name that adds information. Don't extract just because a block is separable.

Mixing refactoring with bug fixes. AI will occasionally notice what looks like a bug and fix it silently while refactoring. This is dangerous: the "bug" might be intentional behavior, and the fix changes semantics while you're focused on structure. Always ask: "Did you fix anything that wasn't a structural change? If so, list it explicitly."

The refactoring contract: Tell AI explicitly: "This is a structural refactoring only. Do not fix bugs, improve algorithms, add error handling, or change behavior — even if you see an opportunity. Structural changes only. Flag anything that looks like a bug separately." This constraint is the difference between a refactoring session and a combined refactoring + feature change that's impossible to review safely.

Refactoring Legacy Code: A Practical Sequence

Legacy code is often the most valuable candidate for refactoring and the riskiest place to apply it. This sequence minimizes risk when AI-assisted refactoring hits a 500-line class with no tests and unclear ownership:

  1. Understand before touching. Prompt: "Explain what this code does, section by section. List every input, output, side effect, and external dependency. Do not suggest changes yet."
  2. Write characterization tests. Cover the behavior you understand. Accept that you won't cover what you don't understand yet — that coverage comes after step 3.
  3. Apply Rename first. Better names cost nothing and make everything else clearer. Ask AI to rename every unclear variable, method, and class before doing any structural change.
  4. Apply Extract Method for comment-demarcated blocks. Any comment that says "do X" is a hint for an extraction named X. This makes the structure explicit without changing behavior.
  5. Apply Extract Class when clusters emerge. After extraction, field-and-method clusters become visible. Move related things together.
  6. Replace conditionals with polymorphism last. This is the deepest structural change. Apply it only after the code is well-named and well-extracted, so the conditional structure is easy to see and reason about.

The compounding effect: Refactoring creates a virtuous cycle with AI. Better-structured code produces better AI suggestions — the AI reasons about a focused, named function far more accurately than about a 200-line entangled block. Better AI suggestions produce better refactoring proposals. The investment in structure compounds: each refactoring session leaves the codebase in better shape for the next one, and each session costs less because the code is easier to reason about.

Key Takeaways

Back to all articles