Refactor a Legacy Module
What you'll learn
~35 min- Identify common anti-patterns in legacy DS platform modules and map them to current standards
- Refactor raw SQL route handlers to use response helpers, Zod validation, and withPermission()
- Add data_source provenance tracking and pagination to legacy endpoints
- Use AI to perform a systematic refactor while preserving existing behavior
Every platform has legacy modules
The DS platform has been in production for over a year. The first modules were built before the team established response helpers, before withPermission() existed, before anyone agreed on pagination standards. They work. They serve users. But they are a maintenance liability:
- Raw SQL strings in route handlers — no parameterization abstraction, no query builder.
- Manual JSON responses —
NextResponse.json({ data })instead oflistResponse()orerrorResponse(). - No authorization guard — the route checks
session.user.roleinline with an if/else chain instead of usingwithPermission(). - No pagination — the GET endpoint returns every row. For a table with 50 records, this was fine. For 5,000, it times out.
- No Zod validation — the POST handler trusts
req.json()without schema validation. - No
data_sourcecolumn — no way to tell if a record was manually entered, seeded, or synced. - Bare
catch (e) { return NextResponse.json({ error: e.message })} — leaks stack traces.
If this list sounds familiar, you have a legacy module. This lesson walks through a systematic refactor using AI, bringing it up to the standards you built in Lessons 5-11.
Building a new module from scratch (Lessons 5-6) is the exception. Most of your work is bringing existing code up to standard. This lesson is the one you will reference the most.
Anatomy of a legacy route handler
Here is what a typical pre-standard route handler looks like. This is the vehicles module — written 14 months ago, before the platform conventions existed:
// src/app/api/vehicles/route.ts (BEFORE refactor)import { getServerSession } from 'next-auth';import { NextResponse } from 'next/server';import { getPool } from '@/lib/db/connection';
export async function GET() { const session = await getServerSession(); if (!session) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); }
try { const pool = await getPool(); const result = await pool.request().query('SELECT * FROM vehicles ORDER BY plate_number'); return NextResponse.json(result.recordset); } catch (e: any) { return NextResponse.json({ error: e.message }, { status: 500 }); }}
export async function POST(req: Request) { const session = await getServerSession(); if (!session) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } if (session.user.role !== 'admin' && session.user.role !== 'operator') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); }
try { const body = await req.json(); const pool = await getPool(); await pool.request() .input('plate', body.plateNumber) .input('make', body.make) .input('model', body.model) .input('year', body.year) .input('assigned_to', body.assignedTo) .query(`INSERT INTO vehicles (plate_number, make, model, year, assigned_to) VALUES (@plate, @make, @model, @year, @assigned_to)`); return NextResponse.json({ success: true }, { status: 201 }); } catch (e: any) { return NextResponse.json({ error: e.message }, { status: 500 }); }}Count the issues:
| Line | Problem | Standard |
|---|---|---|
SELECT * | Returns all columns, all rows, no pagination | listResponse() with page/pageSize, cap at 500 |
result.recordset returned directly | No response envelope | listResponse({ data, total, page, pageSize }) |
| Inline role check | Hardcoded role names, no matrix reference | withPermission('operator') |
body.plateNumber | No validation | VehicleCreateSchema.parse(body) |
e.message in response | Leaks internal details | formatSafeError(e) via errorResponse() |
No data_source | Cannot distinguish manual from synced records | data_source: 'manual' on insert |
The refactor prompt
This is where everything you have learned comes together. The prompt references the specific platform conventions from earlier lessons:
Refactor src/app/api/vehicles/route.ts to match our current platform standards.
CURRENT STATE: The file has raw SQL, no pagination, inline role checking,no Zod validation, no response helpers, and catches that leak error details.
REQUIRED CHANGES:
1. RESPONSE HELPERS: Import listResponse, errorResponse, successResponse from src/lib/api/responses.ts. Replace all NextResponse.json() calls.
2. AUTHORIZATION: Import withPermission from src/lib/auth/permissions.ts. - GET: wrap with withPermission('viewer') - POST: wrap with withPermission('operator') Remove all inline session.user.role checks.
3. PAGINATION (GET): - Import parsePagination from src/lib/api/pagination.ts - Accept page and pageSize from query params - Cap pageSize at 500 - Use SQL OFFSET/FETCH NEXT for Azure SQL pagination - Include total count in a separate COUNT(*) query - Return via listResponse({ data, total, page, pageSize })
4. ZOD VALIDATION (POST): - Create src/lib/schemas/vehicle.ts with VehicleCreateSchema: plateNumber: string, 1-10 chars, uppercase alphanumeric make: string, 1-50 chars model: string, 1-50 chars year: number, 1990-2035 assignedTo: string, optional - Parse request body with schema, return 400 with Zod errors on failure
5. SAFE ERRORS: Use errorResponse() which internally calls formatSafeError(). Remove all catch blocks that return e.message directly.
6. DATA PROVENANCE: Add data_source column to the INSERT statement. Value: 'manual' for API-created records.
7. REPLACE SELECT *: Explicitly list columns in the SELECT query.
Do NOT change the database schema. Do NOT change the URL path. The API contract(request/response shape) for existing consumers should remain backward-compatiblewhere possible -- the response envelope shape changes (from bare array to{ data, total, page, pageSize }) but that is an expected migration.When you wrap a bare array with listResponse(), existing frontend code that does const vehicles = await res.json() and then maps over it will break — because vehicles is now { data: [...], total, page, pageSize }. You need to update the SWR hook to access .data. Coordinate with your team before merging.
The refactor checklist
Use this checklist for every legacy module. Each item maps to a lesson where the standard was established:
| Step | What to do | Reference |
|---|---|---|
| 1. Response helpers | Replace NextResponse.json() with listResponse, errorResponse, successResponse | Lesson 7 |
| 2. Authorization | Replace inline role checks with withPermission() | Lesson 4 |
| 3. Pagination | Add parsePagination(), SQL OFFSET/FETCH, cap at 500 | Lesson 7 |
| 4. Zod validation | Create schema file, parse request body before processing | Lesson 5 |
| 5. Safe errors | Replace catch (e) { return res(e.message) } with errorResponse() | Lesson 7 |
| 6. Data provenance | Add data_source column to INSERT statements | Lesson 10 |
| 7. Column listing | Replace SELECT * with explicit column names | Lesson 8 |
| 8. Registration | Verify module is in permission matrix and provenance map | Lesson 6 |
| 9. Tests | Write Vitest tests for the refactored route | Lesson 12 |
| 10. Accessibility | Check the updated UI for WCAG violations | Lesson 14 |
Steps 1-7 are in the prompt above. Steps 8-10 are follow-up tasks.
Update the SWR hook
The frontend must match the new response shape. Here is the typical change:
// BEFORE: bare array responseconst { data: vehicles } = useSWR<Vehicle[]>('/api/vehicles', fetcher);
// AFTER: envelope responseconst { data: response } = useSWR<ListResponse<Vehicle>>( `/api/vehicles?page=${page}&pageSize=${pageSize}`, fetcher);const vehicles = response?.data ?? [];const total = response?.total ?? 0;This change also unlocks pagination in the DataGrid:
<DataGrid rows={vehicles} rowCount={total} paginationMode="server" paginationModel={{ page: page - 1, pageSize }} onPaginationModelChange={(model) => { setPage(model.page + 1); setPageSize(model.pageSize); }}/>Include the SWR hook update in your refactor prompt. If the route and hook are updated in the same commit, nothing breaks in between. Ask the AI: “Also update src/hooks/useVehicles.ts to match the new listResponse envelope and expose page, pageSize, total for DataGrid server pagination.”
Handle the edge case: modules with consumers
Some legacy modules have other modules that depend on their API response shape. The vehicles module might be consumed by a fleet dashboard, a maintenance scheduler, and a reporting pipeline. Changing the response envelope breaks all of them.
Two strategies:
Strategy 1: Versioned response (recommended for high-traffic modules)
Add a query parameter: /api/vehicles?format=v2. Default to the legacy bare-array format for backward compatibility. New consumers use format=v2 to get the envelope. Deprecate v1 after all consumers migrate.
Strategy 2: Update all consumers in one PR (recommended for low-traffic modules)
If the vehicles API has 2-3 consumers and they are all in the same repo, refactor everything together. Search for /api/vehicles across the codebase, update each consumer to handle the new envelope, and merge it all in one PR. This is faster and avoids maintaining two formats.
Search the codebase for all files that fetch from /api/vehicles. For each one:1. Show the current code that processes the response2. Show the updated code to handle the new { data, total, page, pageSize } envelope3. If the consumer doesn't need pagination, it can just access .data and ignore the restThe full refactor workflow
Refactoring priorities
Not all legacy modules need to be refactored at the same urgency. Here is how to prioritize:
| Priority | Criteria | Why |
|---|---|---|
| Immediate | No withPermission() — any authenticated user can write | Authorization gap — audit finding |
| Immediate | e.message in error responses | Information leakage — security finding |
| High | No pagination on tables > 500 rows | Performance — page timeouts in production |
| High | No Zod validation on POST/PUT | Input validation gap — injection risk |
| Medium | No response helpers | Inconsistent API contract — frontend bugs |
| Medium | SELECT * | Returns unnecessary data, breaks on schema changes |
| Low | No data_source column | Provenance gap — not urgent unless audit scope includes data lineage |
Create a spreadsheet or Azure DevOps dashboard tracking each module against this checklist. When the state auditor asks “which modules have authorization guards?”, you can answer with a list and the commit SHA where each guard was added. That is the kind of answer that closes audit items.
You are refactoring a legacy route that returns a bare JSON array. The fleet dashboard and two report pages consume this endpoint. What is the safest refactoring approach?
Key takeaways
- Legacy module refactoring is the most common real-world task on the DS platform. Building new modules from scratch is the exception.
- The refactor follows a predictable sequence: response helpers, authorization, pagination, validation, safe errors, provenance, column listing. Each step maps to a lesson where the standard was established.
- AI handles the mechanical transformation. You specify the target standards, the AI applies them. Your job is verifying the output and coordinating with consumers.
- The response envelope change is a breaking change. Plan for it: either version the API or update all consumers in the same PR.
- Prioritize by risk. Missing
withPermission()and leaked error messages are audit findings. Missing pagination is a performance problem. Missing provenance is a data lineage gap. Fix them in that order. - Everything from Lessons 4-14 converges here. Authorization, response helpers, pagination, Zod validation, provenance, DensityGate, testing, accessibility — this lesson is the proof that the platform standards work as a coherent system.
What’s next
The Testing & Quality lane is complete. In the next lane — Infrastructure & Delivery — you will instrument your application with Azure Application Insights telemetry, configure Key Vault secrets with Managed Identity, set up Azure DevOps git workflows, and build a deployment pipeline that packages and ships your application to Azure Government.