Der GraphQL-Client: Apollo Link und Fragments
Apollo Link lässt jede GraphQL-Operation durch eine Kette von Middleware laufen: authLink setzt Header, errorLink behandelt Fehler zentral, split routet Subscriptions. Dazu Fragments als wiederverwendbare Feldmengen und warum Interfaces possibleTypes brauchen.
Im Beitrag über das Apollo-Client-Caching ging es darum, was mit einer Antwort passiert, sobald sie beim Client angekommen ist: wie der normalisierte Cache die Daten zerlegt, ablegt und wiederfindet. Diesmal drehe ich die Blickrichtung um. Bevor eine Antwort im Cache landen kann, muss die Anfrage erst hinaus zum Server, und auf diesem Weg fallen Entscheidungen an, die mit der einzelnen Query nichts zu tun haben: einen Token mitschicken, einen 401 zentral behandeln, Subscriptions auf einen anderen Transport leiten. Das sind Cross-Cutting-Concerns, und die eigentliche Frage lautet nicht, welche API man dafür aufruft, sondern wo diese Belange hingehören. Verteilt über jeden einzelnen useQuery-Aufruf oder gebündelt an einer Stelle.
Genau das ist die Entscheidung, um die es hier geht, und ich halte die gebündelte Variante für die tragfähigere. Apollo Client beantwortet sie mit einer Kette aus Middleware. Der Trade-off ist überschaubar: Man akzeptiert eine Indirektion, die man einmal verstehen muss, und bekommt eine Architektur, in der Auth, Fehlerbehandlung und Routing jeweils an einer benannten Stelle stehen statt verstreut in Dutzenden. Verändert sich die Auth-Strategie, ändert man einen Link, nicht vierzig Komponenten. Genau daran misst sich der Wert einer Transport-Schicht, nicht an der Zahl ihrer Optionen.
Solche Anforderungen kommen gestaffelt. Zuerst steht ein ApolloClient, alles funktioniert. Dann heißt es: „Wir müssen einen Auth-Token mitschicken.“ Kurz darauf: „Und wenn der Server einen 401 zurückgibt, sollen alle betroffenen Operationen zentral behandelt werden.“ Und schließlich: „Ach ja, Subscriptions brauchen wir auch noch.“ Spätestens hier zeigt sich, dass der eine Netzwerk-Link aus dem Setup nur die Spitze war. Darunter steht ein komponierbares System, das genau für solche Fälle gebaut wurde: Apollo Link.
Der zweite Teil dieses Beitrags widmet sich den Fragments. Sie wirken auf den ersten Blick wie eine reine Schreiberleichterung, sind aber der Baustein, mit dem sich Queries sauber zusammensetzen und Komponenten wartbar halten lassen. Ich verwende Apollo Client 3.10, die Import-Pfade und Signaturen beziehen sich auf diese Version. In Apollo Client 4 kommen React-Hooks wie useFragment aus @apollo/client/react; das gezeigte Cache-Modell bleibt gleich.
Apollo Link ist eine Kette
Die Grundidee ist schnell erzählt. Jede Operation, ob Query, Mutation oder Subscription, durchläuft eine Kette von Links, bevor sie den Server erreicht, und die Antwort läuft dieselbe Kette rückwärts wieder zurück. Ein Link ist ein Stück Middleware: Er darf die Operation ansehen, sie verändern und an den nächsten Link weiterreichen. Der letzte Link in der Kette ist besonders. Er ist terminierend, das heißt, er reicht nicht weiter, sondern schickt die Anfrage tatsächlich ab. In den allermeisten Fällen ist das der HttpLink.
Wer schon einmal Express-Middleware oder eine Redux-Middleware-Kette gesehen hat, erkennt das Muster sofort. Jeder Link bekommt die Operation und eine forward-Funktion, mit der er an den nächsten Link übergibt. Und wie bei jeder Middleware-Kette gilt: Die Reihenfolge entscheidet über das Verhalten.
Zusammengesetzt wird die Kette mit ApolloLink.from, das ein Array von Links in genau der Reihenfolge verkettet, in der sie dort stehen. Für zwei Links reicht auch linkA.concat(linkB). Schauen wir uns eine typische Kette an.
import { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
const httpLink = new HttpLink({ uri: '/graphql' });
const authLink = setContext((_, { headers }) => {
const token = getToken();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
console.error(`[GraphQL error] ${operation.operationName}: ${err.message}`);
}
}
if (networkError) {
console.error(`[Network error] ${networkError.message}`);
}
});
const link = ApolloLink.from([errorLink, authLink, httpLink]);
const client = new ApolloClient({
link,
cache: new InMemoryCache(),
});
Drei Links, eine klare Aufgabenteilung. Der errorLink steht außen, weil er sowohl die hinausgehende Operation als auch die zurückkommende Antwort sehen soll. Fehler, die tief in der Kette entstehen, laufen auf dem Rückweg durch ihn hindurch. Der authLink steht kurz vor dem Netzwerk-Link, weil er den Token möglichst spät setzt, wenn er am aktuellsten ist. Und der httpLink steht ganz am Ende, weil er terminierend ist.
Warum die Reihenfolge zählt
Genau hier liegt der Fallstrick, der mir in Code-Reviews am häufigsten begegnet. Der terminierende Link muss das letzte Element sein. Steht der authLink versehentlich hinter dem httpLink, wird er nie erreicht, denn die Anfrage ist längst abgeschickt, wenn dieser Link an der Reihe wäre. Das Ergebnis: Der Token fehlt, der Server antwortet mit 401, und die Fehlersuche beginnt an der völlig falschen Stelle, nämlich beim Auth-Server statt bei der Link-Reihenfolge.
Der authLink verdient noch einen zweiten Blick, weil sein Name in die Irre führen kann. setContext verändert die HTTP-Header nicht direkt. Es liefert ein Objekt, das in den Context der Operation gemischt wird, üblicherweise unter dem Schlüssel headers. Erst der HttpLink liest diesen Context aus und übersetzt ihn in echte HTTP-Header. Der authLink bereitet also nur vor, gesendet wird woanders. Wer das verinnerlicht hat, versteht auch, warum die Reihenfolge so wichtig ist: Der Vorbereiter muss vor dem Absender stehen.
Ein Detail, das in der Praxis oft gebraucht wird: Die Funktion in setContext darf asynchron sein. Gibt sie ein Promise zurück, wartet Apollo Link, bevor die Operation weiterläuft. Damit lässt sich ein Token bei Bedarf erst nachladen oder erneuern, ohne die aufrufende Komponente damit zu belasten. Im Blick behalten sollte man dabei nur, dass jede Operation auf diese Auflösung wartet: Ein langsamer Token-Endpunkt bremst dann sämtliche Anfragen aus. In der Regel legt man den Token deshalb in einem Speicher ab, den setContext synchron lesen kann, und stößt die Erneuerung getrennt davon an.
Der errorLink unterscheidet zwei Fehlerklassen, die man nicht verwechseln sollte. graphQLErrors ist ein Array von Fehlern, die der Server bewusst zurückgegeben hat, etwa eine fehlgeschlagene Validierung oder ein nicht gefundener Datensatz. Der Transport hat funktioniert, die Antwort enthält nur eben Fehler im errors-Feld. networkError dagegen bedeutet, dass der Transport selbst gescheitert ist: keine Verbindung, ein 500er, ein Timeout. Diese Trennung ist der Grund, warum onError der richtige Ort für zentrale Behandlung ist. Man muss nicht in jedem useQuery-Aufruf einzeln prüfen, ob der Token abgelaufen ist, man macht es einmal, an einer Stelle.
Wenn ich mir dieselbe Kette als Diagramm aufmale, sieht der Weg einer Operation so aus.
flowchart LR Op[Operation] --> E[errorLink onError] E --> A[authLink setContext] A --> H[httpLink terminating] H --> S[(GraphQL Server)] S -. response .-> H H -. response .-> A A -. response .-> E E -. response .-> Op
Die durchgezogenen Pfeile zeigen die Anfrage nach außen, die gestrichelten die Antwort auf dem Rückweg. Dass der errorLink auf beiden Wegen beteiligt ist, macht deutlich, warum er ganz außen sitzt.
Die gängigen Links im Überblick
Über die drei genannten hinaus gibt es einige Links, die regelmäßig gebraucht werden. Hier die wichtigsten mit ihrer jeweiligen Aufgabe:
errorLink(onError): zentrale Behandlung von GraphQL- und Netzwerkfehlern, etwa Logging oder das Ausloggen bei abgelaufenem Token.authLink(setContext): bereitet Header und Token vor und mischt sie in den Operation-Context.retryLink(RetryLink): wiederholt Operationen bei Netzwerkfehlern, mit konfigurierbarem Backoff und Jitter.splitLink(split): routet Operationen nach einem Prädikat auf verschiedene Zweige, klassisch Subscriptions gegen Queries.httpLink(HttpLink): der terminierende Link, der die Anfrage über HTTP versendet.
Der RetryLink gehört in die Kette vor den terminierenden Link, damit er die Wiederholung anstoßen kann, bevor die Anfrage endgültig hinausgeht. Eine sinnvolle vollständige Kette wäre also from([errorLink, retryLink, authLink, httpLink]). Wichtig dabei: Die Retry-Bedingung sollte man explizit setzen. Gibt man in onError bedingungslos ein forward(operation) zurück, um eine Operation zu wiederholen, kann daraus eine Endlosschleife werden, wenn der Fehler dauerhaft ist. Der RetryLink mit seiner retryIf-Bedingung ist der robustere Weg, weil er die Anzahl der Versuche begrenzt.
import { RetryLink } from '@apollo/client/link/retry';
const retryLink = new RetryLink({
delay: { initial: 300, max: 3000, jitter: true },
attempts: {
max: 3,
retryIf: (error) => Boolean(error),
},
});
Subscriptions abzweigen mit split
Sobald Subscriptions ins Spiel kommen, kann ein zweiter Transport nötig werden. Queries und Mutations laufen in diesem Beispiel über HTTP, die Subscriptions über eine dauerhafte WebSocket-Verbindung. Andere Stacks transportieren Subscriptions über HTTP und Server-Sent Events. Die Aufgabe, die Operation im hier gezeigten WebSocket-Setup auf den richtigen Zweig zu leiten, übernimmt split. Die Funktion bekommt ein Prädikat und zwei Links: Ist das Prädikat wahr, geht es in den ersten Zweig, sonst in den zweiten.
Um zu entscheiden, ob eine Operation eine Subscription ist, hilft getMainDefinition. Sie zieht die Haupt-Definition aus dem Query-Dokument, sodass sich kind und operation prüfen lassen.
import { split } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
const wsLink = new GraphQLWsLink(
createClient({ url: 'wss://example.com/graphql' }),
);
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
ApolloLink.from([errorLink, authLink, httpLink]),
);
Der WebSocket-Transport heißt hier GraphQLWsLink und baut auf der graphql-ws-Bibliothek auf. Für einen WebSocket-Zweig ist das die passende Variante. Die ältere Kombination aus WebSocketLink und subscriptions-transport-ws findet man noch in vielen Projekten, sie gilt aber als veraltet. Ob WebSocket überhaupt der richtige Transport ist, hängt vom Server und vom eingesetzten Subscription-Protokoll ab; SSE ist eine mögliche Alternative.
Ein Punkt, der leicht übersehen wird: Der WebSocket-Zweig läuft an errorLink und authLink vorbei, denn er hängt im split an einem eigenen Ast. Eine Subscription authentifiziert sich deshalb nicht über den HTTP-Header, sondern über die connectionParams des graphql-ws-Clients, die beim Verbindungsaufbau gesetzt werden. Wer den Token nur im authLink mitgibt, wundert sich sonst, warum die Query autorisiert ist, die Subscription aber nicht.
Vom Apollo-Detail zum Prinzip
Ein Schritt zurück, und es geht nicht mehr um Apollo. Die Link-Kette ist ein Sonderfall eines Musters, das weit über GraphQL hinausreicht: Cross-Cutting-Concerns wandern aus dem fachlichen Code heraus in eine Kette aus Middleware, die jede Operation durchläuft. Auth, Logging, Fehlerbehandlung, Wiederholung, Routing. Das sind Belange, die überall gebraucht werden und nirgends fachlich hingehören. Express macht es mit seiner Middleware, Redux mit seiner Middleware-Kette, ein API-Gateway mit seiner Filter-Kette, und Apollo Client eben mit seinen Links. Wer das Muster einmal erkannt hat, sieht es überall.
Der Reiz liegt in einer klaren Grenzziehung. Fachlicher Code fragt Daten an und stellt sie dar. Er soll nichts davon wissen, wie ein Token entsteht oder was bei einem 401 passiert. Die Kette nimmt ihm das ab, und zwar an einer einzigen, benannten Stelle. Der Gewinn ist nicht die Technik, sondern die Lokalität der Änderung. Ein neuer Concern kommt als zusätzlicher Link hinzu, ohne dass ein einziger useQuery-Aufruf angefasst werden muss. Das ist der Unterschied zwischen einer Anpassung, die man an einem Nachmittag erledigt, und einer, die sich durch vierzig Dateien zieht und mit jeder davon ein Risiko trägt.
Umsonst ist das nicht. Der Preis ist Indirektion. Der Weg einer Operation steht nicht mehr linear im Komponentencode, sondern verteilt sich über eine Kette, deren Reihenfolge man kennen muss, um das Verhalten zu verstehen. Ein falsch platzierter Link ist schwerer zu finden als ein falsch gesetzter Header direkt an der Aufrufstelle. Bei einer Handvoll Operationen ohne gemeinsame Belange wäre die Kette deshalb Aufwand ohne Ertrag.
Die Zuordnung lässt sich an wenigen Kriterien festmachen. Betrifft ein Belang viele oder alle Operationen, gehört er in die Kette. Betrifft er genau eine, gehört er an die Aufrufstelle. Ändert er sich unabhängig vom fachlichen Code und aus anderen Gründen als dieser, ist das ein starkes Signal für die Kette. Und je mehr solcher Belange gleichzeitig auf dem Tisch liegen, desto klarer zahlt sich die Trennung aus, weil sie verhindert, dass Auth, Fehler und Transport sich im selben Stück Code vermischen. Genau in dieser Situation, wenn mehrere Concerns zusammenkommen, ist die Kette kein Overhead, sondern das, was die Codebasis übersichtlich hält.
Fragments: Queries aus Bausteinen
Damit zur zweiten Hälfte. Ein Fragment ist eine benannte, wiederverwendbare Menge von Feldern eines bestimmten Typs. Statt in jeder Query, die einen User lädt, dieselben Felder erneut aufzuzählen, definiert man sie einmal und setzt sie per Spread ein.
import { gql } from '@apollo/client';
const USER_FIELDS = gql`
fragment UserFields on User {
id
name
email
}
`;
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
...UserFields
}
}
${USER_FIELDS}
`;
Zwei Dinge sind wichtig. Der Spread ...UserFields verweist auf das Fragment, und das Fragment-Dokument muss der Query per ${USER_FIELDS} mitgegeben werden, sonst kennt der Parser den Namen nicht. Das ist ein häufiger Anfängerfehler: Der Spread steht da, aber die Interpolation fehlt, und die Fehlermeldung ist nicht besonders sprechend.
Der eigentliche Gewinn zeigt sich erst im größeren Projekt und heißt Colocation. Die Idee: Ein Fragment steht direkt bei der Komponente, die genau diese Felder anzeigt. Eine UserCard-Komponente definiert ihr eigenes UserFields-Fragment und exportiert es. Die übergeordnete Query, die mehrere Komponenten füllt, sammelt die Fragmente ihrer Kinder ein und setzt sie zusammen. Ändert sich, welche Felder die UserCard braucht, ändert sich nur ihr Fragment, und die Query oben zieht die Änderung automatisch mit. Die Datenanforderung steht dort, wo die Daten gebraucht werden, und nicht in einer entfernten, zentralen Query-Datei, die niemand mehr pflegen mag.
Seit Apollo Client 3.8 gibt es dafür auch den Hook useFragment. Er erlaubt einer Komponente, genau die Felder eines Fragments aus dem Cache zu lesen, ohne selbst eine Query abzusetzen. Die Komponente wird nur dann neu gerendert, wenn sich die Felder ihres Fragments ändern, ein feinerer Schnitt als das erneute Rendern bei jeder Änderung der übergeordneten Query.
import { useFragment } from '@apollo/client';
function UserCard({ id }: { id: string }) {
const { data } = useFragment({
fragment: USER_FIELDS,
fragmentName: 'UserFields',
from: { __typename: 'User', id },
});
return <div>{data.name} · {data.email}</div>;
}
Fragment-Matching und possibleTypes
Ein letzter Punkt, der oft übersehen wird und dann für schwer auffindbare Fehler sorgt. Sobald ein Fragment auf einem Interface oder einer Union sitzt statt auf einem konkreten Typ, muss der Cache wissen, welche konkreten Typen zu diesem abstrakten Typ gehören. Nehmen wir ein Interface SearchResult, das von User und Post implementiert wird. Eine Query möchte je nach konkretem Typ unterschiedliche Felder laden.
query Search($term: String!) {
search(term: $term) {
__typename
... on User {
name
}
... on Post {
title
}
}
}
Damit Apollo diese Inline-Fragmente, ... on User und ... on Post, korrekt zuordnen kann, braucht der InMemoryCache eine possibleTypes-Angabe. Sie sagt ihm, welche konkreten Typen hinter jedem Interface oder jeder Union stehen.
const cache = new InMemoryCache({
possibleTypes: {
SearchResult: ['User', 'Post'],
},
});
Fehlt diese Angabe, kann der Cache das Matching nicht zuverlässig durchführen. Felder fehlen dann in den Ergebnissen, oder Apollo greift auf eine Heuristik zurück und gibt eine Warnung aus. Das Tückische daran ist, dass es in einfachen Fällen scheinbar trotzdem funktioniert und erst bei komplexeren Abfragen bricht. Deshalb pflegt man possibleTypes am besten gar nicht von Hand, sondern lässt es aus dem Schema generieren, per Introspection oder über GraphQL Codegen. So bleibt die Angabe automatisch aktuell, wenn ein neuer Typ das Interface implementiert.
Fazit
Die Transport-Schicht von Apollo Client ist kein monolithischer Netzwerk-Baustein, sondern eine Kette aus kleinen, klar umrissenen Stücken. Der Ertrag zeigt sich erst in der Wirkung. Weil jeder Concern an genau einer Stelle sitzt, sind spätere Anpassungen lokal statt verstreut. Das verkürzt die Zyklen, senkt die Reibung bei jeder Änderung an Auth oder Fehlerbehandlung und hält die Grenze zwischen fachlichem Code und Infrastruktur sauber. Der Preis dafür ist die Indirektion der Kette, und die meisten frustrierenden Debugging-Sessions, die ich dazu erlebt habe, lassen sich auf eine vertauschte Reihenfolge zurückführen. Das ist ein fairer Handel, solange man die Reihenfolge bewusst setzt und den terminierenden HttpLink ans Ende.
Fragments sind das Gegenstück auf der Query-Seite und folgen demselben Prinzip. Sie halten Feldmengen wiederverwendbar und ermöglichen Colocation nah an den Komponenten, sodass die Datenanforderung dort steht, wo die Daten gebraucht werden, und nicht in einer zentralen Riesen-Query, die niemand mehr pflegen mag. Ändert eine Komponente ihren Bedarf, ändert sich ihr Fragment statt einer weit entfernten Datei. Sobald abstrakte Typen ins Spiel kommen, ist possibleTypes die leicht vergessene Voraussetzung, ohne die das Matching bricht. Link-Reihenfolge und Fragmentgrenzen bleiben damit zwei konkrete Stellen, an denen ich Änderungen prüfe; Apollo nimmt mir diese Entscheidungen nicht ab.
Wie der Token, den der authLink setzt, überhaupt entsteht und serverseitig geprüft wird, ist ein Thema für sich; dazu passt der Beitrag über GraphQL-Auth im Context. Und wenn die geladenen Listen größer werden, lohnt der Blick in die GraphQL-Paginierung und Connections, wo Fragments und fetchMore zusammenspielen.
Weiterführende Quellen
- Apollo Link: Overview
- ApolloLink API (from, concat, split)
- Error handling mit onError
- Fragments (Colocation, possibleTypes, useFragment)
- Subscriptions (split und getMainDefinition)
Wie fandest du diesen Beitrag?
Kommentare