GraphQL-Schema, das mitwächst: Evolution ohne Bruch
Wie man ein GraphQL-Schema additiv weiterentwickelt, statt es zu versionieren – sichere Änderungen, echte Breaking Changes, @deprecated als Werkzeug und eine Feld-Migration Schritt für Schritt.
GraphQL-Schema, das mitwächst: Evolution ohne Bruch
Im GraphQL-Überblick und in den GraphQL-Konzepten habe ich das Schema als das Herzstück beschrieben: ein typisierter Vertrag zwischen Server und Client, verbindlich, überprüfbar, zugleich Dokumentation und Validierungsregel. Beide Beiträge haben am Ende dasselbe Versprechen gemacht – dass ich einen eigenen Artikel darüber schreibe, wie so ein Schema mitwächst. Den löse ich hier ein.
Denn ein Vertrag, der sich nie ändert, ist einfach. Die schwierige Frage kommt später, im laufenden Betrieb: Ein neues Feld muss rein, ein altes ist überflüssig geworden, eine Signatur passt nicht mehr. Wie ändert man diesen Vertrag, während draußen Clients laufen, die man teils nicht einmal kennt – Web, Mobil, ein Partner-Backend, eine App-Version von vor achtzehn Monaten, die noch im Feld ist? Genau an dieser Stelle trennt sich in meinen Schulungen die Spreu vom Weizen. Fast jeder kann ein GraphQL-Schema entwerfen. Es über Jahre zu pflegen, ohne bestehende Clients zu brechen, ist die eigentliche Disziplin.
Kein /v2, sondern ein Schema
Wer von REST kommt, hat einen Reflex: Für eine größere Änderung baut man /v2. Die alte URL bleibt, die neue lebt daneben, irgendwann schaltet man /v1 ab. Das funktioniert, aber es kostet – jede Version ist Code, den jemand baut, testet, betreibt und dokumentiert, oft jahrelang parallel.
GraphQL geht einen anderen Weg. Es gibt keine Versionsnummer in der URL, sondern genau ein Schema, das sich additiv weiterentwickelt. Der Grund dafür steckt in einer Eigenschaft, die ich in den Konzepten schon beschrieben habe: Ein Client bekommt nur, was er in seiner Abfrage nennt. Er fragt name und title, also bekommt er name und title – und sonst nichts. Kommt morgen ein Feld subtitle ins Schema, merkt dieser Client es nicht einmal. Er hat es nie genannt, also taucht es in seiner Antwort nicht auf, und seine Antwort behält exakt dieselbe Form wie gestern.
Das ist der Hebel. Weil der Client die Form seiner Antwort selbst bestimmt, stören ihn zusätzliche Felder im Schema grundsätzlich nicht. Man kann das Schema also wachsen lassen, ohne dass jede Ergänzung ein Ereignis ist. Die offizielle Doku bringt es auf den Punkt: GraphQL nimmt eine klare Haltung gegen Versionierung ein und stellt stattdessen Werkzeuge für kontinuierliche Weiterentwicklung bereit.
Sichere Änderungen: das, was man einfach hinzufügen darf
Eine ganze Klasse von Änderungen ist von Natur aus ungefährlich, weil sie nur ergänzt und nie etwas wegnimmt, worauf ein Client sich verlässt. Ein neues Feld an einem bestehenden Typ bemerkt niemand, der es nicht abfragt. Ein neuer Typ oder ein neuer Query- beziehungsweise Mutation-Einstieg ist reines Angebot, keine Pflicht. Ein neues optionales Argument an einem bestehenden Feld lässt jeden, der es weglässt, beim alten Verhalten – vorausgesetzt, der Default entspricht dem, was vorher galt. Und ein neuer Enum-Wert ist ebenfalls additiv, allerdings mit einer Einschränkung, auf die ich gleich zurückkomme, denn hier lauert eine Falle.
Der gemeinsame Nenner: Bestehende Abfragen liefern nach der Änderung dasselbe Ergebnis wie vorher. Das ist die eigentliche Definition von „nicht brechend" – nicht, dass sich nichts ändert, sondern dass keine gültige Abfrage von gestern heute plötzlich ungültig wird oder eine andere Form zurückgibt.
Breaking Changes: das, was Clients wirklich zerreißt
Auf der anderen Seite stehen die Änderungen, die eine gestern gültige Abfrage heute brechen. Die wichtigsten, mit konkreten Beispielen:
Am offensichtlichsten ist es, ein Feld zu entfernen oder umzubenennen. Fragt ein Client user { fullName } und ich benenne fullName in displayName um, ist die Abfrage sofort ungültig. Umbenennen ist aus Schema-Sicht nichts anderes als Löschen plus Hinzufügen – der alte Name verschwindet, und jede Abfrage, die ihn nennt, läuft in einen Validierungsfehler.
Subtiler, und in jeder Schulung für Überraschung gut, ist die Nullability in die falsche Richtung. Ein nullbares Feld auf non-null zu ziehen ist nicht brechend: Wer bisher mit einem möglichen null gerechnet hat, kommt auch damit klar, dass jetzt immer ein Wert kommt. Umgekehrt aber – ein non-null-Feld nullbar machen – ist brechend. Ein Client, der sich auf die Garantie String! verlassen und nie gegen null geprüft hat, bekommt plötzlich ein null, mit dem er nicht rechnet. Dieselbe Logik gilt für Argumente, nur spiegelverkehrt: Ein Pflichtargument optional zu machen ist harmlos, ein optionales zur Pflicht zu erheben bricht jeden, der es bisher weggelassen hat.
Auch ein geänderter Argument-Typ oder ein neues Pflichtargument bricht. Wird aus einem String-Argument ein Int, passen bestehende Abfragen nicht mehr. Und ein neues nicht-optionales Argument macht jede Abfrage ungültig, die es noch nicht mitliefert – deshalb oben die Betonung auf optional.
Und einen Enum-Wert zu entfernen bricht ebenso: Verschwindet ein Wert, den ein Client als Argument sendet oder in einer Antwort erwartet, bricht die Interaktion.
flowchart LR
subgraph Additiv["Sicher – additiv"]
A1["Feld hinzufügen"]
A2["Typ hinzufügen"]
A3["optionales Argument"]
A4["nullable → non-null"]
end
subgraph Breaking["Bricht Clients"]
B1["Feld entfernen/umbenennen"]
B2["non-null → nullable"]
B3["Pflichtargument hinzufügen"]
B4["Enum-Wert entfernen"]
end
Der Trick beim Lesen dieser Liste: Es geht nie um „mehr" oder „weniger", sondern immer um dieselbe Frage – kann eine gestern gültige Abfrage nach der Änderung noch gültig laufen und dieselbe Form zurückgeben? Wenn ja, ist die Änderung additiv. Wenn nein, ist sie brechend, egal wie klein sie aussieht.
@deprecated: veralten statt entfernen
Weil das Entfernen bricht, braucht man einen Weg, ein Feld loszuwerden, ohne es sofort zu entfernen. Genau dafür gibt es in GraphQL eine eingebaute Direktive: @deprecated.
type User {
id: ID!
fullName: String @deprecated(reason: "Use `displayName` instead. Removal planned for 2026-04.")
displayName: String!
}
Das Feld bleibt voll funktionsfähig – jede Abfrage, die fullName nennt, bekommt weiterhin eine Antwort. Aber das Schema trägt jetzt eine sichtbare Markierung. Über Introspection landet dieser Hinweis in jedem Tooling: Der GraphiQL-Explorer streicht das Feld durch, Editor-Plugins warnen beim Tippen, Codegen und Linter melden die Nutzung. Der reason-Text ist dabei kein Kommentar, sondern die Botschaft an das Team am anderen Ende – idealerweise mit Nachfolger und Frist. Client-Teams sehen den Hinweis, migrieren auf displayName, und erst viel später, wenn niemand fullName mehr abfragt, entferne ich es.
Man kann übrigens nicht nur Felder deprecaten, sondern auch einzelne Enum-Werte und Argumente. Das Muster ist überall dasselbe: erst markieren, Zeit geben, dann entfernen. Deprecation ist damit kein Nachgedanke, sondern das zentrale Werkzeug für Schema-Evolution überhaupt.
Nullability ist eine Design-Entscheidung, kein Reflex
Ein Punkt verdient mehr als eine Zeile, weil er beim Entwurf oft falsch entschieden wird und sich später kaum korrigieren lässt: die Frage, ob ein Feld non-null sein soll.
String! fühlt sich sauber an. Es sagt: Hier kommt immer ein Wert. Aber non-null ist eine Garantie, und Garantien kann man in einem laufenden Vertrag nicht mehr zurücknehmen – der Weg von non-null zurück zu nullable ist, wie gesehen, ein Breaking Change. Man legt sich also fest, und zwar dauerhaft.
Schwerer wiegt, was zur Laufzeit passiert, wenn die Garantie einmal nicht hält. Fällt der Resolver eines non-null-Feldes aus – die Datenbank hakt, ein Upstream-Service liefert nichts – dann muss GraphQL ein null verhindern, wo null verboten ist. Es kann den Wert nicht einfach durchlassen. Also lässt es das null nach oben „bubblen": Der Fehler steigt zum nächsten nullbaren Elternfeld auf und macht dort alles null. Ein einziger fehlgeschlagener non-null-Resolver kann so einen ganzen Antwortpfad mitreißen, bis hoch zu einer Stelle, die null verträgt. Aus einem lokalen Problem wird ein großflächiger Ausfall in der Antwort.
Meine Faustregel aus der Praxis: im Zweifel nullable beginnen. Non-null nur dort, wo die Garantie wirklich trägt – bei einer id, bei einem Feld, das strukturell nie fehlen kann. Für alles, was von einem externen System, einer optionalen Relation oder einer Berechnung abhängt, die scheitern darf, ist nullable die ehrlichere und robustere Wahl. Man kann später jederzeit von nullable auf non-null verschärfen, wenn sich die Garantie als stabil erwiesen hat. Den umgekehrten Weg hat man nicht.
Input-Typen: Mutationen zukunftssicher schneiden
Additive Evolution beginnt schon beim Entwurf, nirgends deutlicher als bei Mutationen. Wer eine Mutation mit einer Handvoll Einzelargumenten baut, verbaut sich die Zukunft:
type Mutation {
createPost(title: String!, body: String!, authorId: ID!): Post!
}
Kommt später ein Feld dazu – eine Kategorie, ein Veröffentlichungsdatum – wächst die Argumentliste, und jede Ergänzung, die nicht optional ist, bricht bestehende Aufrufer. Die Signatur ist starr.
Deshalb kapselt man die Eingabe in einen eigenen input-Typ:
input CreatePostInput {
title: String!
body: String!
authorId: ID!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
Die Mutation nimmt jetzt genau ein Argument, input. Neue Eingabefelder ergänzt man additiv im input-Typ – als optionale Felder, damit bestehende Aufrufer, die sie nicht setzen, gültig bleiben. Die Signatur der Mutation selbst rührt sich nie wieder. Dasselbe Prinzip gilt auf der Ausgabeseite: Ein eigener Payload-Typ statt eines nackten Post! gibt Raum, später etwa ein Fehler- oder Metadatenfeld zu ergänzen, ohne die Rückgabe umzubauen. Ein bisschen mehr Struktur am Anfang, dafür jahrelange additive Bewegungsfreiheit.
Enums vorsichtig erweitern
Zurück zu der Falle, die ich bei den sicheren Änderungen offengelassen habe. Einen Enum-Wert hinzuzufügen ist auf der Serverseite additiv – das Schema wird nur reicher. Der Bruch passiert woanders, nämlich im Client-Code.
Viele Clients behandeln Enum-Werte in einem switch oder einer if-Kette und decken genau die Werte ab, die es beim Bau gab. Kommt ein neuer Wert übers Kabel, fällt so ein Client in kein Muster – im besten Fall zeigt er nichts an, im schlechteren wirft er einen Fehler. Aus einer sauber-additiven Schema-Änderung wird so ein realer Bruch beim Nutzer.
Die Lösung liegt nicht im Schema, sondern in der Client-Disziplin: Jeder Verbraucher eines Enums sollte einen Default- oder Fallback-Zweig für unbekannte Werte haben. Genau darauf weisen die GraphQL Best Practices ausdrücklich hin – Clients sollen sich so verhalten, dass ein künftiger, heute noch unbekannter Enum-Wert nichts zum Absturz bringt. Wer das von Anfang an einplant, darf Enums später bedenkenlos erweitern.
Eine Feld-Migration Schritt für Schritt
Führen wir alles an dem Beispiel zusammen, das oben schon anklang: fullName soll displayName weichen. So läuft die Migration sauber ab, ohne dass zu irgendeinem Zeitpunkt ein Client bricht.
flowchart TB S1["1 – displayName additiv<br/>hinzufügen"] --> S2["2 – fullName mit<br/>@deprecated markieren"] S2 --> S3["3 – Clients migrieren<br/>(Frist läuft)"] S3 --> S4["4 – fullName entfernen,<br/>wenn niemand mehr fragt"]
Schritt eins, hinzufügen: Ich lege displayName als neues Feld an. Rein additiv, niemand ist betroffen. Bestehende Clients fragen weiter fullName, neue können ab sofort displayName nehmen.
Schritt zwei, deprecaten: Ich markiere fullName mit einem @deprecated und einem reason, der auf displayName und die geplante Frist verweist. Ab jetzt sehen alle Client-Teams über ihr Tooling, dass das alte Feld auf dem Weg nach draußen ist – aber es funktioniert weiter.
Entscheidend ist die Übergangszeit: Der Resolver liefert beide Felder korrekt.
const resolvers = {
User: {
// legacy field – kept working until the deprecation window closes
fullName: (user) => user.displayName,
displayName: (user) => user.displayName,
},
};
Schritt drei, migrieren: Die Client-Teams stellen in ihrem eigenen Takt auf displayName um. Wie lange das dauert, hängt vom langsamsten relevanten Client ab – bei einer Mobil-App oft Monate, weil alte Versionen im Feld bleiben. Ein Schema-Registry oder Traffic-Analysen zeigen, ob fullName überhaupt noch abgefragt wird.
Schritt vier, entfernen: Erst wenn die Frist abgelaufen und nachweislich kein Traffic mehr auf fullName liegt, entferne ich Feld und Resolver. Jetzt, und keinen Moment früher, wird aus dem Breaking Change ein Nicht-Ereignis, weil niemand mehr da ist, der bricht.
Ein Sicherheitsnetz in der CI
All diese Regeln im Kopf zu behalten, ist die eine Sache. Sie bei jedem Merge zuverlässig einzuhalten, während mehrere Leute am Schema arbeiten, die andere. Deshalb gehört die Prüfung nicht in den Kopf, sondern in die Pipeline.
Ein Werkzeug wie graphql-inspector vergleicht das Schema eines Pull Requests mit dem aktuell veröffentlichten und klassifiziert jede Differenz – additiv, gefährlich oder brechend. Findet es einen Breaking Change, kann es den Build rot färben, bevor irgendetwas gemerged wird. Ein entferntes Feld, ein non-null gewordenes Argument, ein verschwundener Enum-Wert fällt so schon im Review auf, nicht erst, wenn draußen ein Client kaputtgeht. Wer das Schema zusätzlich in einer Registry führt, kann die Prüfung sogar gegen den real beobachteten Traffic laufen lassen: Ein „Breaking Change" an einem Feld, das seit Monaten niemand mehr abfragt, ist eben doch keiner – und die Migration von oben lässt sich exakt an dieser Datenlage festmachen.
Gepflegt, nicht neu geschnitten
Ein GraphQL-Schema entwickelt sich anders als eine REST-API. Man setzt nicht alle paar Jahre ein /v2 daneben und migriert alles auf einmal, sondern man pflegt einen einzigen Vertrag über seine ganze Lebenszeit. Das Wachstum ist additiv: neue Felder, neue Typen, neue optionale Argumente kommen laufend dazu, ohne dass jemand es merkt. Und wo doch etwas verschwinden muss, ersetzt disziplinierte Deprecation den harten Schnitt – markieren, Frist geben, migrieren lassen, dann entfernen.
Das ist weniger spektakulär als ein großer Versionssprung, und genau das ist der Punkt. Ein gutes Schema fühlt sich für seine Clients an wie etwas, das einfach immer da war und nie weh getan hat. Diese Ruhe ist kein Zufall – sie ist das Ergebnis von ein paar Regeln, konsequent angewandt, und einem CI-Check, der über ihre Einhaltung wacht.
Weiterführende Quellen
- Repository introduction-graphql (Kapitel schema, resolver): https://github.com/mikebild/introduction-graphql
- GraphQL, Schema und Typen: https://graphql.org/learn/schema/
- GraphQL Best Practices (Versionierung, Nullability, Deprecation): https://graphql.org/learn/best-practices/
- graphql-inspector (Schema-Checks in der CI): https://github.com/kamilkisiela/graphql-inspector
Kommentare
Kommentar schreiben