Refactoring Legacy Code with Cursor
You have inherited a 5-year-old Express API. Fifteen thousand lines of JavaScript across 50 files. Callbacks nested four levels deep. Business logic tangled into route handlers. Zero tests. And 10,000 daily users depending on every quirky behavior, including the ones nobody documented. Your job: modernize it without a single minute of downtime.
This is the scenario where AI-assisted refactoring genuinely shines — not because the AI is smarter than you, but because it can read thousands of lines of unfamiliar code in seconds and surface patterns, dependencies, and hidden coupling that would take a human days to map. The trick is knowing how to sequence the work so you never break what already works.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A systematic workflow for understanding a legacy codebase using Ask mode before touching any code
- A “characterization test” prompt that generates tests capturing existing behavior, including the bugs
- An inline edit technique for converting callbacks to async/await one function at a time without changing behavior
- A service extraction prompt that separates business logic from HTTP handlers with mechanical precision
- A checkpoint strategy that gives you a safe rollback point after every successful refactoring step
The Workflow
Section titled “The Workflow”Step 1: Map the codebase with Ask mode
Section titled “Step 1: Map the codebase with Ask mode”Resist the urge to start changing code. Use Ask mode first to build a mental model of what you are working with. Ask mode is read-only — it searches your codebase and answers questions without modifying anything.
This gives you a map before you start navigating. Save the output — you will reference it repeatedly.
Step 2: Trace critical paths before touching anything
Section titled “Step 2: Trace critical paths before touching anything”The most dangerous refactoring mistakes happen when you change code on a path you do not fully understand. Pick the three most critical user flows (e.g., login, checkout, payment) and trace them end to end.
@src/routes/checkout.js @src/controllers/orders.js @src/models/order.js @src/services/payment.js
Trace the complete checkout flow from HTTP request to database write:1. What is the exact sequence of function calls?2. Where does the request data get validated (if at all)?3. What database transactions are used and where could they leave partial state?4. What happens if the payment API call fails midway?5. Are there any race conditions with concurrent checkouts?
Draw a sequence diagram in Mermaid syntax.Step 3: Generate characterization tests
Section titled “Step 3: Generate characterization tests”Before refactoring a single line, capture the existing behavior in tests. These are not unit tests for “correct” behavior — they are characterization tests that document what the code actually does, bugs and all. If your refactoring changes a test result, you know you changed behavior.
This prompt explicitly tells the AI not to “improve” behavior during test creation, which is a common failure mode. You want tests that pass against the existing code, period. Fixing bugs comes later, after the refactoring, when you can change test expectations deliberately.
Step 4: Refactor one function at a time with inline edits
Section titled “Step 4: Refactor one function at a time with inline edits”Now the actual refactoring begins. Use Cmd+K (inline edit) for surgical changes to individual functions. This is faster and more controlled than Agent mode for single-function refactors because it keeps changes scoped to exactly the code you select.
Select a single callback-based function in your editor, hit Cmd+K, and describe the transformation:
Convert this callback-based function to async/await.Keep the exact same behavior -- same return values, same error responses,same side effects. Do not change the function signature or add new parameters.Do not add logging or improve error messages. Pure mechanical conversion only.After each conversion, run your characterization tests. If they pass, commit. If they fail, undo with Cmd+Z and investigate why.
Step 5: Extract service layer with Agent mode
Section titled “Step 5: Extract service layer with Agent mode”Once the code is modernized to async/await, the next big win is separating business logic from route handlers. This is where Agent mode excels — it can create new files, move code, update imports, and verify the result in one shot.
This separation makes the code testable in isolation — you can test the service without spinning up an HTTP server. It also makes future changes safer because business rules are in one place instead of scattered across route handlers.
Step 6: Add TypeScript incrementally
Section titled “Step 6: Add TypeScript incrementally”You do not need to convert the entire codebase to TypeScript at once. Cursor makes incremental adoption straightforward:
@src/services/order-service.ts @src/models/order.js
Convert src/models/order.js to TypeScript:1. Rename to order.ts2. Add interfaces for all data shapes (Order, OrderItem, OrderStatus)3. Add proper types to all function parameters and return values4. Use the strict TypeScript config -- no `any` types, enable strictNullChecks5. Export the interfaces so the service layer can use them6. Update imports in all files that reference this module
Make sure tsconfig.json allows .js and .ts files to coexist (allowJs: true).Convert files bottom-up: models first, then services, then controllers, then routes. Each layer depends on the one below it, so typing the foundation first gives you the most value.
Step 7: Optimize the dangerous queries
Section titled “Step 7: Optimize the dangerous queries”With tests in place and clean architecture, you can now safely address the performance issues that every legacy codebase accumulates.
@src/services @src/models
Find and fix the three worst database query patterns in this codebase:1. N+1 queries (loading related data in a loop instead of a join)2. Missing WHERE clauses on UPDATE/DELETE statements3. Queries that load entire tables when they only need specific columns
For each fix:- Show the before/after SQL- Explain the performance impact- Add an index if the query plan would benefit from one- Create a migration file for any schema changes
Do not change the data returned by any public function -- only how it is fetched.When This Breaks
Section titled “When This Breaks”AI “improves” behavior during mechanical refactoring. This is the most common failure. You ask for a callback-to-async conversion and the AI also adds input validation, changes error messages, or “fixes” an edge case. Always diff the changes carefully. If the characterization tests fail, reject the change — the AI changed behavior, not just structure.
Tests pass but production breaks. Characterization tests only cover the paths you tested. Legacy code often has undocumented behavior triggered by specific data patterns. Deploy behind a feature flag and compare the old and new code paths in production with real traffic before cutting over.
Agent mode rewrites too much. When you reference an entire @src folder, Agent sometimes decides to refactor everything at once. Be specific: reference individual files, give explicit scope boundaries, and add “Do not modify any file other than X” to your prompt.
TypeScript conversion surfaces hidden type errors. This is actually a feature, not a bug. When TypeScript catches an impossible type, that is often a real bug. Add it to your backlog but do not fix it during the refactoring phase — fix it separately with its own test.
Circular dependencies after service extraction. If the AI creates services that import from each other, you have a design problem. Use Ask mode to analyze the dependency graph and break the cycle by extracting shared types into a separate module.