Der sauberste Weg, einen GraphQL-Server zu migrieren, ist, ihn nicht zu migrieren.
Wir hatten eine öffentliche Read-API, die den Marktplatz versorgte, Datensätze, Suche, Preise, einen Seller-seitigen Cache, das volle Programm. Das Framework war aus dem Support gedriftet, und die Anreize für ein In-place-Upgrade waren miserabel: Dutzende Konsumenten über Web, native Apps und Partnerintegrationen, kein Wartungsfenster im Angebot und eine Regressionslatte von "die Nutzer dürfen nichts merken". Jeder In-place-Upgrade-Plan, den ich skizzierte, endete in einem Freeze, einem Big-Bang-Wochenende oder beidem.
Also hörte ich auf, Upgrades zu skizzieren. Wir bauten v2 als komplett
neuen Service, graphql-yoga v5 + hono, kein gemeinsamer Code mit
v1, und ließen ihn vier Monate parallel laufen, während die
Datenpipeline beide bediente. Bis wir echten Consumer-Traffic
anfassten, war v2s Antwort für jede Produktions-Query bereits im
Maßstab gegen v1s Antwort gespielt worden, und wir wussten, welche
Felder abwichen und um wie viel.
Drei Mechaniken haben das möglich gemacht.
Mechanik 1: eine parallele Codebase, kein Refactoring
Die Versuchung bei einer Strangler-Fig-Migration ist, einen Fuß in der alten Codebase zu behalten. Typen teilen, Clients teilen, Resolver-Glue teilen, alles um "die Migration einfacher zu machen". Jedes Mal, wenn ich das gesehen habe, hat der alte Code den neuen in seine Design-Constraints hineingezogen.
Wir haben nichts geteilt. v2 war ein neues Repository mit einem neuen
HTTP-Framework (hono), einem neuen GraphQL-Server (graphql-yoga
v5, fetch-basiert im Einklang mit hono), einer neuen
Runtime-Validierungsschicht für Record-Formen, einer
SDL-first-Typing-Strategie über eine einzige Schema-Datei und einem
neuen Context-Modell, acht DataLoader pro Request in der
yoga-Context-Factory instanziiert: Datensätze, Seller, Kundendaten,
Finance und Insurance, Exclusive Offers, verwandte Einträge und Price
Evaluation.
Der erste Foundation-PR landete früh. Der erste Resolver auf
v1-Parität, Kerndatensätze via DataLoader, landete eine Woche später.
Von da an war es ein Monat ein-Resolver-pro-PR: Produkt-Taxonomie,
Price History, verwandte Einträge, Exclusive Offers, Seals,
Übersetzungen, Seller-Cache, Price Info, Cost Model, Featured
Promotions, Financing, Seller-Daten, Suchergebnisse, Ranking,
Produkt-Attribute, Media, Location, Feature-Toggles +
User-Data-Forwarding. Jeder PR trug einen should match between v1 and v2-Test. Die Liste ist lang, weil die API-Oberfläche breit ist;
die Invariante auf jedem PR war klein, verhalte dich wie v1.
Die Kosten einer parallelen Codebase sind real. Man implementiert Plumbing neu. Man verbrennt Kalenderzeit, bevor irgendetwas Nutzer-sichtbares shippt. Man entdeckt, dass manche "offensichtlichen" v1-Verhaltensweisen nie irgendwo spezifiziert waren, sie waren der Resolver-Code. Der Nutzen, der sich später auszahlt: v2s Design ist von dem geformt, was v2 braucht, nicht von dem, was v1 hatte.
Mechanik 2: eine Shadow-Diff-Pipeline als Confidence-Engine
Resolver-für-Resolver-Paritätstests fangen, was man zu testen sich erinnert. Die Unbekannten, Produktions-Query-Formen, die niemand im Team je aufgeschrieben hat, Long-Tail-Clients mit seltsamen Selection Sets, Locale-spezifische Eigenheiten, zeigen sich nur gegen echten Traffic.
Also shippten wir auf halbem Weg durch den Build die Diff-Pipeline. Sie tut genau eine Sache: jeder Produktions-Request, der v2 trifft, wird zusätzlich asynchron gegen v1 gespielt, die Antworten werden gediffed, und sowohl Diff-Größe als auch Diff-Prozent werden als Metriken emittiert.
Die Mechanik im Code ist klein:
// 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;
Der Diff-Schritt zieht query + variables aus dem geklonten
Request, 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 sie unabhängig:
- einer schaltet die Diff-Pipeline an oder aus
- einer steuert, ob Diffs in den Storage 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 und mit Persistenz nur in Fenstern, in denen wir hineinsehen wollten. Eine kleine interne UI blätterte durch die persistierten Diffs. An den meisten Tagen öffnete das Team sie nicht; an den Tagen, an denen doch, war sie das Erste, wonach sie griffen.
Die Diff-Pipeline ist der Teil dieser Migration, den ich am ehesten komplett in ein zukünftiges Projekt übernehmen würde. Sie ist der Grund, warum "sollen wir den Traffic umschalten" eine Frage mit einer numerischen Antwort wurde statt eines Bauchgefühls.
Mechanik 3: 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. Man glaubt, Parität zu haben; hat man nicht, weil jemand an einem Dienstag ein Feld hinzugefügt hat.
Wir fingen das mit zwei parallelen Mechanismen ab.
Stündliches Drift-Monitoring. Ein Scheduled Job introspiziert das Live-v1-Schema und diffed es gegen v2s Schema. Drift postet in einen Team-Channel. Er bricht nichts ab, reines Signal. Der Job ist langweilig, weil das PR-Gate darunter fast alles fängt; der stündliche Lauf ist das Sicherheitsnetz für Änderungen, die am PR-Gate vorbeigehen (Hotfixes, manuelle Deploys, alles Seltsame).
PR-Validierung, die Merges blockiert. Jeder PR, der das v2-Schema berührt, läuft einen Vergleich gegen den Main-Branch und postet einen Kommentar, der jede Änderung als additiv, breaking oder dangerous klassifiziert. Breaking- und Dangerous-Änderungen blockieren den Merge, bis jemand mit dem richtigen Scope sie bestätigt.
Zwei Schienen, weil die Failure-Modes unterschiedlich sind. Das PR-Gate stoppt Leute davor, Breaking Changes innerhalb des Projekts zu shippen; der stündliche Job fängt Änderungen, die außerhalb passieren.
Was ich anders machen würde
Die Diff-Pipeline würde in Woche eins landen, nicht in Woche zehn. Wir haben sechs Wochen lang parallel Resolver gebaut, ohne irgendetwas Produktions-geformtes zur Validierung zu haben. Die Paritätstests waren nützlich, aber begrenzt; die Diff-Pipeline, auf Shadow-Traffic gerichtet, selbst geformt aus Staging-Daten, hätte das Feld-Normalisierungsproblem, die price-info-Feld-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 shipped, nicht
später. Ein Operation-Counter, ein Operation-Error-Counter, ein
strukturierter Error-Typ mit einem expected-Flag, eine
Response-Logger-Middleware, die landeten spät, nach drei Monaten
Resolver ohne Observability auf v1-Niveau. Bis sie drin waren, hatten
wir bereits ein paar Incidents auf Teildaten debuggt. Sie hätten der
dritte oder vierte PR sein sollen, nicht der dreißigste. Die
Framework-Defaults zu verlassen heißt, sie nachzubauen; plane das von
Tag eins ein, oder du zahlst es später unter Last.
Strenger sein, welche v1-Eigenheiten v2 erbt. Manche v1-Antworten hatten Inkonsistenzen, die das ursprüngliche Team unter "Clients tolerieren das" abgelegt hatte. Wir diffeden die in v2 hinein, 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.
Wenn du eine ähnliche Migration erwägst und Notizen vergleichen willst, die Kontaktseite hat die einfachsten Kanäle.