post

GraphQL-Überblick: Vom Schema zur föderierten API

Eine große Landkarte von GraphQL – vom typisierten Schema über Abfragen und Resolver bis zu Echtzeit, Betrieb und föderierter Architektur. Der Stand 2025 mit Apollo Server 5, GraphQL-over-HTTP und Federation, aber mit Blick auf die Ideen, die bleiben.

GraphQL-Überblick: Vom Schema zur föderierten API

Wenn ich GraphQL in einer Schulung vorstelle, fange ich nie mit Code an, sondern mit einer Frage: Wer bestimmt eigentlich, welche Daten über die Leitung gehen – der Server oder der Client? Bei einer klassischen REST-Schnittstelle entscheidet das der Server. Er definiert Endpunkte, und der Client nimmt, was kommt, ob er es braucht oder nicht. GraphQL dreht das um. Der Client formuliert seine Frage, und er bekommt genau die Antwort auf diese Frage – nicht mehr und nicht weniger. Das Backend behält trotzdem die Kontrolle, weil jede mögliche Frage vorher in einem Typsystem festgeschrieben ist.

Dieser Beitrag ist eine große Landkarte. Er geht den Weg vom Schema bis zur verteilten Architektur einmal ganz durch, damit man die Stationen kennt, bevor man sich in einer davon verliert. Zu einzelnen Stationen – wie genau die Ausführung im Inneren abläuft – schreibe ich eigene, tiefere Beiträge. Hier geht es um das Ganze, und um den Stand von 2025: Die GraphQL-Spezifikation hat im September 2025 eine formale Fassung bekommen, die Companion-Spec für GraphQL über HTTP ist ausformuliert, Apollo Server 5 ist die aktuelle Referenz auf Node.js, und Federation ist der übliche Weg, wenn ein Schema zu groß für ein Team wird.

GraphQL ist eine Spezifikation, kein Framework

Der häufigste Irrtum am Anfang: GraphQL sei eine Bibliothek, die man installiert. Ist es nicht. GraphQL ist eine Spezifikation – sie beschreibt eine Abfragesprache und wie ein Server eine solche Abfrage auszuwerten hat. Es gibt Referenzimplementierungen wie graphql-js, und darauf setzen Server wie Apollo Server oder GraphQL Yoga auf, aber das Herzstück ist ein Vertrag, keine Runtime.

Aus dieser einen Tatsache folgt fast alles Weitere. Weil es ein Vertrag ist, gibt es genau einen Endpunkt statt vieler. Weil der Vertrag typisiert ist, weiß der Client vor dem ersten Aufruf, welche Felder es gibt und welchen Typ sie haben. Und weil der Server die Abfrage auswertet statt vorgefertigte Antworten auszuliefern, kann derselbe Endpunkt tausend verschiedene Fragen beantworten, ohne dass jemand tausend Endpunkte pflegt.

flowchart LR
  Client["Client<br/>formuliert die Frage"] -->|eine Query| Endpoint["ein GraphQL-Endpunkt"]
  Endpoint --> Engine["Ausführung<br/>gegen das Schema"]
  Engine --> DB[("Datenbank")]
  Engine --> REST["REST-API"]
  Engine --> Service["anderer Dienst"]
  Engine -->|genau die angefragten Felder| Client

Das Bild rechts ist wichtig: Hinter dem einen Endpunkt kann alles Mögliche liegen. GraphQL ist keine Datenbank und ersetzt keine – es ist eine Schicht, die verschiedene Quellen unter einem gemeinsamen, typisierten Modell zusammenführt.

Das Schema zuerst

Alles beginnt beim Schema. Es wird in der Schema Definition Language geschrieben, kurz SDL, und beschreibt die Typen und ihre Beziehungen. Ein kleines Beispiel aus der Welt eines Blogs:

type Author {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: Author!
}

type Query {
  author(id: ID!): Author
  posts: [Post!]!
}

Drei Dinge lohnen den genauen Blick. Erstens die Modifizierer: Das Ausrufezeichen bedeutet „darf nicht null sein", die eckigen Klammern bedeuten „Liste". [Post!]! ist also eine nicht-nullbare Liste von nicht-nullbaren Posts – eine sehr präzise Aussage, die ein Client sich merken kann. Zweitens die Beziehungen: Author verweist auf Post und umgekehrt. Das Schema beschreibt keinen flachen Katalog, sondern einen Graphen aus Knoten und Kanten – daher der Name.

Drittens, und das ist der Kern: Query ist ein besonderer Typ. Neben ihm gibt es Mutation fürs Schreiben und Subscription für Echtzeit. Diese drei sind die einzigen Einstiegspunkte in den Graphen. Was hier nicht als Feld steht, kann kein Client fragen. Damit ist das Schema ein echter Vertrag: Es sagt vollständig und verbindlich, welche Fragen erlaubt sind und welche Form ihre Antworten haben.

Zwei Werkzeuge machen den Vertrag im Alltag angenehm. Felder lassen sich mit @deprecated(reason: "…") als veraltet markieren, ohne sie sofort zu entfernen – Clients bekommen den Hinweis, brechen aber nicht. Und für Mutationen gibt es eigene Input-Typen, damit Eingaben nicht mit Ausgabetypen verwechselt werden. Beides sind kleine Zeichen dafür, dass GraphQL für langlebige APIs gedacht ist, nicht für den Wegwerf-Endpunkt.

Die Abfrageseite: der Client fragt genau

Eine Abfrage sieht der Antwort ähnlich, die sie erzeugt. Man beschreibt die gewünschte Form, und man bekommt genau diese Form zurück:

query AutorMitPosts($id: ID!) {
  author(id: $id) {
    name
    posts {
      title
    }
  }
}

Der Client fragt hier nach dem Namen eines Autors und den Titeln seiner Posts – und bekommt nur das. Kein überflüssiges Feld, keine zweite Runde für die Posts. Das $id ist eine Variable; Abfragen werden nicht durch String-Bastelei parametrisiert, sondern über typisierte Variablen, die der Server prüft.

Zwei weitere Bausteine tauchen schnell auf. Fragmente bündeln wiederkehrende Feldmengen, damit man sie nicht in jeder Query wiederholt. Und Introspection erlaubt es, das Schema selbst abzufragen – der Server beschreibt sich, weshalb Werkzeuge wie GraphiQL Autovervollständigung und Dokumentation ohne zusätzliche Konfiguration anbieten können. Der Vertrag ist also nicht nur verbindlich, er ist auch auslesbar.

Die Ausführung: Resolver für Resolver

Wie wird aus der Abfrage eine Antwort? Der Server läuft die Abfrage von oben nach unten durch und ruft für jedes Feld eine Funktion auf, den Resolver. Jeder Resolver bekommt vier Argumente in fester Reihenfolge: das Elternobjekt (source), die Argumente des Feldes (args), einen anfragebezogenen Kontext (context) und Informationen über den Ausführungszustand (info).

const resolvers = {
  Query: {
    author: (source, { id }, context) => context.db.ladeAutor(id),
  },
  Author: {
    posts: (author, args, context) => context.db.ladePostsVon(author.id),
  },
};

Man sieht das Prinzip: Der Resolver für author liefert einen Autor, und dessen Rückgabewert wird zum source für die Feld-Resolver darunter – der Resolver für posts bekommt den Autor als erstes Argument und lädt darüber seine Posts. So wandert die Ausführung als Kette durch den Graphen. Ein Resolver kann synchron, per Promise oder async arbeiten und dabei jede Datenquelle ansprechen – eine Datenbank, eine REST-API, einen anderen Dienst.

Genau hier lauert die bekannteste Falle, das N+1-Problem. Fragt jemand zehn Autoren mit ihren Posts ab, läuft der posts-Resolver zehnmal und macht zehn einzelne Datenbankabfragen – plus die eine für die Autoren. Die Lösung heißt Batching: Man sammelt die zehn Anfragen innerhalb eines Ausführungstakts ein und beantwortet sie mit einer einzigen Abfrage. Die dafür übliche Bibliothek ist DataLoader.

flowchart TB
  subgraph Ohne["Ohne DataLoader (N+1)"]
    Q1["10 Autoren"] --> R1["10× posts-Resolver"]
    R1 --> DB1[("10 einzelne Abfragen")]
  end
  subgraph Mit["Mit DataLoader"]
    Q2["10 Autoren"] --> R2["10× posts-Resolver"]
    R2 --> L["DataLoader sammelt IDs"]
    L --> DB2[("1 gebündelte Abfrage")]
  end

Wie die Ausführung im Detail durch den Baum läuft, in welcher Reihenfolge Geschwisterfelder ausgewertet werden und warum Mutationen anders behandelt werden als Abfragen – das ist ein Thema für sich, dem ich einen eigenen, tieferen Beitrag widme. Für die Landkarte reicht: Eine Abfrage ist ein Pfad durch den Graphen, und jeder Knoten auf dem Pfad wird von einem Resolver bedient.

Schreiben und Echtzeit

Lesen ist nur die halbe Geschichte. Mutationen sind der schreibende Teil und sehen aus wie Abfragen, tragen aber eine Absicht:

mutation PostAnlegen($input: PostInput!) {
  createPost(input: $input) {
    id
    title
  }
}

Der Unterschied ist mehr als kosmetisch: Auf oberster Ebene führt der Server Mutationen nacheinander aus, nicht parallel, damit sich zwei Schreibvorgänge nicht ins Gehege kommen. Und weil eine Mutation ein Ergebnis zurückgibt – hier den angelegten Post –, kann der Client seinen Zustand direkt aus der Antwort aktualisieren, ohne neu zu laden.

Für laufende Aktualisierungen gibt es Subscriptions. Der Client abonniert ein Ereignis, und der Server schickt neue Daten, sobald sie entstehen. Klassisch lief das über WebSockets; inzwischen ist auch der Transport über Server-Sent Events verbreitet, gerade wenn eine einfache, einseitige Verbindung genügt – dazu habe ich an anderer Stelle mehr geschrieben. Die GraphQL-over-HTTP-Spezifikation hat 2025 zusätzlich die Transportfragen für Queries und Mutationen formalisiert, sodass Server und Clients verschiedener Hersteller verlässlicher zusammenspielen.

Betrieb: Fehler, Paginierung, Auth, Caching

Zwischen dem ersten funktionierenden Schema und einer API, die in Produktion trägt, liegen ein paar Themen, die man nicht überspringen sollte.

Fehler behandelt GraphQL eigen: Eine Antwort kann gleichzeitig Daten und ein errors-Feld enthalten. Ein fehlgeschlagenes Teilfeld macht nicht die ganze Antwort kaputt – der Rest kommt trotzdem. Das ist mächtig, verlangt aber Disziplin, damit Clients erwartbare Fehler von echten Pannen unterscheiden können.

Paginierung löst man selten über simple offset/limit-Argumente, sondern meist über cursorbasierte Verbindungen – das Connections-Muster mit Kanten und Seiteninformationen, das aus der Relay-Welt stammt und sich als Konvention durchgesetzt hat. Es ist etwas umständlicher zu lesen, aber stabil, wenn sich die Liste unter den Fingern ändert.

Authentifizierung und Autorisierung gehören nicht ins Schema, sondern in den Kontext. Wer die Anfrage stellt, wird beim Aufbau des context ermittelt, und die Resolver entscheiden anhand dieses Kontexts, was sie preisgeben. So bleibt die Zugriffslogik dort, wo die Daten geladen werden.

Caching schließlich ist anders als bei REST, wo die URL der natürliche Cache-Schlüssel ist. Bei GraphQL geht bei einem Endpunkt alles über dieselbe Adresse. Deshalb cachen Clients wie Apollo Client auf Ebene der einzelnen Objekte – jedes Objekt mit stabiler ID landet normalisiert im Cache, sodass dasselbe Objekt aus verschiedenen Abfragen nur einmal existiert.

Architektur: eine Schicht, die aggregiert

Damit sind wir bei der eigentlichen Stärke von GraphQL im Großen. Weil hinter dem Endpunkt beliebige Quellen liegen können, eignet sich GraphQL hervorragend als Aggregationsschicht – ein Backend for Frontend, das mehrere Dienste, Datenbanken und fremde APIs unter einem Modell zusammenzieht, zugeschnitten auf das, was die Oberfläche wirklich braucht.

flowchart TB
  App["Web- & Mobile-Client"] --> GW["GraphQL-Gateway<br/>(föderiertes Schema)"]
  GW --> S1["Subgraph: Nutzer"]
  GW --> S2["Subgraph: Bestellungen"]
  GW --> S3["Subgraph: Katalog"]
  S1 --> DB1[("Nutzer-DB")]
  S2 --> DB2[("Bestell-DB")]
  S3 --> REST["Katalog-REST"]

Wird das Schema zu groß für ein einzelnes Team, kommt Federation ins Spiel. Statt eines Monolithen betreibt jedes Team einen eigenen Teilgraphen (Subgraph), und ein Gateway setzt daraus für den Client ein einziges, zusammenhängendes Schema zusammen. Der Client merkt davon nichts – er sieht weiterhin einen Graphen. Federation ist heute der übliche Weg, wenn GraphQL organisatorisch skalieren muss; die ältere Schema-Stitching-Variante trifft man noch in Bestandssystemen.

Bei der Wahl des Servers hat sich das Feld sortiert. Apollo Server 5 ist die sichere Standardwahl für einen gewöhnlichen Node.js-Stack – größtes Ökosystem, gute Federation-Unterstützung, seit Anfang 2026 die aktuelle Generation, nachdem Version 4 das Ende ihrer Lebenszeit erreicht hat. GraphQL Yoga ist die schlankere Alternative, besonders wenn man auf Edge-Runtimes deployt, wo der Fußabdruck von Apollo Server zu groß wäre. Beide setzen auf graphql-js in Version 16 als gemeinsamem Fundament.

Das Übungsrepo als Landkarte

Wer das alles anfassen will, findet in introduction-graphql die Stationen als einzelne Kapitel wieder – von intro, architecture und principles über schema, types und resolver bis zu mutations, subscriptions, paging, auth und caching. Dazu kommen ausführbare Beispiele: ein Apollo-Monorepo, ein Server in C# als Beleg dafür, dass GraphQL sprachunabhängig ist, und ein Ordner mit Rezepten für wiederkehrende Aufgaben – DataLoader, Paginierung, Auth, Optimistic UI, serverseitiges Rendern. Die Kapitel sind bewusst so geschnitten, dass jedes für sich lesbar ist, und genau so ist auch diese Landkarte gedacht.

Was bleiben soll

Wenn von diesem Überblick ein Satz hängen bleiben soll, dann dieser: GraphQL ist zuerst eine Frage der API-Gestaltung und erst danach eine Frage des Werkzeugs. Das Schema ist der Vertrag, die Resolver sind die Umsetzung, und alles darüber – Federation, Caching, der konkrete Server – ist Ausführung dieser einen Idee, dass der Client seine Frage stellt und das Backend ein explizites Modell behält.

Die Tools werden sich weiter ändern; Apollo Server 4 ist schon Geschichte, und die nächste Server-Generation kommt bestimmt. Was bleibt, ist das typisierte Schema und die Ausführung entlang des Graphen. Zu einzelnen Stationen – wie die Ausführung im Inneren genau abläuft, wie man ein Schema entwirft, das mitwächst – folgen eigene Beiträge.

Weiterführende Quellen

Kommentare

Kommentar schreiben