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 einmal hinaus zum Server. Genau diese Anfrage-Seite ist das Thema hier: die Transport-Schicht von Apollo Client, die aus einer scheinbar simplen Idee besteht und in der Praxis überraschend viele Fragen aufwirft.
In meinen Schulungen erlebe ich immer wieder denselben Moment. Jemand hat einen ApolloClient aufgesetzt, alles funktioniert – und dann kommt die erste echte Anforderung: „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." An diesem Punkt merken die meisten, dass der eine Netzwerk-Link, den sie beim Setup gesehen haben, 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.
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 in Schulungen am häufigsten für Kopfzerbrechen sorgt. Der terminierende Link muss das letzte Element sein. Steht der authLink versehentlich hinter dem httpLink, wird er nie erreicht – 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<br/>onError] E --> A[authLink<br/>setContext] A --> H[httpLink<br/>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 in Projekten immer wieder auftauchen. 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, reicht ein einziger Transport nicht mehr. Queries und Mutations laufen über HTTP, Subscriptions dagegen über eine dauerhafte WebSocket-Verbindung. Die Aufgabe, die richtige Operation auf den richtigen Transport 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. Das ist die aktuelle Empfehlung. Die ältere Kombination aus WebSocketLink und subscriptions-transport-ws findet man noch in vielen Projekten, sie gilt aber als veraltet – für neue Projekte gibt es keinen Grund mehr, sie zu wählen.
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.
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 – 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, die man aus kleinen, klar umrissenen Stücken zusammensetzt. Jeder Link hat eine Aufgabe – Fehler zentral behandeln, Header vorbereiten, wiederholen, routen, senden – und die Reihenfolge im from-Array bestimmt, wie diese Aufgaben ineinandergreifen. Der terminierende HttpLink gehört ans Ende, und die meisten frustrierenden Debugging-Sessions in meinen Schulungen lassen sich auf eine vertauschte Reihenfolge zurückführen.
Fragments sind das Gegenstück auf der Query-Seite. Sie halten Feldmengen wiederverwendbar, ermöglichen Colocation nah an den Komponenten und machen Queries wartbar, statt sie zu einer zentralen Riesen-Datei anwachsen zu lassen. Sobald abstrakte Typen ins Spiel kommen, ist possibleTypes die kleine, leicht vergessene Voraussetzung, ohne die das Matching nicht sauber funktioniert. Wer beide Seiten – die Link-Kette und die Fragment-Komposition – bewusst gestaltet, hat einen Client, der auch dann noch übersichtlich bleibt, wenn Auth, Fehlerbehandlung und Subscriptions gleichzeitig auf dem Tisch liegen.
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.
Kommentare
Kommentar schreiben