Chapter 11

AI Refactoring in Practice — Safe Refactoring with Composer

Chapter 11: AI Refactoring in Practice — Safe Refactoring with Composer

The golden rule of AI refactoring: tests first, then refactor. This chapter covers three real-world cases using Cursor Composer: splitting a 200-line god function, eliminating scattered duplicate logic, and introducing the Repository Pattern to decouple the data layer — every step with a reversible Git checkpoint.

The Iron Rule: No Tests, No Refactor

AI refactoring carries the same risk as manual refactoring — you think you're only changing structure, but you accidentally change behavior. The risk is amplified with AI because Composer can touch a dozen files in one shot, making surprises harder to catch.

Building a test net for legacy code: If the module you're about to refactor has no tests, ask AI to write characterization tests first:

Cursor Chat Prompt

@src/legacy/UserManager.ts

This is a legacy module with zero test coverage.
Before refactoring, write a suite of characterization tests:
- The goal is NOT to verify correctness, but to document current behavior
- Cover all public methods including edge inputs (null, empty strings, negative numbers)
- If behavior changes during refactoring, these tests will fail and alert us

Use Jest with in-memory SQLite instead of mocking the database

Run the characterization tests once to confirm they all pass, then commit. That's your safety net. Run this suite after every refactoring step — any behavioral change will be caught immediately.

Composer's diff view is your most critical safety checkpoint. Before accepting any changes, read through the diff line by line. AI sometimes "helpfully" fixes logic it considers problematic during a structural refactor — these changes may be beneficial or dangerous; you must decide.

Case 1: Split a 200-Line God Function

Starting point: one function doing 7 things

// Anti-pattern: one function doing 7 things across 200 lines
async function handleOrder(orderId) {
  // Lines 1-30: validate order status
  // Lines 31-55: check and lock inventory (loop with DB queries inside)
  // Lines 56-95: calculate total (tax, discounts, shipping)
  // Lines 96-115: call payment gateway
  // Lines 116-140: send confirmation email
  // Lines 141-165: update order status
  // Lines 166-200: write audit log
}

Composer Prompt: specify the split and constraints precisely

Cursor Composer Prompt

@src/services/order.ts (lines 45-250 are the handleOrder function)

Split this function by responsibility into these focused functions:
1. validateOrder(orderId): Promise<Order> — verify order exists and has valid status
2. lockInventory(items: OrderItem[]): Promise<void> — atomically check and reserve stock
3. calculateTotal(items, coupon?): number — compute final price including tax and discounts
4. processPayment(amount, paymentMethod): Promise<PaymentResult> — call payment gateway
5. sendConfirmationEmail(order, user): Promise<void> — send confirmation email

Hard constraints:
- Keep the original handleOrder function — it should call these new functions internally
- Add JSDoc to each function with parameter types and possible thrown errors
- Do NOT change any business logic — structural split only
- Do NOT add any TODO or FIXME comments

After: handleOrder becomes a pure orchestrator

/**
 * Full order processing flow: validate → lock inventory → price → pay → notify
 * @throws {OrderNotFoundError} Order doesn't exist
 * @throws {InsufficientStockError} Insufficient stock
 * @throws {PaymentFailedError} Payment gateway error
 */
async function handleOrder(orderId) {
  const order = await validateOrder(orderId)
  await lockInventory(order.items)
  const total = calculateTotal(order.items, order.coupon)
  const payment = await processPayment(total, order.paymentMethod)
  await db.orders.update(orderId, { status: 'COMPLETED', paymentId: payment.id })
  await sendConfirmationEmail(order, order.user)
  await auditLog.record({ action: 'ORDER_COMPLETED', orderId })
}

The 200-line function compresses to 10 lines. One read tells you the entire business flow. Each sub-function is independently testable: testing inventory locking doesn't need to trigger a real payment; testing email sending doesn't need real order data.

Case 2: Eliminate Duplication (DRY)

Step 1: Use AI to find all scattered pagination logic

Cursor Chat Prompt

@Codebase

Find all code in this project that implements offset/limit pagination.
List every file and line number where pagination logic appears.
Note the differences between implementations (some may also have cursor-based pagination).

Once AI finds 5 locations with near-identical 8-15 line pagination blocks, use Composer to extract them:

Cursor Composer Prompt

@src/routes/users.ts @src/routes/products.ts @src/routes/orders.ts

These files contain duplicated pagination logic. Please:
1. Create src/utils/pagination.ts with two utility functions:
   - parsePaginationParams(query): { page, limit, offset } — parse from query string with defaults and upper-bound validation
   - buildPaginationMeta(total, page, limit): PaginationMeta — build metadata to return to the client
2. Replace duplicated code in all three files with calls to these utilities
3. Do not change the query logic in each file — only replace the pagination parts

Show me the complete pagination.ts first. I'll approve it before you modify the other files.

Two-step approval: Have AI show you the extracted utility first. Once you confirm the signature and logic are correct, let it go modify all call sites. This prevents AI from mass-introducing bugs because it misunderstood one call site's context.

Case 3: Introduce Repository Pattern

Problem: Prisma queries scattered across route handlers

// src/routes/users.ts — route bearing data access responsibility
app.get('/users/:id', async (req, res) => {
  const user = await prisma.user.findUnique({
    where: { id: parseInt(req.params.id) },
    include: { profile: true, orders: { take: 5, orderBy: { createdAt: 'desc' } } }
  })
  if (!user) return res.status(404).json({ error: 'Not found' })
  res.json(user)
})
// Problem: identical Prisma queries duplicated in admin routes and webhook handlers
// Problem: testing this route requires a real database connection — cannot mock
Cursor Composer Prompt

@src/routes/

Problem: Prisma queries are written directly in route handlers, causing:
1) Identical queries duplicated across multiple files
2) Route tests must connect to a real database — no mocking possible
3) Switching ORMs would require touching every route

Refactor to Repository Pattern — only handle User-related code this session:
1. Create src/repositories/userRepository.ts with these methods:
   - findById(id: number): Promise<UserWithProfile | null>
   - findByEmail(email: string): Promise<User | null>
   - create(data: CreateUserInput): Promise<User>
   - update(id: number, data: UpdateUserInput): Promise<User>
   - findWithRecentOrders(id: number, orderLimit?: number): Promise<UserWithOrders | null>
2. Replace all prisma.user.* calls in src/routes/users.ts with userRepository methods
3. Keep the request/response interface of every route unchanged

Other resources (product, order) will be handled separately — touch ONLY user this session

After: routes become pure HTTP handlers

// src/repositories/userRepository.ts — all Prisma knowledge lives here
export const userRepository = {
  async findById(id: number) {
    return prisma.user.findUnique({ where: { id }, include: { profile: true } })
  },
  async findWithRecentOrders(id: number, orderLimit = 5) {
    return prisma.user.findUnique({
      where: { id },
      include: { profile: true, orders: { take: orderLimit, orderBy: { createdAt: 'desc' } } }
    })
  },
  async create(data: Prisma.UserCreateInput) {
    return prisma.user.create({ data })
  }
}

// src/routes/users.ts — route has no idea Prisma exists
app.get('/users/:id', async (req, res) => {
  const user = await userRepository.findWithRecentOrders(parseInt(req.params.id))
  if (!user) return res.status(404).json({ error: 'Not found' })
  res.json(user)
})

After introducing the repository, route tests only need to mock the userRepository object — no database connection needed. Switching from Prisma to another ORM means only touching the repository file; all routes are unaffected.

Common Refactoring Traps

Trap What It Looks Like Prevention
AI quietly changes behavior Tests pass but logic differs from original — AI "helpfully" fixed something it considered wrong Write characterization tests first; carefully review every git diff before accepting
Scope creep You said only touch user module, but order and product files also got modified Explicitly write "only change X, do not touch other files" in the Composer Prompt
Unnecessary abstraction Extracted a shared function that's only called once — code is now harder to read Ask AI: "When would this be reused? If there's only one caller, is the extraction worth it?"
Tests not updated after split Old tests called implementation details that no longer exist after the split Run tests immediately after each refactor step; analyze failures before fixing them

Chapter Key Takeaways

Takeaway Core Principle
1. Tests first Refactoring without a test net is flying blind. Write characterization tests before touching legacy code
2. Commit after each step One refactoring action = one commit, so you can always roll back to the last safe state
3. Explicit constraints Composer Prompts must specify "only change X" and "do not touch Y" to prevent scope creep
4. Two-step approval Before batch-modifying multiple files, have AI show the core design (interface definitions) for approval
5. Beware over-abstraction If the refactored code is harder to read than before, it may be over-engineered — roll it back
Rate this chapter
4.8  / 5  (27 ratings)

💬 Comments