Applied Module 12 · AI-Accelerated Government Development

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 responsesNextResponse.json({ data }) instead of listResponse() or errorResponse().
  • No authorization guard — the route checks session.user.role inline with an if/else chain instead of using withPermission().
  • 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_source column — 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.

💬This is the most common real-world task

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:

LineProblemStandard
SELECT *Returns all columns, all rows, no paginationlistResponse() with page/pageSize, cap at 500
result.recordset returned directlyNo response envelopelistResponse({ data, total, page, pageSize })
Inline role checkHardcoded role names, no matrix referencewithPermission('operator')
body.plateNumberNo validationVehicleCreateSchema.parse(body)
e.message in responseLeaks internal detailsformatSafeError(e) via errorResponse()
No data_sourceCannot distinguish manual from synced recordsdata_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-compatible
where possible -- the response envelope shape changes (from bare array to
{ data, total, page, pageSize }) but that is an expected migration.
Claude Code — Legacy Module Refactor
/home/dev/ds-platform $ claude 'Refactor src/app/api/vehicles/route.ts to match our platform standards: response helpers, withPermission, pagination, Zod validation, safe errors, data provenance'
I'll refactor the vehicles route to current platform standards. Created: src/lib/schemas/vehicle.ts (22 lines) - VehicleCreateSchema with Zod validation - plateNumber: z.string().min(1).max(10).regex(/^[A-Z0-9]+$/) - year: z.number().int().min(1990).max(2035) Updated: src/app/api/vehicles/route.ts (58 lines → 52 lines) - GET: withPermission('viewer'), parsePagination, OFFSET/FETCH NEXT, listResponse({ data, total, page, pageSize }) - POST: withPermission('operator'), VehicleCreateSchema.parse(), data_source: 'manual', successResponse({ id }) - Both: errorResponse() with formatSafeError, no e.message leaks - SELECT * replaced with explicit column list The API contract changed: Before: GET returns raw array [{...}, {...}] After: GET returns { data: [{...}], total: 142, page: 1, pageSize: 50 } Frontend consumers of /api/vehicles will need to access .data instead of the top-level array.
/home/dev/ds-platform $ npx vitest --run src/app/api/vehicles
✓ src/app/api/vehicles/__tests__/route.test.ts (8 tests) 52ms ✓ GET returns paginated list with metadata ✓ GET respects page and pageSize params ✓ GET caps pageSize at 500 ✓ GET with viewer role succeeds ✓ POST with operator role creates vehicle ✓ POST with viewer role returns 403 ✓ POST with invalid body returns 400 with Zod errors ✓ POST sets data_source to manual Test Files 1 passed (1) Tests 8 passed (8) Duration 0.41s
/home/dev/ds-platform $
The response shape change is a breaking change

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:

StepWhat to doReference
1. Response helpersReplace NextResponse.json() with listResponse, errorResponse, successResponseLesson 7
2. AuthorizationReplace inline role checks with withPermission()Lesson 4
3. PaginationAdd parsePagination(), SQL OFFSET/FETCH, cap at 500Lesson 7
4. Zod validationCreate schema file, parse request body before processingLesson 5
5. Safe errorsReplace catch (e) { return res(e.message) } with errorResponse()Lesson 7
6. Data provenanceAdd data_source column to INSERT statementsLesson 10
7. Column listingReplace SELECT * with explicit column namesLesson 8
8. RegistrationVerify module is in permission matrix and provenance mapLesson 6
9. TestsWrite Vitest tests for the refactored routeLesson 12
10. AccessibilityCheck the updated UI for WCAG violationsLesson 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 response
const { data: vehicles } = useSWR<Vehicle[]>('/api/vehicles', fetcher);
// AFTER: envelope response
const { 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);
}}
/>
💡Refactor the hook at the same time as the route

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 response
2. Show the updated code to handle the new { data, total, page, pageSize } envelope
3. If the consumer doesn't need pagination, it can just access .data and ignore the rest

The full refactor workflow

Claude Code — Complete Refactor Workflow
/home/dev/ds-platform $ claude 'Refactor the vehicles module to platform standards and update all consumers'
I'll refactor the vehicles module and update all consumers. Updated: src/app/api/vehicles/route.ts — response helpers, withPermission, pagination, Zod, safe errors, data_source Created: src/lib/schemas/vehicle.ts — VehicleCreateSchema Updated: src/hooks/useVehicles.ts — new envelope, server pagination Updated: src/app/vehicles/page.tsx — DataGrid server pagination Updated: src/app/fleet/components/VehicleSelector.tsx — access .data Updated: src/app/reports/fleet-summary/page.tsx — access .data 6 files changed, 3 consumers updated.
/home/dev/ds-platform $ npx vitest --run
✓ src/app/api/vehicles/__tests__/route.test.ts (8 tests) 42ms ✓ src/lib/schemas/__tests__/vehicle.test.ts (5 tests) 8ms ✓ src/hooks/__tests__/useVehicles.test.ts (3 tests) 15ms Test Files 3 passed (3) Tests 16 passed (16) Duration 0.68s
/home/dev/ds-platform $ npx playwright test e2e/vehicles.spec.ts
Running 6 tests using 1 worker ✓ vehicles — list loads with pagination (1.4s) ✓ vehicles — viewer cannot create (0.9s) ✓ vehicles — operator creates vehicle (2.1s) ✓ vehicles — invalid plate shows error (1.2s) ✓ vehicles — pagination controls work (1.8s) ✓ vehicles — accessibility audit passes (1.6s) 6 passed (9.0s)
/home/dev/ds-platform $

Refactoring priorities

Not all legacy modules need to be refactored at the same urgency. Here is how to prioritize:

PriorityCriteriaWhy
ImmediateNo withPermission() — any authenticated user can writeAuthorization gap — audit finding
Immediatee.message in error responsesInformation leakage — security finding
HighNo pagination on tables > 500 rowsPerformance — page timeouts in production
HighNo Zod validation on POST/PUTInput validation gap — injection risk
MediumNo response helpersInconsistent API contract — frontend bugs
MediumSELECT *Returns unnecessary data, breaks on schema changes
LowNo data_source columnProvenance gap — not urgent unless audit scope includes data lineage
Track refactoring progress

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.


KNOWLEDGE CHECK

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.