GraphQL Federation at Scale
Splitting one GraphQL API into composable subgraphs with Apollo Federation v2. Entities, @key directives, reference resolvers, supergraph composition, and batching entity lookups so queries stay fast as teams multiply.
Published on • August 11, 2026
AI Assistant

Every GraphQL horror story has the same origin: one schema, one server, and twenty teams shipping into it. Schema reviews become merge gatekeepers, deployments couple every team to one build, and a flaky resolvers.ts takes down the entire API. Federation splits the problem along ownership lines instead of splitting SQL. Every team owns a subgraph — a complete, independently deployable GraphQL service exposing the fields it owns — and a router merges the subgraphs into one supergraph that clients query as a single GraphQL server. The schema is composed, not hand-merged, so ownership boundaries are enforced by tooling rather than PR etiquette.
In this post, you will learn how Apollo Federation v2 models cross-service data with entities and @key directives, how entity resolution works internally (representations, reference resolvers, and the _entities batching endpoint), how to compose and publish a supergraph, and how to keep entity lookups from collapsing into N+1 queries.
Prerequisites
- A GraphQL API you have written resolvers for (any server language)
- Apollo Server or GraphQL Yoga familiarity helps
- Node.js 20+ for the runnable examples
Why federate instead of one giant gateway
A schema gateway (one server that proxies to many services) still centralizes schema state and forces each service to hand-roll joins. Federation inverts that: composition happens declaratively from each subgraph’s SDL, and the router generates query plans. There is no central “merged schema” file to keep honest. The unit of federation is the entity: an object type whose fields are distributed across subgraphs, uniquely identified by its @key field set — like a primary key. Two steps make an entity work: define the @key directive, and implement a reference resolver.
Entities and the @key directive
Here is the classic running example. Two subgraphs both know about Product, identified by upc. The Products subgraph owns the pricing fields, the Inventory subgraph owns stock fields:
# products subgraph
type Product @key(fields: "upc") {
upc: String!
name: String!
price: Int
}
# inventory subgraph
type Product @key(fields: "upc") {
upc: String!
inStock: Boolean!
}
The @key directive tells the router “this subgraph can resolve an instance of Product if you supply its upc.” Because both subgraphs declare the same key, the router combines their fields into a single Product type — one query { product(upc: "...") { name inStock } } resolves name from Products and inStock from Inventory in a single plan.
Keys become more powerful as teams diverge. A subgraph that needs only the key can declare a “stub” of the entity without contributing fields. Multiple @keys are allowed — the router picks the most efficient one for each join — and a composite key (@key(fields: "region id")) covers identities no single field uniquely owns. In Federation v2 you no longer write @extends or manual @external stubs on referencing subgraphs as v1 forced; composition propagates entity metadata. But one rule survives: a type referenced across subgraphs without a @key is a value type — it cannot be joined across service boundaries.
Entity resolution: representations and __resolveReference
Under the hood, the router needs a way to fetch entity fields from a subgraph that doesn’t own the entry point. It uses a deliberately minimal container called an entity representation: an object that is nothing more than __typename plus exactly the fields from a @key:
{ __typename: 'Product', upc: '0316438960' }
Subgraphs expose a special entry point, Query._entities, that accepts a batch of representations. For each representation, the subgraph dispatches to the entity’s reference resolver — __resolveReference in Apollo-land. That resolver receives the representation as its first argument and must return the fully populated entity object for the fields this subgraph contributes:
// inventory subgraph
const resolvers = {
Product: {
__resolveReference(productRepresentation) {
// productRepresentation = { __typename: 'Product', upc: '0316438960' }
return fetchStock(productRepresentation.upc);
}
}
};
The @apollo/subgraph plugin auto-wires _entities, iterates the incoming representations array, and calls the matching __resolveReference for each __typename — respecting input order. The router then stitches the returned partials into one response.
The representation contract is stricter than it looks: the reference contains only your key fields — never assume more. If your __resolveReference genuinely needs a non-key field to resolve, that field must be declared @requires. For example, inventory allocation depends on Product.price, a field owned elsewhere:
type Product @key(fields: "upc") {
upc: String!
inStock: Boolean!
allocation: Int @requires(fields: "price")
}
Declaring the dependency tells the router to fetch price from Products first, then hand your resolver { upc, price }. Reading a field that is not in the key and not @requires is a silent-bug factory: it looks correct in development and returns undefined under a different query plan.
Federation v2’s other directives
A production supergraph uses a handful more directives day to day:
@shareable— multiple subgraphs may resolve the same field (safe, idempotent values).@external— marks a field as owned elsewhere, defined only to satisfy@requires/@provides.@provides— a subgraph can resolve a field it does not own, as long as it also returns the field that field depends on (a denormalization hint for the planner).@override(from: "products")— during migrations, tell the router to prefer this subgraph’s version of a moved field until the destination catches up.@interfaceObject— adds fields to an interface from a subgraph that does not implement it.@inaccessible— compose a field but keep it out of the public supergraph.
Composing and publishing the supergraph
Composition is CI-gated, not manual. With the Apollo toolchain, you run rover, the schema CLI:
# Validate + publish each subgraph to the registry
rover subgraph check products --schema subgraph.graphql --name products --routing-url http://products:4001
rover subgraph publish products --schema subgraph.graphql --name products
# Locally, compose all subgraphs into a supergraph
rover supergraph compose --config ./supergraph.yaml > supergraph.graphql
# Run the router against it
./router --supergraph supergraph.graphql
subgraph check is the gate: it validates that composition still succeeds and, against live traffic, that no operation clients actually run would break from the change. A @key change that breaks a real field fails the build before it touches production.
Batching entity lookups: killing the N+1
Federation’s batching solves half the N+1 problem at the router layer. The other half lives in your reference resolvers: the router sends a batch of e.g. 500 Product representations into one _entities call, but if your __resolveReference fires 500 separate database queries, you have just moved the N+1 into the subgraph. Batch inside the resolver using a DataLoader keyed on the arriving keys:
import DataLoader from 'dataloader';
import { fetchStockByUPC } from './db';
const loader = new DataLoader(async (upcs) => {
const rows = await fetchStockByUPC(upcs); // one query, WHERE upc IN (...)
const byUpc = new Map(rows.map(r => [r.upc, r]));
return upcs.map(upc => byUpc.get(upc) ?? null); // keep order!
});
const resolvers = {
Product: {
__resolveReference: (rep) => loader.load(rep.upc),
}
};
Two invariants keep this correct. Return results in the exact input order (DataLoader requires it), and never return undefined for a missing record — return null or throw. A silent undefined makes the router drop the entire entity branch with no error in the trace, and it looks like a schema bug three weeks later.
Putting It All Together
The canonical three-subgraph federation repo — products, reviews, inventory, plus a supergraph and CI composition — is available as a runnable gist reference:
https://gist.github.com/redlinesoft/graphql-federation-at-scale
Clone it, run pnpm install in each subgraph plus ./router --supergraph supergraph.graphql, and query the router:
$ curl -s -X POST http://localhost:4000 -d '{"query":"{ topProducts { upc name inStock reviews { body } } }"}'
{
"data": {
"topProducts": [
{
"upc": "1",
"name": "Table",
"inStock": true,
"reviews": [{ "body": "Love it!" }]
}
]
}
}
The response merges fields owned by Products, Inventory, and Reviews inside a single resolver tree, joined purely by @key. That is the whole federation promise in one payload: three services, one query, no client-side stitching.
Conclusion & Next Steps
You learned how federation replaces a monolithic schema with composed subgraphs, how @key defines the identity the router uses to join entities, how __resolveReference hydrates the minimal representations it receives, and how to batch those lookups. The v2 directives — @shareable, @requires, @override, @interfaceObject — cover the lifecycle of a growing graph without turning schema reviews into a bottleneck.
Next steps: decompose one service you already own into two subgraphs and compose them locally, wire rover subgraph check into CI before you let a schema merge, and read the federation spec’s subgraph requirements so the next subgraph you add is correct on the first publish.
References / Sources
- GraphQL Foundation — graphql.org/learn, the language and type system reference. https://graphql.org/learn
- Apollo Federation subgraph specification — the directive semantics and
_entitiescontract. https://www.apollographql.com/docs/federation/federated-types/federation-spec - Apollo Federation entities documentation — defining keys and reference resolvers. https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/entities
- GraphQL DataLoader — batching and caching reference resolvers. https://github.com/graphql/dataloader