Der sauberste Weg, einen GraphQL-Server zu migrieren, ist, ihn nicht zu migrieren.
Der fragliche Graph war eine öffentliche Read-API, Listings, Suche, Preise, Dealer-Cache, die den Marktplatz über Web, native iOS, native Android, Partnerintegrationen und ein internes Marketplace-Produkt versorgte. Das Framework war aus dem Support gefallen, der Resolver-Layer hatte organische Auswüchse über seinen Abstraktionen ausgebildet, und ein In-place-Upgrade hätte entweder ein Wartungsfenster verlangt, das wir nicht nehmen konnten, oder einen Freeze, den wir nicht durchsetzen konnten.
Also plante ich keines von beidem. Wir bauten graphql-api-v2
als komplett neuen Service, graphql-yoga v5 + hono, kein
gemeinsamer Code mit v1, und ließen ihn vier Monate parallel laufen,
während DynamoDB und die Upstream-APIs beide bedienten. Bis wir echten
Consumer-Traffic anfassten, war jede Produktions-Query, die v2
erreichte, asynchron gegen v1 gespielt und Feld für Feld verglichen
worden. Wir wussten, welche Felder abwichen und um wie viel, bevor wir
ein einziges Prozent Traffic verschoben.
Der Ausgangszustand
graphql-api-v1 war ein Legacy-GraphQL-Service, dessen
Resolver-Schicht sich um frühere Framework-Idiome verhärtet hatte. Er
las aus einer DynamoDB-Tabelle mit Classified-Daten (per Assume-Role)
und rief eine Sammlung interner APIs auf: Inventory Service, Finance and
Insurance, Exclusive Offers, Price Evaluation, Dealer Cache (Lambda +
S3) sowie eine Vielzahl von Vehicle-Taxonomy-Lookups.
┌──────────────────┐
Web / Native / │ │
Funnels ─────► │ graphql-api-v1 │ ← live in Produktion
└─────────┬────────┘
│
┌───────────┴───────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ DynamoDB │ │ Upstream │
│ (classif)│ │ Services │
└──────────┘ └──────────┘
Drift hatte sich entlang dreier Achsen breitgemacht:
- Framework: der GraphQL-Server lief auf einem Stack ohne sauberen Upgrade-Pfad; aus früheren Idiomen geerbte Konventionen waren zu handgeschriebenem Glue-Code verhärtet.
- Type Safety: Typen wurden generiert, aber der Pfad von SDL zu Resolver hatte genug manuelles Stitching angesammelt, um das Hinzufügen eines Felds zur Koordinationsübung zu machen.
- Observability: erwartete Fehler (Listing nicht gefunden) und echte Fehler sahen in den Metriken gleich aus, On-call wurde für beides geweckt.
Constraints
| Constraint | Implikation |
|---|---|
| Live öffentliche API | Web, native iOS und Android, Partnerintegrationen, interne Funnels, kein Wartungsfenster im Angebot. |
| Feld-genaue Parität | Konsumenten waren auf Resolver-Outputs auf Feldebene angewiesen; selbst eine JSON-Serialisierungsdifferenz konnte einen Downstream-Parser brechen. |
| Keine gemeinsame Release-Cadence | Die Consumer-Integrationen koordinierten ihre Releases nicht mit uns. Die API musste konsistent funktionieren, oder sich sofort zurückrollen lassen. |
| Confidence im Maßstab | Eine Test-Suite konnte die Produktions-Query-Formen nicht aufzählen. Confidence musste aus echtem Traffic kommen. |
Vorgehen
Phase 1: Foundation (Mitte November 2025)
Woche eins war Scaffolding: DynamoDB-Zugriff per Assume-Role,
Runtime-Validierung der DDB-Records mit arktype, ein SDL-first-Schema
geladen aus rootSchema.graphql über yogas createSchema(),
Type-Generation mit gql.tada + @graphql-codegen, und der erste
DataLoader-basierte Resolver. Die architektonischen Entscheidungen,
die später wichtig wurden, fielen hier:
graphql-yogav5 als GraphQL-Server, Web-Standards-fetch()im Einklang mithono, weniger Batteries-included-Annahmen als der alte Stack mit sich herumtrug.- SDL-first, das Schema lebt in einer Datei,
rootSchema.graphql, geladen voncreateSchema(). Kein Annotations-getriebener Tanz. DataLoaderper Request in der yoga-Context-Factory instanziiert. Im stationären Zustand waren es acht:listingLoader,sellerDetailsLoader,externalCustomerLoader,financingLoader,promotionLoader,stockInfoLoader,twinListingLoader,priceEvaluationLoader.
Das erste Vergleichswerkzeug, ein Schema-Diff zwischen der DynamoDB-Record-Form und v1s GraphQL-Typen, landete neben dem ersten Resolver. Es brachte Form-Lücken ans Licht, bevor sie zu Laufzeit-Überraschungen wurden.
Phase 2: Resolver-für-Resolver-Parität (Mitte November bis Mitte Dezember)
Ein Monat ein-Resolver-pro-PR, jeder mit einem should match between v1 and v2-Test gegen ein aufgezeichnetes v1-Fixture. Domains in
Reihenfolge: Listings, Price History, Vehicle-Taxonomy (Make / Model /
Body / Fuel / Engine), Inventory und Twin-Listings, Exclusive Offers,
Seals, Übersetzungen, Dealer-Cache, Price Info, Cost Model, Featured Promotions,
Financing and Insurance, Seller (mit Vendor-Contact-Daten),
Suchergebnisse (searchByFilters), Ranking und
Tracking-Parameter, Vehicle-Equipment (200+ Typen), Webpage-URL, Media,
Price Evaluation, Location, Feature-Toggles + userData-Forwarding.
Begleitende Infrastruktur landete parallel: ein Token-Caching-Lambda
für M2M-Identity-Tokens in AWS SSM, eine Staging-Deploy-Pipeline und
eine Env-Validation-Schicht, gepinnt über arktype.
Phase 3: DataLoader-Optimierung (Anfang Dezember)
Sobald die Basis-Resolver liefen, waren die Round-Trips das nächste
Ziel. Direkte API-Calls wurden zu gebatchten DataLoader-Fetches:
financingLoader minimierte das ausgehende GraphQL via
gqlmin und berechnete Cache-Keys vorab; promotionLoader holte
ahead-of-time vor der Selection-Set-Auswertung; stockInfoLoader
konsolidierte 404-Handling und fügte einen isBusinessListing()-Guard
hinzu; valuationHistoryLoader batchte sauber ohne Upstream-Änderungen.
Am Ende dieser Phase machte eine Query mit zwanzig Listings eine vorhersagbare Anzahl an Upstream-Calls, einen pro Loader, statt zwanzig-mal-N.
Phase 4: Shadow-Diff-Pipeline (Mitte Januar 2026)
Das war die Confidence-Engine.
Jeder Produktions-Request, der v2 traf, wurde asynchron gegen v1
gespielt, die Antworten wurden mit jsondiffpatch gediffed, und
sowohl Diff-Größe als auch Diff-Prozent als Datadog-Metriken
emittiert.
Die Mechanik im Code ist klein:
// app.ts, Request klonen, bevor yoga ihn konsumiert
const cloned = diffingEnabled ? request.clone() : null;
const response = await yoga.fetch(request, env);
if (cloned) {
// setTimeout feuert nach dem Antworten, blockiert den Nutzer nie
setTimeout(() => performQueryDiff(cloned, response.clone()), 0);
}
return response;
performQueryDiff extrahiert query + variables, ruft
fetchFromLegacyService auf, normalisiert ein paar bekannt-
inkonsistente Felder (der adTargeting-JSON-String war der
schlimmste Übeltäter), führt den Diff mit Object-Hash-Array-Matching
aus, emittiert graph_v1_v2_diff.diff_size und
graph_v1_v2_diff.diff_percentage und persistiert den Diff bei Bedarf
nach S3, keyed nach Content-Hash, sodass Wiederholungen
deduplizieren.
Zwei Feature-Toggles steuern die Pipeline unabhängig:
enable-query-diffing-v2, der Diff selbstenable-query-diffing-v2-save-s3, der S3-Write
Die Trennung war wichtiger, als ich erwartet hatte. Die Metriken
waren billig; die S3-Writes waren das laute Ding. Wir liefen mit
Metriken 24/7 an und mit S3 nur in Fenstern, in denen wir hineinsehen
wollten. Eine kleine UI unter /tools/diffs blätterte durch die
persistierten Diffs.
Phase 5: Schema-Drift auf zwei Schienen (Ende Januar)
Ein v1/v2-Strangler-Fig hat einen leisen Failure-Mode: v1s Schema entwickelt sich weiter, während v2 gebaut wird. Wir fingen das mit zwei parallelen Mechanismen ab:
- Stündliches Drift-Monitoring (
schema-drift-monitoring.yaml) introspeziert das Live-v1-Schema und diffed es gegen v2srootSchema.graphql. Drift postet nach Slack, reines Signal, bricht nichts ab. - PR-Validierung, die Merges blockiert
(
schema-pr-validation.yaml) vergleicht Schema-Änderungen aus PRs gegen den Main-Branch und postet einen Kommentar, der jede Änderung als additiv, breaking oder dangerous klassifiziert. Breaking- und Dangerous-Änderungen blockieren den Merge.
Zwei Schienen, weil die Failure-Modes unterschiedlich sind. Das PR-Gate stoppt Leute innerhalb des Projekts; der stündliche Job fängt Änderungen, die außerhalb passieren.
Phase 6: Observability und Härtung (Februar 2026)
Eigene yoga-Plugins ersetzten, was der alte Stack ab Werk gegeben hatte:
- Ein eigener yoga-Logger pipt yogas Logs durch
pinofür strukturiertes Datadog-Logging. useOperationCounterzähltgraphql_operations_totalnach Operation-Name (Introspektion ausgeschlossen).useOperationErrorCounterist ein globaler Error-Handler überuseErrorHandler; er parsed das Document-AST, um die Locale zu extrahieren, und überspringt erwartete Fehler.listingNotFoundErrorist ein strukturierterAppErrormit einemexpected-Boolean-Flag und einer StatsD-Metrik mitoperationName+locale-Tag, Listing-not-found sieht damit nicht mehr wie ein echter Fehler aus.- Eine Response-Logger-Middleware loggt 4xx/5xx aus dem GraphQL-Endpoint.
- Ein DynamoDB-Projection-Toggle A/B-testet vollständige Dokument-Reads gegen projizierte Reads.
Phase 7: Öffentliches Exponieren und Auth (März 2026)
Das Repository wurde in ein Yarn-Workspace + Turborepo-Monorepo
umstrukturiert (apps/graphql-api/). CloudFront-Distributionen für
Staging und Produktion machten v2 öffentlich adressierbar. Eine
Auth-Middleware erzwang Basic-Auth auf *.api.<platform>.com für
bekannte Clients, marketplace-web (das Web-Frontend),
web-frontend, ios-app, android-app und die
Partnerintegrationen.
Phase 8: Stufenweise Cutover (laufend)
Die Traffic-Migration wird über Feature-Toggles gesteuert. Der Toggle
enable-graphql-api-v2-shadow-traffic zeigt an, dass v1 selektiv
Shadow-Traffic an v2 routen kann. Jede Consumer-Kohorte zieht in
eigener Frequenz um, mit den Diff-Metriken als Gate.
Die schwierigen Stellen
adTargeting-JSON-String-Normalisierung. v1 lieferte einen
JSON-codierten String mit Key-Reihenfolge, die zwischen Requests
schwankte. v2s Encoder ordnete Keys deterministisch, die
Diff-Pipeline meldete das auf jeder einzelnen Antwort als
"Differenz". Wir bauten einen Normalisierer in die Diff-Pipeline ein,
bevor verglichen wurde, aber im Nachhinein gingen zwei Tage damit
verloren, scheinbarer Divergenz hinterherzurennen.
Feldform-Unklarheit in price-info. v1s price-info-Resolver lieferte je nach Listing-Typ leicht unterschiedliche Formen. Undokumentiertes Verhalten, auf das die Konsumenten sich verließen. Es in v2 zu reproduzieren hieß, den v1-Resolver Zeile für Zeile zu lesen, es gab keine Spezifikation.
Eigene yoga-Plugins kamen zu spät. Wir fügten Operation-Counter,
Error-Counter und strukturierten AppError im Februar hinzu, nach
drei Monaten Resolver ohne Observability auf v1-Niveau. Bis sie
landeten, hatten wir bereits ein paar Incidents auf Teildaten
debuggt.
Ergebnis
| Metrik | Vorher | Nachher |
|---|---|---|
| Coverage der v2-Produktionsantworten | , | 100% gediffed |
| Resolver auf v1-Parität | 0 | 20+ |
| Per-Request-DataLoader-Rationalisierung | ad-hoc | 8 pro Request |
| Schema-Drift-Erkennung | Keine | Stündlich + PR-Gate |
| Erforderliche Cutover-Events | , | 0 |
| Consumer-sichtbare Regressionen während Shifts | , | 0 |
Was ich anders machen würde
Die Diff-Pipeline würde in Woche eins landen, nicht in Woche zehn.
Sechs Wochen Resolver-Bauen ohne irgendetwas Produktions-geformtes zur
Validierung bedeuteten, dass die Paritätstests mehr Gewicht trugen,
als sie verdient hatten. Die Diff-Pipeline gegen Shadow-geformte
Staging-Daten hätte die adTargeting-Normalisierung, die
price-info-Mismatches und eine Handvoll Locale-Eigenheiten zwei
Monate früher zutage gefördert. Bau die Confidence-Engine, bevor du
das Ding baust, in das sie Confidence haben soll.
Eigene yoga-Plugins gehören zur ersten Nutzung, nicht später. Den Framework-Default zu verlassen heißt, ihn nachzubauen, Operation Counting, strukturierte Fehlerbehandlung, Klassifizierung erwartet-vs-real. Plane das von Tag eins ein, oder du zahlst es später unter Last.
Härter sein, welche v1-Eigenheiten v2 erbt. Manche v1-Antworten hatten Inkonsistenzen, die das ursprüngliche Team unter "Clients tolerieren das" abgelegt hatte. Wir diffeden das in v2, weil v1 das Orakel war. Im dritten Monat führten wir "bewusste Nicht-Parität"- Notizen, Felder, in denen v2 korrekt und v1 der Bug war. Eine sauberere Politik von Tag eins wäre gewesen: Parität ist Default; absichtliche Abweichungen werden dokumentiert, mit Verweis auf den Konsumenten, der das mitbekommen muss.