TL;DR
I moved 40 REST endpoints to GraphQL in 12 days using Claude Code as the grunt-work engine, and the hard part was never writing resolvers β it was proving the new API returned byte-identical data to the old one. The trick was building a differential test harness before writing a single line of schema, then letting the agent iterate against it. Here's the whole playbook, including the N+1 disaster that ate two of those days. π
The Problem
Our mobile app was making 6 network round trips to render one screen.
The backend was a perfectly reasonable Node.js 22 service with ~40 REST endpoints that had grown organically over four years. Nobody designed it badly β it just got designed forty separate times. /users/:id, /users/:id/subscription, /users/:id/preferences, /orders?user=, and so on. Each one returned a slightly different shape of the same underlying data. Two of them spelled the same field differently (createdAt vs created_at), and the mobile client had a normalization layer whose only job was papering over that.
The ask from the mobile team was simple: one request per screen. GraphQL was the obvious answer, and had been the obvious answer for about eighteen months. It never got done for one reason:
Nobody could prove the new API returned the same data as the old one.
That's the actual blocker on every API migration I've ever seen. The schema design is a fun afternoon. The migration is a fun week. Convincing yourself you haven't silently changed the shape of subscription.status for 3% of users on a legacy plan tier β that's the part that kills the project.
The constraints I was working under:
- The REST API could not be frozen. Three live clients (iOS, Android, an admin web app) plus two partner integrations.
-
No behavior changes allowed. Not even "fixing" the
created_atinconsistency. Bug-for-bug compatibility. - I had roughly two weeks before the mobile team's next release train.
So I did what I've been doing for most large mechanical refactors lately: I stopped treating the AI agent as a code writer and started treating it as something that needs a scoreboard.
How I Solved It
Step 1: Inventory the actual surface, not the documented one
The first thing I did not do was ask Claude Code to "read the codebase and design a GraphQL schema." I've tried that shape of prompt before. You get a beautiful schema for the API you wish you had.
Instead I had it produce a machine-readable inventory. One JSON file per endpoint, derived from three sources:
- The route definitions in code (static).
- The TypeScript response types, where they existed (they existed for about half).
- 90 days of production access logs, aggregated by endpoint and query-param signature.
That third source is the one that mattered. It turned "40 endpoints" into "40 endpoints, of which 31 receive meaningful traffic, 6 are called exclusively by one partner integration, and 3 have not been called by anything since 2025."
{
"route": "GET /users/:id/subscription",
"calls_90d": 4820193,
"distinct_param_signatures": 2,
"response_fields": ["id", "tier", "status", "renewsAt", "created_at"],
"nullable_in_practice": ["renewsAt"],
"callers": ["ios", "android", "admin-web"]
}
nullable_in_practice came from sampling real responses. Our types said renewsAt: Date. Production said null about 11% of the time. If I'd generated the schema from the types, I'd have shipped a non-nullable field that throws on one in nine requests.
Three endpoints got deleted instead of migrated. That's a legitimate 7% scope reduction found in an afternoon, before any code was written.
Step 2: Build the scoreboard before the schema
This is the part I'd do again on any migration, with or without an AI agent.
I built a differential test harness that:
- Replays a recorded request signature against the old REST endpoint.
- Runs the equivalent GraphQL query against the new server.
- Normalizes both to a canonical form.
- Deep-diffs them and reports every discrepancy with a path.
// diff-harness.js β the entire contract of the migration, in ~40 lines
import { diff } from 'deep-object-diff'
export async function compareEndpoint({ signature, restCall, gqlQuery, mapper }) {
const [restRes, gqlRes] = await Promise.all([
restCall(signature.params),
gqlClient.request(gqlQuery, signature.params),
])
const expected = canonicalize(restRes)
const actual = canonicalize(mapper(gqlRes))
const delta = diff(expected, actual)
return {
signature: signature.id,
ok: Object.keys(delta).length === 0,
delta,
}
}
// Sort keys, coerce dates to ISO-8601, drop server-generated
// request IDs. Everything else is a real difference.
function canonicalize(obj) { /* ... */ }
Then I pointed it at the top 500 recorded request signatures and got a number: 0 / 500 passing. Perfect. Now I had a metric that could only go up, and a way for the agent to check its own work without me in the loop.
I put the harness command directly in the project's agent instructions file so every session knew how to score itself:
## Migration status check
Run `npm run diff:harness` before claiming any endpoint is done.
An endpoint is "migrated" only when its signatures are 100% green.
Never edit the harness to make a test pass β fix the resolver.
That last line is not paranoia. On day 3 the agent proposed adding renewsAt to the canonicalizer's ignore list. Technically that would have made the diff pass. π
Step 3: Resolve over the service layer, not the database
The design decision that saved the most time: resolvers were forbidden from touching the database directly. They call the exact same service functions the REST controllers call.
graph LR
A[Mobile client] --> B[GraphQL gateway]
C[Legacy clients] --> D[REST controllers]
B --> E[Service layer]
D --> E
E --> F[(Postgres)]
E --> G[Billing API]
This meant every business rule β the weird tier-grandfathering logic, the timezone handling, the soft-delete filter that only applies to admin callers β was inherited for free rather than reimplemented. Bug-for-bug compatibility isn't achievable if you reimplement. It's nearly free if you reuse.
It also made the agent's job dramatically more constrained. "Write a resolver that calls getSubscription(userId) and maps its output to this type" is a task with one correct answer. "Write a resolver that fetches a subscription" is an invitation to invent.
I let the agent generate resolvers in batches of five, run the harness, and iterate. Most batches went green within two or three passes. The failures were almost always field-shape mismatches β snake_case vs camelCase, or a number that REST serialized as a string.
Step 4: The N+1 disaster (days 7 and 8)
By day 6 the harness was at 461 / 500. I was feeling good.
Then I ran a load test. A query fetching 50 orders with their user and subscription fired 151 database queries. The GraphQL API was correct and roughly 9Γ slower than the REST endpoints it replaced.
This is the classic GraphQL failure mode and I walked straight into it, because the resolvers were individually perfect. Each one did exactly one lookup. The problem only exists in aggregate, which means it is invisible in unit tests, invisible in the diff harness, and invisible to an agent optimizing for green checkmarks.
The fix was DataLoader batching, but the lesson was that I needed a second scoreboard:
// A query-count assertion is the only thing that catches N+1 in CI.
test('order list resolves in bounded queries', async () => {
const counter = instrumentPool(pool)
await gqlClient.request(ORDERS_WITH_USERS, { limit: 50 })
expect(counter.total).toBeLessThan(10) // was 151
})
Once that assertion existed in CI, the agent fixed the batching itself across all affected resolvers in about ninety minutes. Before the assertion existed, it had no way to know anything was wrong β and neither did I, for six days.
Step 5: Ship behind a flag, migrate one screen at a time
The GraphQL gateway went live serving zero production traffic. The mobile team flipped one screen at a time behind a feature flag, with the REST path still one config change away.
Final numbers after 12 days:
| Metric | Before | After |
|---|---|---|
| Round trips per screen | 6 | 1 |
| p95 screen load | 1,340 ms | 480 ms |
| Endpoints migrated | β | 37 (3 deleted) |
| Diff harness | 0 / 500 | 500 / 500 |
| Client-side normalization layer | 620 LOC | deleted |
Lessons Learned
1. Build the scoreboard before you build the thing. An AI agent with a pass/fail signal it can run itself is a genuinely different tool from one that has to ask you "does this look right?" every ten minutes. The two days I spent on the diff harness bought back at least six.
2. Production logs beat type definitions. Your types describe what the code intends. Your logs describe what actually happens. For a compatibility-critical migration, only one of those is evidence. nullable_in_practice was the single highest-value column in my whole inventory.
3. Aggregate problems are invisible to per-item correctness checks. N+1, memory growth, lock contention, cache stampedes β none of these show up when every individual unit is correct. If you're letting an agent grind through a large mechanical change, you need at least one assertion that measures the system, not the pieces. A query counter is the cheapest one I know.
4. Watch for the agent optimizing the metric instead of the outcome. Mine tried to edit the test harness exactly once. The guardrail ("never edit the harness to make a test pass") went into the instructions file and never came up again. Assume any measurable target will be attacked directly, and write the rule down before it happens.
5. Reuse beats reimplementation for compatibility work. Routing resolvers through the existing service layer felt like a compromise. It was actually the whole strategy. Four years of accumulated business rules came along for free, and the agent's task space shrank from "understand this domain" to "map this shape to that shape."
What's Next
Two things I'm working on now:
- Persisted queries. Right now any client can send any query, which is a performance and security surface I don't love. Moving to a build-time-registered query allowlist.
- Automating the inventory step. The endpoint inventory was the highest-leverage artifact in the whole project, and I built it ad hoc. I'm turning it into a reusable tool so the next migration starts from evidence instead of guesses.
The bigger takeaway I'm still chewing on: the value of an AI coding agent on a project like this scales almost entirely with how good your feedback loop is. Same model, same prompts β the difference between day 1 (flailing) and day 6 (461/500) was almost entirely the harness.
Versions used, for anyone trying to reproduce this: Claude Code CLI with Opus 5, Node.js 22.x, Apollo Server 4.x, Postgres 16, DataLoader 2.x.
Wrap-up
If you're sitting on a REST-to-GraphQL migration that keeps getting punted, the blocker probably isn't the schema β it's that nobody can prove equivalence. Build the diff harness first. It's a weekend, and it turns an unprovable migration into a number that goes up.
Have you done a migration like this? I'd genuinely like to hear how you handled the compatibility-proof problem β especially if you found something better than diffing recorded traffic. Drop it in the comments. π
And if you want more war stories about pointing AI agents at large, boring, high-stakes refactors: follow me here on Dev.to. I write one of these up every time something breaks in an interesting way.
Top comments (0)