Der sauberste Weg, einen GraphQL-Server zu migrieren, ist, ihn nicht zu migrieren.
Der fragliche Graph war eine öffentliche Read-API, Produktdaten, Suche, Preise, ein Seller-seitiger Cache, die den Marktplatz über Web, native iOS, native Android, Partnerintegrationen und ein internes 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 einen komplett neuen
Service (v2: graphql-yoga v5 + hono, kein gemeinsamer Code mit v1)
und ließen ihn vier Monate parallel laufen, während der Datenspeicher
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
v1 war ein Legacy-GraphQL-Service, dessen Resolver-Schicht sich um frühere Framework-Idiome verhärtet hatte. Er las aus einer dokumentenbasierten Datenbank (per Assume-Role) und rief eine Sammlung interner APIs auf: Inventory Service, Finance and Insurance, Exclusive Offers, Price Evaluation, einen Seller-seitigen Cache (Serverless + Object Storage) sowie eine Vielzahl von Produkt-Taxonomie-Lookups.
┌──────────────────┐
Web / Native / │ │
Funnels ─────► │ v1 │ ← live in Produktion
└─────────┬────────┘
│
┌───────────┴───────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Store │ │ Upstream │
│ (Daten) │ │ 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 (Datensatz 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
Woche eins war Scaffolding: Datenbank-Zugriff per Assume-Role,
Runtime-Validierung der Records mit einer Schema-Validierungs-
Bibliothek, ein SDL-first-Schema geladen aus einer einzigen
Root-Schema-Datei über yogas createSchema(), generierte Typen über
dieses Schema, 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, geladen von
createSchema(). Kein Annotations-getriebener Tanz. DataLoaderper Request in der yoga-Context-Factory instanziiert. Im stationären Zustand waren es acht, für Datensätze, Seller, Kundendaten, Preise, Promotions, Inventar, verwandte Einträge und Bewertungshistorie.
Das erste Vergleichswerkzeug, ein Schema-Diff zwischen der Datenbank-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
Ein Monat ein-Resolver-pro-PR, jeder mit einem should match between v1 and v2-Test gegen ein aufgezeichnetes v1-Fixture. Domains in
Reihenfolge: Kerndatensätze, Price History, Produkt-Taxonomie (Kategorie
/ Modell / Typ / Attribute), Inventory und verwandte Einträge, Exclusive
Offers, Seals, Übersetzungen, Seller-Cache, Price Info, Cost Model,
Featured Promotions, Financing and Insurance, Seller-Daten (mit
Kontaktdaten), Suchergebnisse, Ranking und Tracking-Parameter,
Produkt-Attribute (200+ Typen), Seiten-URL, Media, Price Evaluation,
Location, Feature-Toggles + User-Data-Forwarding.
Begleitende Infrastruktur landete parallel: eine Token-Caching-Funktion für Service-zu-Service-Identity-Tokens, eine Staging-Deploy-Pipeline und eine Env-Validation-Schicht.
Phase 3: DataLoader-Optimierung
Sobald die Basis-Resolver liefen, waren die Round-Trips das nächste Ziel. Direkte API-Calls wurden zu gebatchten DataLoader-Fetches: der Pricing-Loader minimierte das ausgehende GraphQL und berechnete Cache-Keys vorab; der Promotions-Loader holte ahead-of-time vor der Selection-Set-Auswertung; der Inventory-Loader konsolidierte 404-Handling und fügte einen Eligibility-Guard hinzu; der Valuation-History-Loader batchte sauber ohne Upstream-Änderungen.
Am Ende dieser Phase machte eine Query mit zwanzig Datensätzen eine vorhersagbare Anzahl an Upstream-Calls, einen pro Loader, statt zwanzig-mal-N.
Phase 4: Shadow-Diff-Pipeline
Das war die Confidence-Engine.
Jeder Produktions-Request, der v2 traf, wurde asynchron gegen v1 gespielt, die Antworten wurden gediffed, und sowohl Diff-Größe als auch Diff-Prozent als Metriken emittiert.
Die Mechanik im Code ist klein:
// Request klonen, bevor yoga ihn konsumiert
const cloned = diffingEnabled ? request.clone() : null;
const response = await yoga.fetch(request, env);
if (cloned) {
// feuert nach dem Antworten, blockiert den Nutzer nie
setTimeout(() => performQueryDiff(cloned, response.clone()), 0);
}
return response;
Der Diff-Schritt extrahiert query + variables, ruft den
Legacy-Dienst auf, normalisiert ein paar bekannt-inkonsistente Felder
(ein Legacy-Feld mit instabiler JSON-Key-Reihenfolge war der
schlimmste Übeltäter), führt den Diff mit Object-Hash-Array-Matching
aus, emittiert Größen- und Prozent-Metriken und persistiert den Diff
bei Bedarf in Object Storage, keyed nach Content-Hash, sodass
Wiederholungen deduplizieren.
Zwei Feature-Toggles steuern die Pipeline unabhängig:
- einer für den Diff selbst
- einer dafür, ob Diffs persistiert werden
Die Trennung war wichtiger, als ich erwartet hatte. Die Metriken waren billig; die Storage-Writes waren das laute Ding. Wir liefen mit Metriken 24/7 an und mit Persistenz nur in Fenstern, in denen wir hineinsehen wollten. Eine kleine interne UI blätterte durch die persistierten Diffs.
Phase 5: Schema-Drift auf zwei Schienen
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 introspeziert das Live-v1-Schema und diffed es gegen v2s Schema. Drift postet in einen Team-Channel, reines Signal, bricht nichts ab.
- PR-Validierung, die Merges blockiert 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
Eigene yoga-Plugins ersetzten, was der alte Stack ab Werk gegeben hatte:
- Ein eigener yoga-Logger pipt yogas Logs durch strukturiertes Logging für die Observability-Plattform.
- Ein Operation-Counter zählt Operationen insgesamt nach Namen (Introspektion ausgeschlossen).
- Ein globaler Error-Handler parsed das Document-AST, um die Locale zu extrahieren, und überspringt erwartete Fehler.
- Ein strukturierter "Datensatz nicht gefunden"-Fehler trägt ein
expected-Boolean-Flag und eine Metrik mit Operation-Name- und Locale-Tag, sodass das nicht mehr wie ein echter Fehler aussieht. - Eine Response-Logger-Middleware loggt 4xx/5xx aus dem GraphQL-Endpoint.
- Ein Projection-Toggle auf der Datenbank A/B-testet vollständige Dokument-Reads gegen projizierte Reads.
Phase 7: Öffentliches Exponieren und Auth
Das Repository wurde in ein Monorepo-Package umstrukturiert. CDN-Distributionen für Staging und Produktion machten v2 öffentlich adressierbar. Eine Auth-Middleware erzwang Basic-Auth auf der öffentlichen API-Domain für bekannte Clients: das Web-Frontend, die iOS-App, die Android-App und die Partnerintegrationen.
Phase 8: Stufenweise Cutover
Die Traffic-Migration wird über Feature-Toggles gesteuert. Ein Shadow-Traffic-Toggle 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
Normalisierung eines Legacy-Felds mit JSON-String. 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 Datensatz-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 Error-Typ in Monat fünf 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 JSON-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.