The cleanest way to migrate a GraphQL server is not to migrate it.
The graph in question was a public read API (product records, search, pricing, a seller-side cache) serving the marketplace through web, native iOS, native Android, partner integrations, and an internal product. The framework had drifted out of support, the resolver layer had grown organic warts on top of its abstractions, and an in-place upgrade meant either a maintenance window we couldn't take or a freeze we couldn't enforce.
So I planned for neither. We built an entirely new service (v2:
graphql-yoga v5 + hono, no shared code with v1) and ran it in
parallel for four months while the data store and upstream APIs fed
both. By the time we touched real consumer traffic, every production
query that hit v2 had been replayed against v1 asynchronously and
diffed field-by-field. We knew which fields disagreed and by how much
before we shifted a single percent of traffic.
The starting state
v1 was a legacy GraphQL service whose resolver layer had ossified around earlier framework idioms. It read from a document-store table (via assume-role) and called out to a constellation of internal APIs: inventory service, finance and insurance, exclusive offers, price evaluation, a seller-side cache (serverless + object storage), and a sprawl of product-taxonomy lookups.
┌──────────────────┐
web / native / │ │
funnels ──────► │ v1 │ ← live in production
└─────────┬────────┘
│
┌───────────┴───────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ store │ │ upstream │
│ (records)│ │ services │
└──────────┘ └──────────┘
Drift had set in along three axes:
- Framework: the GraphQL server was on a stack with no clean upgrade path; conventions inherited from earlier idioms had calcified into hand-rolled glue.
- Type safety: types were generated, but the SDL-to-resolver path had acquired enough manual stitching to make adding a field a coordination exercise.
- Observability: expected errors (record-not-found) and real errors looked the same in metrics, which meant on-call paged for both.
Constraints
| Constraint | Implication |
|---|---|
| Live public API | Web, native iOS and Android, partner integrations, internal funnels. No maintenance window on offer. |
| Field-level parity | Consumers depended on resolver outputs to the field; even a JSON-serialisation difference could break a downstream parser. |
| No shared release cadence | The consumer integrations didn't coordinate releases with us. The API had to behave consistently or roll back instantly. |
| Confidence at scale | A test suite couldn't enumerate the production query shapes. Confidence had to come from real traffic. |
Approach
Phase 1: foundation
Week one was scaffolding: document-store access via assume-role,
runtime record validation with a schema-validation library, an
SDL-first schema loaded from a single root schema file via yoga's
createSchema(), generated types over that schema, and the first
DataLoader-based resolver. The architectural choices that mattered
later were made here:
graphql-yogav5 as the GraphQL server, Web Standardsfetch()alignment withhono, fewer batteries-included assumptions than the legacy stack had outgrown.- SDL-first, the schema lives in one file, loaded by
createSchema(). No annotation-driven dance. DataLoaderinstantiated per-request in the yoga context factory. By steady state there were eight, covering records, sellers, customer data, pricing, promotions, inventory, related items, and valuation history.
The first comparison tool, a schema diff between the live data-store record shape and v1's GraphQL types, landed alongside the first resolver. It surfaced shape gaps before they became runtime surprises.
Phase 2: resolver-by-resolver parity
A month of one-resolver-per-PR, each carrying a should match between v1 and v2 test against a recorded v1 fixture. Domains shipped, in
order: core records, price history, product taxonomy, inventory and
related items, exclusive offers, seals, translations, seller cache,
price info, cost model, featured promotions, financing and insurance,
seller data (with contact info), search results, ranking and tracking
parameters, product attributes (200+ types), page URLs, media, price
evaluation, location, feature toggles + user-data forwarding.
Supporting infrastructure landed alongside: a token-caching function for service-to-service identity tokens, a staging deploy pipeline, and an env-validation layer.
Phase 3: DataLoader optimisation
Once basic resolvers worked, round trips were the next thing to attack. Direct API calls became batched DataLoader fetches: the pricing loader minified its outgoing GraphQL and computed cache keys ahead of time; the promotions loader fetched ahead of selection-set evaluation; the inventory loader consolidated 404 handling and added an eligibility guard; the valuation-history loader batched cleanly with no upstream changes.
By the end of this phase, a query with twenty records made a predictable number of upstream calls, one per loader, instead of twenty-times-N.
Phase 4: shadow-diff pipeline
This was the confidence engine.
Every production request that hit v2 was replayed against v1 asynchronously, the responses were diffed, and both diff size and diff percentage were emitted as metrics.
The mechanic in code is small:
// clone the request before yoga consumes it
const cloned = diffingEnabled ? request.clone() : null;
const response = await yoga.fetch(request, env);
if (cloned) {
// fires after the response is sent, never blocks the user
setTimeout(() => performQueryDiff(cloned, response.clone()), 0);
}
return response;
The diff step extracts query + variables, calls the legacy
service, normalises a few known-inconsistent fields (one legacy field
with unstable JSON key ordering was the worst offender), runs the diff
with object-hash array matching, emits size and percentage metrics,
and optionally persists the diff to object storage keyed by content
hash so repeats deduplicate.
Two feature toggles control the pipeline independently:
- one for the diff itself
- one for whether diffs are persisted to storage
Splitting them mattered more than I expected. The metrics were cheap; the storage writes were the noisy thing. We ran with metrics on 24/7 and persistence on for windows when we wanted to inspect. A small internal UI browsed the persisted diffs.
Phase 5: schema drift on two rails
A v1/v2 strangler fig has a quiet failure mode: v1's schema keeps evolving while v2 is being built. We caught this with two parallel mechanisms:
- Hourly drift monitoring introspects the live v1 schema and diffs it against v2's schema. Drift posts to a team channel, pure signal, doesn't fail anything.
- PR validation that blocks merge compares PR schema changes against the main branch and posts a comment classifying each change as additive, breaking, or dangerous. Breaking and dangerous changes block merge.
Two rails because the failure modes are different. The PR gate stops people from shipping breaking changes inside the project; the hourly job catches changes that happen outside it.
Phase 6: observability and hardening
Custom yoga plugins replaced what the old stack had given us out of the box:
- A custom yoga logger pipes yoga's logs through structured logging for the observability platform.
- An operation counter tracks total operations by name (introspection excluded).
- A global error handler parses the document AST to extract locale and skips expected errors.
- A structured "record not found" error carries an
expectedboolean flag and a metric tagged by operation name and locale, so record-not-found stops looking like a real error. - A response-logger middleware logs 4xx/5xx from the GraphQL endpoint.
- A data-store projection toggle A/B-tests full-document reads against projected reads.
Phase 7: public exposure and auth
The repository was restructured into a monorepo package. CDN distributions for staging and production made v2 publicly addressable. Auth middleware enforced basic auth on the public API domain for known clients: the web frontend, the iOS app, the Android app, and the partner integrations.
Phase 8: progressive cutover
Traffic migration is controlled by feature toggles. A shadow-traffic toggle indicates v1 can route shadow traffic to v2 selectively. Each consumer cohort moves on its own cadence, with the diff metrics as the gate.
The hard parts
A legacy field's JSON-string normalisation. v1 returned a JSON-encoded string with key ordering that shifted between requests. v2's encoder ordered keys deterministically, which the diff pipeline reported as a "difference" on every single response. We added a normaliser to the diff pipeline before the comparison, but in hindsight, two days were lost to chasing what looked like real divergence.
Field-shape ambiguity in price info. v1's price-info resolver returned slightly different shapes depending on record type. The behaviour was undocumented; the consumers depended on it. Reproducing it in v2 required reading the v1 resolver code line-by-line, there was no spec.
Custom yoga plugins arrived late. We added the operation counter, error counter, and structured error type in month five, after three months of resolvers without observability matching v1's. By the time they landed we'd already debugged a couple of incidents on partial data.
Outcome
| Metric | Before | After |
|---|---|---|
| v2 production response coverage | — | 100% diffed |
| Resolvers at v1 parity | 0 | 20+ |
| Per-request DataLoader rationalisation | ad-hoc | 8 per request |
| Schema drift detection | None | Hourly + PR gate |
| Cutover events required | — | 0 |
| Consumer-visible regressions during shift | — | 0 |
What I'd do differently
The diffing pipeline would land in week one, not week ten. Six weeks of resolver building without anything production-shaped to validate against meant the parity tests carried more weight than they deserved. The diff pipeline pointed at shadow-shaped staging data would have surfaced the JSON-normalisation issue, the price-info mismatches, and a handful of locale quirks two months earlier. Build the confidence engine before you build the thing it has confidence in.
Custom yoga plugins should ship alongside their first use, not later. Leaving the framework defaults means recreating them, operation counting, structured error handling, expected-vs-real classification. Budget for that on day one or you spend it later under load.
Be stricter about which v1 quirks v2 inherits. Some v1 responses had inconsistencies the original team had filed under "clients tolerate it." We diffed those into v2 because v1 was the oracle. By month three we were maintaining "deliberate non-parity" notes, fields where v2 was correct and v1 was the bug. A cleaner policy from day one would have been: parity is the default; intentional deviations get documented with a link to the consumer who needs to notice.