GraphQL Federation: ein Schema aus vielen Subgraphs
Wenn ein Team ein Monolith-Schema nicht mehr allein tragen kann, teilt Federation den Graphen in Subgraphs auf, die ein Router zu einem Supergraph komponiert. Dieser Artikel zeigt an einem User- und Orders-Beispiel, wie @key und __resolveReference Entitäten über Subgraph-Grenzen hinweg zusammenhalten.
Wenn ich in einer Schulung zum Thema GraphQL-Architektur ankomme, steht irgendwann immer dieselbe Frage im Raum: „Unser Schema ist inzwischen riesig, drei Teams hängen dran, jeder Merge ist ein Politikum – wie schneiden wir das auseinander, ohne dass die Clients es merken?" Genau an dieser Stelle wird Federation interessant. Und genau an dieser Stelle rutschen viele auch in die Falle, Federation zu früh einzuführen – aber dazu später mehr.
Das Problem: ein Schema, das niemandem mehr gehört
Am Anfang ist ein GraphQL-Schema wunderbar. Ein Team, ein Repository, ein type Query, alles liegt beieinander. Der Reiz von GraphQL ist ja gerade, dass Clients sich aus einem einzigen Endpoint genau die Felder holen, die sie brauchen. Verwandte Fragen habe ich in früheren Beiträgen schon behandelt – etwa wie man Listen mit GraphQL-Paginierung sauber ausliefert oder wie Berechtigungen über GraphQL-Auth im Context durchgereicht werden.
Das Problem beginnt mit dem Wachstum. Sobald mehrere Teams an einem Schema arbeiten, wird die Datei zum Nadelöhr. Das User-Team will ein Feld an type Order hängen, muss dafür aber in den Code des Order-Teams greifen. Deployments koppeln sich aneinander. Ein type gehört plötzlich vielen, und „vielen gehören" heißt in der Praxis: niemandem. Teilnehmer beschreiben mir das meist als Merge-Konflikte, die eigentlich Organisations-Konflikte sind.
Federation dreht die Frage um. Statt „Wie teilen sich Teams ein Schema?" fragt sie: „Wie betreibt jedes Team sein eigenes Schema – und der Client sieht trotzdem nur einen Graphen?"
Die Grundidee: Subgraphs, Supergraph, ein Endpoint
In Apollo Federation 2 – das ist die Generation, die seit 2022 relevant ist und die ich hier durchgehend meine – betreibt jedes Team einen Subgraph. Ein Subgraph ist ein ganz normaler GraphQL-Service mit eigenem Schema, eigener Datenbank, eigenem Deployment. Die einzelnen Subgraph-Schemas werden zu einem Supergraph-Schema komponiert. Zur Laufzeit sitzt davor ein Router (oder ein Gateway), der die eingehende Query entgegennimmt, einen Plan erstellt und die Teilfragen an die zuständigen Subgraphs verteilt.
Der entscheidende Punkt, den ich in Schulungen immer betone: Der Client merkt von all dem nichts. Er spricht mit einem Endpoint, sieht ein Schema, stellt eine Query. Die Aufteilung ist reine Betriebs- und Team-Topologie – für den Konsumenten unsichtbar.
flowchart TB
Client["Client<br/>eine Query, ein Endpoint"]
Router["Router / Gateway<br/>komponiert das Supergraph"]
Users["Users-Subgraph<br/>Team A"]
Orders["Orders-Subgraph<br/>Team B"]
UDB[("Users-DB")]
ODB[("Orders-DB")]
Client --> Router
Router --> Users
Router --> Orders
Users --> UDB
Orders --> ODB
Wer die Laufzeit betreibt, hat 2024 zwei etablierte Optionen: den @apollo/gateway in Node oder den in Rust geschriebenen Apollo Router. Beide sind gültig. Der Router ist der performantere Weg und die übliche Empfehlung für neue Setups, aber das Gateway ist keineswegs abgelöst – wer schon ein Node-Gateway betreibt, muss nicht umziehen.
Rollen im System
Bevor wir Code schreiben, hilft es, die drei Bausteine sauber auseinanderzuhalten. Ich zeichne das in Schulungen gern als kurze Rollenliste an die Wand:
- Der Subgraph trägt genau seine eigenen Felder bei. Er kennt seine Datenbank, sein Schema, sein Deployment – und sonst nichts. Team-Autonomie steckt hier.
@keymarkiert einen Typ als Entity: einen Typ, der subgraph-übergreifend referenzierbar ist. Ohne@keykann ein anderer Subgraph den Typ weder erweitern noch per Referenz auf ihn zeigen.- Der Router plant die Query. Er zerlegt eine eingehende Anfrage in Teilfragen, holt sich bei jedem Subgraph dessen Beitrag und fügt die Antwort wieder zusammen. Cross-Subgraph-Joins sind seine Aufgabe, nicht die der Teams.
Diese Trennung ist der ganze Trick: Teams denken lokal, der Router denkt global.
Fed 2 ist explizit: das @link-Opt-in
Ein Subgraph in Federation 2 sagt ausdrücklich, welche Federation-Direktiven er nutzen will. Das geschieht über @link am Schema. Das ist ein bewusster Bruch mit Federation 1, wo extend type ... @key implizit ein bestimmtes Verhalten mitbrachte. In Fed 2 importiert man, was man braucht:
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.0"
import: ["@key", "@shareable", "@external"]
)
Wer das @link vergisst oder eine Direktive nutzt, die nicht importiert wurde, bekommt keinen Laufzeitfehler, sondern einen Kompositionsfehler – und zwar früh, beim Komponieren oder Publizieren. Das überrascht Fed-1-Umsteiger regelmäßig, ist aber der bessere Zeitpunkt für einen Fehler.
Der Users-Subgraph
Fangen wir mit dem einfachen Teil an. Der Users-Subgraph besitzt die User-Entity. Er definiert sie, markiert sie mit @key und liefert einen Reference-Resolver, der eine User-Repräsentation auflöst.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
Der Code dazu nutzt buildSubgraphSchema aus dem Paket @apollo/subgraph. Das ist der Baustein, der aus typeDefs und resolvers ein federiertes GraphQLSchema macht, das die Federation-Direktiven und die von der Spec verlangten Felder wie _entities kennt.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
`;
const resolvers = {
Query: {
user: (_parent, { id }) => fetchUserById(id),
},
User: {
// The router hands us a representation: { __typename: "User", id }
__resolveReference: (reference) => fetchUserById(reference.id),
},
};
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
const { url } = await startStandaloneServer(server, { listen: { port: 4001 } });
console.log(`Users subgraph ready at ${url}`);
Das Herzstück ist __resolveReference. Diese Funktion bekommt eine Representation – ein Objekt mit __typename und den @key-Feldern – und liefert die vollständige Entity zurück (oder null, wenn es sie nicht gibt). Wichtig zu verstehen: Ein normaler Feld-Resolver reicht hier nicht, weil der Router den Auflösungsprozess nur mit den Key-Feldern startet. Ohne Reference-Resolver kann er die Lücke nicht füllen.
Der Orders-Subgraph mit Cross-Reference
Jetzt wird es interessant. Der Orders-Subgraph besitzt Order. Eine Order gehört einem User – aber der Orders-Subgraph kennt von User nur die ID. Er will trotzdem ein Feld order.user anbieten. Dafür nimmt er User als Stub auf: mit @key, aber nur mit dem Key-Feld, ohne eigene Auflösung der übrigen Felder.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type Order @key(fields: "id") {
id: ID!
total: Int!
user: User
}
# User lives in the users subgraph; here it is only referenced.
type User @key(fields: "id") {
id: ID!
}
type Query {
order(id: ID!): Order
}
Der Resolver für Order.user macht etwas, das anfangs fast zu simpel wirkt: Er gibt nur eine Referenz zurück – __typename plus Key-Feld. Die eigentliche Auflösung von name oder email passiert später im Users-Subgraph.
const resolvers = {
Query: {
order: (_parent, { id }) => fetchOrderById(id),
},
Order: {
__resolveReference: (reference) => fetchOrderById(reference.id),
// Return just a reference; the users subgraph fills in the rest.
user: (order) => ({ __typename: 'User', id: order.userId }),
},
};
So bleibt jede Verantwortung dort, wo sie hingehört. Das Orders-Team weiß, welcher User zu einer Order gehört (die userId liegt in seiner DB), aber es muss die User-Daten nicht selbst kennen. Der Router übernimmt den Rest.
Was der Router im Hintergrund tut
Nehmen wir eine Client-Query, die beides überspannt:
query {
order(id: "42") {
total
user {
name
}
}
}
Der Client sieht eine Query. Der Router sieht einen Plan. Er holt zuerst total und die user.id aus dem Orders-Subgraph, schickt dann eine _entities-Anfrage mit der Representation { __typename: "User", id } an den Users-Subgraph, dessen __resolveReference daraus den name besorgt, und merged am Ende alles zu einer Antwort.
sequenceDiagram
participant C as Client
participant R as Router
participant O as Orders-Subgraph
participant U as Users-Subgraph
C->>R: order(id: 42) { total, user { name } }
R->>O: order { total, user { id } }
O-->>R: { total: 90, user: { id: 7 } }
R->>U: _entities([{ __typename: User, id: 7 }])
U-->>R: { name: "Ada" }
R-->>C: { total: 90, user: { name: "Ada" } }
Wenn dieser Ablauf einmal sitzt, macht es „klick": @key sagt, woran eine Entity erkennbar ist, __resolveReference sagt, wie man sie aus dieser Erkennung vollständig macht, und der Router orchestriert dazwischen.
Komponieren und starten
Die Subgraph-Schemas müssen zu einem Supergraph zusammengeführt werden. Lokal geht das mit rover supergraph compose und einer Konfiguration, die auf die Subgraphs zeigt:
# supergraph.yaml lists each subgraph and its endpoint
rover supergraph compose --config ./supergraph.yaml > supergraph.graphql
# Start the Apollo Router with the composed supergraph
./router --supergraph supergraph.graphql
In größeren Setups übernimmt Managed Federation über Apollo Studio / GraphOS diesen Schritt: Jeder Subgraph publiziert sein Schema, die Komposition passiert zentral, der Router zieht die aktuelle Supergraph-Version. Der wichtige Punkt bleibt derselbe – die Komposition ist der Moment, in dem Fehler auffallen, nicht die Laufzeit.
Geteilte Felder: der Unterschied zu Federation 1
Ein Detail, an dem Fed-1-Umsteiger regelmäßig stolpern: In Federation 2 sind Nicht-Key-Felder standardmäßig nicht teilbar. Wenn dasselbe Feld eines Typs in mehreren Subgraphs aufgelöst wird, muss es explizit mit @shareable markiert sein – sonst schlägt die Komposition fehl. Das betrifft vor allem Value-Types, die in mehreren Subgraphs identisch vorkommen.
type Money @shareable {
amount: Int!
currency: String!
}
Verwandt ist @external: Ein Feld, das in diesem Subgraph nur referenziert wird und einem anderen gehört, wird als @external markiert – häufig zusammen mit @requires oder @provides, wenn ein Subgraph für seine Logik ein fremdes Feld braucht. Fed 2 ist an dieser Stelle strenger als Fed 1, und das ist Absicht: Mehrdeutigkeiten werden zum Fehler, statt sich still in der Laufzeit zu verstecken.
Die Fallstricke, die ich immer erwähne
Federation löst ein Organisationsproblem, handelt sich dafür aber technische Eigenheiten ein. Drei davon kommen in jeder Schulung auf:
- Das N+1-Problem im Reference-Resolver. Der Router löst Entities einzeln oder in Batches auf. Wenn
__resolveReferencenaiv pro Aufruf einen DB-Roundtrip macht, entstehen viele kleine Anfragen. Ein DataLoader pro Subgraph-Request bündelt sie – dasselbe Muster, das man auch außerhalb von Federation gegen N+1 einsetzt. - Das Key-Feld muss immer in der Payload landen. Ein Feld mit Argumenten oder ein Union- bzw. Interface-Rückgabetyp taugt nicht als
@key; ein nullbares Feld ist erlaubt, aber nicht empfehlenswert. Unterschiedliche Key-Felder für dieselbe Entity über verschiedene Subgraphs sind erlaubt, aber fehleranfällig – im Zweifel überall denselben Key nutzen. - Kompositionsfehler statt Laufzeitfehler. Ein vergessenes
@shareable, ein Typ-Mismatch, ein fehlender@link-Import – all das wird beim Komponieren oder Publizieren abgelehnt. Das ist der richtige Zeitpunkt, überrascht aber jeden, der aus einer Welt kommt, in der solche Dinge erst im Betrieb knallen.
Federation 2 oder Schema-Stitching?
Wer länger mit GraphQL arbeitet, kennt noch Schema-Stitching – den älteren Weg, mehrere Schemas zusammenzuführen, heute etwa mit @graphql-tools/stitch. Beim Stitching kombiniert man Remote-Schemas manuell und schreibt Delegations- und Merge-Logik selbst. Das funktioniert, aber die Kopplung liegt im Gateway-Code, nicht in einer Spec. Federation deklariert die Beziehungen stattdessen im Schema (@key, @shareable) und überlässt dem Router die Ausführung. Stitching ist nicht tot – für manche Sonderfälle mit fremden, nicht anpassbaren Schemas ist es weiterhin brauchbar. Für eine neue, teamgetriebene Architektur greife ich 2024 aber zu Federation 2.
Wann ich NICHT federiere
Der wichtigste Satz zum Schluss, weil er in Schulungen am meisten Diskussion auslöst: Federation ist Overhead. Router, Komposition, Reference-Resolver, ein zusätzlicher Netzwerk-Hop pro Cross-Subgraph-Join – das alles will betrieben und verstanden sein. Wenn ein einzelnes Team ein Schema betreibt, das gut in einen Service passt, dann löst Federation ein Problem, das dieses Team nicht hat. Man handelt sich verteilte Systeme ein, um eine Organisationsgrenze abzubilden, die noch gar nicht existiert.
Meine Faustregel: Federation folgt der Team-Struktur, nicht der Technik. Erst wenn mehrere Teams sich an einem Schema in die Quere kommen, wenn Deployments sich koppeln, wenn ein type niemandem mehr gehört – dann zahlt sich der Schnitt aus. Vorher ist ein guter Monolith mit klaren Modulen fast immer die bessere Wahl. Und wer clientseitig ohnehin schon sauber mit Apollo-Client-Caching arbeitet, merkt: An der Client-Seite ändert Federation nichts – dort bleibt es genau der eine Graph, der es immer war.
Weiterführende Quellen
- Introduction to Apollo Federation: https://www.apollographql.com/docs/federation/
- Entities (
@key,__resolveReference): https://www.apollographql.com/docs/federation/entities - Implementing a Subgraph with Apollo Server: https://www.apollographql.com/docs/federation/subgraphs/
- API
@apollo/subgraph(buildSubgraphSchema): https://www.apollographql.com/docs/apollo-server/using-federation/api/apollo-subgraph - Moving to Federation 2 (inkl.
@shareable): https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/moving-to-federation-2 - Subgraph-Spec (
_entities,_Entity,@link): https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec
Kommentare
Kommentar schreiben