GraphQL-Konzepte: Schema, Resolver, Ausführung
Die Ideen hinter GraphQL, die bleiben, egal welcher Server gerade modern ist – das Schema als typisierter Vertrag, die Query als Form, das Resolver-Modell und die Reihenfolge der Ausführung. Ein konzeptueller Deep-Dive mit Diagrammen.
GraphQL-Konzepte: Schema, Resolver, Ausführung
Im Überblick habe ich GraphQL als eine große Landkarte gezeichnet – vom Schema bis zur föderierten Architektur. Dieser Beitrag geht in die Tiefe, aber nicht bei den Werkzeugen, sondern bei den Ideen. Server kommen und gehen; Apollo Server 4 ist schon Vergangenheit, die nächste Generation steht bereit. Was bleibt, sind ein paar Konzepte, die sich seit Jahren nicht ändern, weil sie in der Spezifikation stehen und nicht in einer Bibliothek. Wer diese Konzepte einmal sauber verstanden hat, liest jeden GraphQL-Server, egal von welchem Hersteller, mit demselben mentalen Modell.
Drei Dinge trage ich hier zusammen: Was ein Schema wirklich ist, warum eine Abfrage aussieht wie ihre Antwort, und wie die Ausführung im Inneren Feld für Feld abläuft. Das klingt nach Grundlagen, aber gerade an diesen Grundlagen entscheidet sich, ob man GraphQL beherrscht oder nur benutzt.
Das Schema ist ein typisierter Graph
Ein Schema wirkt beim ersten Lesen wie eine Sammlung von Datentypen. Das ist es auch, aber die entscheidende Eigenschaft steckt in den Beziehungen zwischen den Typen. Nehmen wir wieder ein Blog:
type Author {
id: ID!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: Author!
}
Author verweist auf Post, Post verweist zurück auf Author. Damit beschreibt das Schema keinen flachen Katalog, sondern einen Graphen: Die Typen sind Knoten, die Felder, die auf andere Typen zeigen, sind Kanten. Das ist keine Metapher, sondern der wörtliche Grund für den Namen. Eine Abfrage ist dann nichts anderes als ein Pfad durch diesen Graphen, den der Client selbst wählt.
Die Modifizierer sind Teil des Vertrags, nicht Dekoration. String! heißt: Dieses Feld ist garantiert vorhanden. [Post!]! heißt: garantiert eine Liste, garantiert ohne Lücken darin. Ein Client kann sich darauf verlassen und muss nicht defensiv gegen null prüfen, wo das Schema null ausschließt. Umgekehrt zwingt ein nicht-nullbares Feld, das doch null liefert, einen Fehler herbei – die Garantie wird also durchgesetzt, nicht bloß versprochen.
Und dann gibt es die drei Wurzeltypen: Query, Mutation und Subscription. Sie sind die einzigen Türen in den Graphen. Alles, was ein Client tun kann, beginnt an einem Feld eines dieser drei Typen. Was dort nicht steht, existiert für den Client nicht. Das macht das Schema zu einem vollständigen, verbindlichen Vertrag – und zu einer der wenigen API-Beschreibungen, die zugleich Dokumentation, Validierungsregel und Typdefinition sind.
Eine Abfrage ist eine Form
Der zweite Gedanke ist einfach und trotzdem der, den Umsteiger am längsten überrascht: Eine GraphQL-Abfrage hat dieselbe Gestalt wie ihre Antwort.
query {
author(id: "1") {
name
posts {
title
}
}
}
Die Antwort darauf ist strukturgleich – ein author mit name und einer Liste posts, in der jeder Eintrag ein title hat. Man beschreibt nicht, wie Daten geholt werden, sondern welche Form man am Ende haben will. Das ist dieselbe deklarative Haltung, die auch anderswo in der modernen Frontend-Welt auftaucht: Man nennt das Ziel, nicht den Weg.
Daraus folgt das berühmte Versprechen, kein Over- und kein Under-Fetching zu haben. Kein überflüssiges Feld, weil der Client nur nennt, was er braucht. Keine zweite Runde für die Posts, weil er sie im selben Pfad mitfragt. Der Server bekommt damit eine präzise Beschreibung des Bedarfs – und genau diese Präzision ist die Grundlage dafür, dass er effizient antworten kann.
Das Resolver-Modell
Jetzt zur Ausführung. Für jedes Feld in der Abfrage ruft der Server eine Funktion auf, den Resolver. Ein Resolver bekommt immer dieselben vier Argumente, in dieser Reihenfolge:
function resolver(source, args, context, info) {
// source – das Elternobjekt (der Rückgabewert des Feldes darüber)
// args – die Argumente dieses Feldes in der Abfrage
// context – ein anfragebezogenes Objekt (z. B. der eingeloggte Nutzer, Datenzugriffe)
// info – Informationen über den Ausführungszustand (welches Feld, welcher Pfad)
}
Das erste Argument ist der Schlüssel zum Verständnis der Kette. Der Rückgabewert eines Resolvers wird zum source für die Resolver der Felder darunter. Löst der Resolver für author einen Autor auf, bekommt der Resolver für dessen posts genau diesen Autor als source und kann darüber die Posts laden.
const resolvers = {
Query: {
author: (source, { id }, context) => context.db.ladeAutor(id),
},
Author: {
// source ist hier der Autor aus dem Resolver darüber
posts: (author, args, context) => context.db.ladePostsVon(author.id),
},
Post: {
// source ist hier ein einzelner Post
author: (post, args, context) => context.db.ladeAutor(post.authorId),
},
};
Für die meisten Felder schreibt man gar keinen Resolver. Wenn ein Feld name heißt und das Elternobjekt eine Eigenschaft name hat, greift der Standard-Resolver: Er liest die gleichnamige Eigenschaft aus dem source. Man schreibt Resolver also nur dort explizit, wo etwas zu tun ist – wo Daten geladen, berechnet oder umgeformt werden. Das hält die meisten Schemas erstaunlich schlank.
Der context verdient einen eigenen Satz, weil an ihm viel hängt. Er wird einmal pro Anfrage gebaut und an jeden Resolver durchgereicht. Dort landet, wer die Anfrage stellt, und dort liegen die Zugänge zu den Datenquellen. Weil jeder Resolver denselben Kontext sieht, ist er der natürliche Ort für Authentifizierung und für geteilte Ressourcen – ohne dass man sie durch jede Funktionssignatur fädeln müsste.
Die Reihenfolge der Ausführung
Wie genau wandert die Ausführung durch den Baum? Man kann es sich als Durchlauf von oben nach unten vorstellen, Ebene für Ebene. Zuerst wird das Wurzelfeld aufgelöst, dann dessen Kinder, dann deren Kinder – der Rückgabewert jeder Ebene speist die nächste.
flowchart TB Q["Query"] --> A["author-Resolver<br/>liefert Autor"] A --> N["name<br/>(Standard-Resolver)"] A --> P["posts-Resolver<br/>liefert Liste"] P --> T1["title von Post 1"] P --> T2["title von Post 2"]
Zwei Feinheiten sind wichtig, weil sie im Alltag Konsequenzen haben. Erstens: Geschwisterfelder auf derselben Ebene löst der Server nebenläufig auf, nicht streng nacheinander. Gibt ein Resolver ein Promise zurück, wartet der Server es ab, aber er blockiert nicht die Geschwister. Deshalb dürfen Resolver keine Annahmen über ihre Ausführungsreihenfolge relativ zu Nachbarfeldern treffen – sie sollen unabhängig voneinander funktionieren.
Zweitens, und das ist die eine bewusste Ausnahme: Auf der obersten Ebene einer Mutation läuft die Ausführung seriell. Nennt eine Mutation mehrere Schreibfelder, führt der Server sie strikt nacheinander aus, in der Reihenfolge der Abfrage. Das ist Absicht – zwei Schreibvorgänge, die parallel liefen, könnten sich gegenseitig überholen. Bei Abfragen gibt es diese Sorge nicht, weil sie nichts verändern, also lässt der Server sie nebenläufig laufen.
N+1 und warum Batching dazugehört
Aus dem Resolver-Modell folgt fast zwangsläufig ein Problem, das jeder GraphQL-Entwickler früher oder später trifft. Fragt jemand eine Liste von Autoren ab und zu jedem die Posts, läuft der posts-Resolver einmal pro Autor. Bei zehn Autoren sind das zehn Aufrufe – und wenn jeder eine eigene Datenbankabfrage macht, hat man zehn Abfragen plus die eine für die Autorenliste. Das ist das N+1-Problem: eine Abfrage für die Liste, N weitere für die Kinder.
Der Ausweg ist Batching. Statt sofort loszulaufen, sammelt man alle Anfragen, die innerhalb eines Ausführungstakts entstehen, und beantwortet sie mit einer einzigen Abfrage. Die übliche Umsetzung ist die DataLoader-Bibliothek: Man fragt nicht mehr direkt die Datenbank, sondern einen Loader nach einer ID; der Loader wartet bis zum Ende des aktuellen Ticks, bündelt alle gesammelten IDs und löst sie in einem Rutsch auf. Nebenbei merkt er sich innerhalb der Anfrage, was er schon geladen hat, sodass dieselbe ID nicht zweimal geholt wird.
flowchart LR
R1["posts von Autor 1"] --> L["DataLoader<br/>sammelt IDs im Tick"]
R2["posts von Autor 2"] --> L
R3["posts von Autor 3"] --> L
L -->|eine gebündelte Abfrage| DB[("Datenbank")]
Wichtig ist zu sehen, dass das kein Trick gegen einen Designfehler ist, sondern die konsequente Antwort auf die Natur des Resolver-Modells. Die feldweise Auflösung ist der Grund für die Flexibilität von GraphQL – und Batching ist der Preis und zugleich die saubere Lösung dafür. Ein Loader gehört in den context, damit er pro Anfrage frisch ist und sein Cache nicht über Anfragegrenzen hinweg leckt.
Warum diese Konzepte bleiben
Man kann GraphQL mit Apollo Server bauen, mit GraphQL Yoga, mit einer Bibliothek in C#, Go oder Python. Der Server wechselt, das Ökosystem verschiebt sich, die Spezifikation bekommt neue Fassungen – 2025 wurde die Kern-Spec formal veröffentlicht und der Transport über HTTP ausformuliert. Aber das Schema bleibt ein typisierter Graph, die Abfrage bleibt eine Form, die Resolver bleiben eine Kette mit denselben vier Argumenten, und die Ausführung bleibt ein Durchlauf von oben nach unten mit seriellen Mutationen und nebenläufigen Geschwistern.
Genau deshalb lohnt es sich, diese Ebene zu verstehen und nicht nur die API eines bestimmten Servers. Wer weiß, was ein Resolver bekommt und in welcher Reihenfolge der Baum abläuft, findet die Ursache eines N+1-Problems in Minuten, entwirft Schemas, die mitwachsen, und wechselt den Server, ohne umlernen zu müssen. Die Werkzeuge sind austauschbar. Die Konzepte sind es nicht.
Weiterführende Quellen
- Übungsrepository introduction-graphql (Kapitel resolver, schema, subscriptions): https://github.com/mikebild/introduction-graphql
- Folienübersicht zu diesem Themenkomplex: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-graphql.html
- GraphQL, Ausführung: https://graphql.org/learn/execution/
- GraphQL, Schema und Typen: https://graphql.org/learn/schema/
- GraphQL-Spezifikation, Execution: https://spec.graphql.org/
- DataLoader (Batching und Caching pro Anfrage): https://github.com/graphql/dataloader
Kommentare
Kommentar schreiben