Advanced GraphQL Federation: Building a Distributed Supergraph

GraphQL federation is the natural evolution for organizations that have outgrown a single, monolithic GraphQL schema. As engineering teams scale, maintaining a unified schema becomes an organizational bottleneck - every new field requires coordination with the schema owners, every breaking change ripples across all consumers.
Federation solves this with a distributed supergraph: multiple teams maintain independent GraphQL subgraphs, which are composed into a single unified API by a gateway. Clients query one endpoint and see a coherent schema; behind the scenes, the gateway routes query fragments to the appropriate subgraphs and stitches the results together.
This guide covers Apollo Federation v2 (the current standard), subgraph design patterns, entity resolution, N+1 mitigation with DataLoaders, caching, and production performance considerations.
The Supergraph Architecture
┌─────────────────────────────────┐
Client ────────────▶│ Apollo Router (Gateway) │
└─────┬─────────┬────────┬─────────┘
│ │ │
┌─────▼──┐ ┌────▼──┐ ┌──▼──────┐
│ Users │ │Orders │ │ Products │
│Subgraph│ │Subgraph│ │ Subgraph │
└────────┘ └────────┘ └──────────┘
│ │ │
Postgres MongoDB Postgres
The client issues a single query:
query GetOrderWithDetails {
order(id: "ord_123") {
id
total
user {
name
email
}
items {
product {
name
price
}
quantity
}
}
}
The router's query planner splits this into sub-operations, fetches data from the Orders, Users, and Products subgraphs in parallel where possible, and merges the result. The client receives one response with no knowledge of the underlying distribution.
Defining Subgraph Schemas
Each subgraph uses Federation-specific directives to declare what it owns and what it can contribute to other subgraphs.
The @key Directive: Entity Declaration
The @key directive declares an entity - a type that can be referenced and extended across subgraphs. The key specifies the field(s) that uniquely identify an instance:
Users subgraph:
# users-subgraph/schema.graphql
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
createdAt: String!
}
type Query {
user(id: ID!): User
me: User
}
Orders subgraph:
# orders-subgraph/schema.graphql
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@external", "@requires"])
# Reference the User entity defined in the Users subgraph
type User @key(fields: "id") {
id: ID!
orders: [Order!]!
}
type Order @key(fields: "id") {
id: ID!
total: Float!
status: OrderStatus!
userId: ID!
user: User!
items: [OrderItem!]!
}
enum OrderStatus { PENDING, FULFILLED, CANCELLED }
type OrderItem {
productId: ID!
quantity: Int!
unitPrice: Float!
}
type Query {
order(id: ID!): Order
orders(userId: ID!): [Order!]!
}
Notice that the Orders subgraph declares User @key(fields: "id") with only the id field - it references the User entity without owning it. The gateway knows that to resolve order.user.name, it must first fetch the userId from Orders, then query Users with { _entities(representations: [{__typename: "User", id: $userId}]) }.
Entity Resolution: The __resolveReference Function
Every entity must implement a __resolveReference resolver. The gateway calls this when it needs to resolve cross-subgraph references:
// users-subgraph/resolvers.ts
import { UserService } from "./services/UserService";
const resolvers = {
User: {
// Called by the gateway with the entity "representation" (partial object containing @key fields)
__resolveReference: async (reference: { id: string }, context: Context) => {
return context.dataSources.userService.findById(reference.id);
},
},
Query: {
user: (_: unknown, { id }: { id: string }, context: Context) => {
return context.dataSources.userService.findById(id);
},
me: (_: unknown, __: unknown, context: Context) => {
return context.dataSources.userService.findById(context.userId);
},
},
};
The gateway may call __resolveReference with a batch of representations in a single request (via the _entities query). This is where N+1 problems appear if you're not careful.
The N+1 Problem in Federation
Consider an order list query that returns 50 orders, each with a user field. Without optimization, the gateway would:
- Fetch 50 orders from the Orders subgraph (1 query)
- For each order's user, call
_entities50 times to the Users subgraph (50 queries)
Total: 51 subgraph calls to render one page.
Solution: DataLoader in __resolveReference
The Apollo Router automatically batches entity resolution requests. It collects all User references from an operation and sends them in a single _entities query. But your __resolveReference must still handle this efficiently:
import DataLoader from "dataloader";
// Create a DataLoader per request (in context factory)
export function createUserLoader(db: Database): DataLoader<string, User> {
return new DataLoader(
async (userIds: readonly string[]) => {
// Single DB query fetches all users at once
const users = await db.query(
"SELECT * FROM users WHERE id = ANY($1)",
[userIds]
);
// DataLoader requires results in the same order as input keys
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
},
{ cache: true } // cache within request - same user requested twice = one DB hit
);
}
// In context factory:
const context = {
loaders: { user: createUserLoader(db) }
};
// In resolver:
const resolvers = {
User: {
__resolveReference: async (reference: { id: string }, context: Context) => {
return context.loaders.user.load(reference.id);
},
},
};
With this pattern: 50 orders → 1 batch call to Users subgraph → 1 SQL query. Total: 2 subgraph calls.
The @requires Directive: Computed Fields
Sometimes a subgraph needs fields from another subgraph to compute its own fields. The @requires directive declares this dependency:
# shipping-subgraph/schema.graphql
type User @key(fields: "id") {
id: ID!
address: String! @external # owned by Users subgraph
shippingZone: ShippingZone! # computed from address by Shipping subgraph
@requires(fields: "address")
}
const resolvers = {
User: {
__resolveReference: async (reference: { id: string; address: string }) => {
// `address` is provided by the gateway from the Users subgraph
return {
id: reference.id,
shippingZone: computeShippingZone(reference.address),
};
},
},
};
The gateway's query planner automatically fetches address from Users before calling the Shipping subgraph. You declare the dependency; the router figures out execution order.
Schema Composition with Rover CLI
Apollo Rover is the CLI for managing your supergraph schema:
# Install Rover
curl -sSL https://rover.apollo.dev/nix/latest | sh
# Check subgraph schema for Federation compatibility
rover subgraph check my-graph@production \
--schema ./users-subgraph/schema.graphql \
--name users
# Publish subgraph schema to Apollo Studio
rover subgraph publish my-graph@production \
--schema ./users-subgraph/schema.graphql \
--name users \
--routing-url https://users.internal.example.com/graphql
# Compose supergraph schema locally (for CI validation)
rover supergraph compose --config ./supergraph.yaml > supergraph-schema.graphql
supergraph.yaml:
federation_version: =2.3.5
subgraphs:
users:
routing_url: https://users.internal.example.com/graphql
schema:
file: ./users-subgraph/schema.graphql
orders:
routing_url: https://orders.internal.example.com/graphql
schema:
file: ./orders-subgraph/schema.graphql
products:
routing_url: https://products.internal.example.com/graphql
schema:
file: ./products-subgraph/schema.graphql
Always run rover supergraph compose in CI before deploying any subgraph schema change. Composition errors (like conflicting type definitions or invalid @requires chains) fail fast here rather than at runtime.
Caching Strategy
Federation introduces multiple cache boundaries. A well-designed caching strategy is essential for performance:
1. Subgraph-level response caching
Use @cacheControl directives to annotate per-field TTLs:
type Product @key(fields: "id") @cacheControl(maxAge: 300) {
id: ID!
name: String! @cacheControl(maxAge: 3600) # rarely changes
price: Float! @cacheControl(maxAge: 60) # changes more often
stockCount: Int! @cacheControl(maxAge: 0) # never cache
}
Apollo Router respects Cache-Control headers from subgraphs and can serve cached supergraph responses for public/authenticated queries separately.
2. DataLoader caching (request-scoped)
As shown above, DataLoader caches within a single request. Never reuse DataLoader instances across requests - they'd serve stale data.
3. Redis for hot entities
For heavily read entities (e.g., product catalog that changes rarely):
async function findProductById(id: string): Promise<Product> {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await db.products.findById(id);
await redis.setex(`product:${id}`, 300, JSON.stringify(product)); // 5-min TTL
return product;
}
Deploying Apollo Router
Apollo Router is the production-grade gateway written in Rust, replacing the Node.js Apollo Gateway:
# router.yaml
supergraph:
path: /graphql
cors:
origins:
- https://app.example.com
headers:
all:
request:
- propagate:
matching: ^x-.* # forward all x-* headers to subgraphs
traffic_shaping:
all:
deduplicate_variables: true
router:
timeout: 30s
subgraphs:
users:
timeout: 5s
orders:
timeout: 10s
telemetry:
exporters:
tracing:
otlp:
endpoint: http://otel-collector:4317
Run it:
docker run -p 4000:4000 \
-v $(pwd)/supergraph-schema.graphql:/app/supergraph.graphql \
-v $(pwd)/router.yaml:/app/router.yaml \
ghcr.io/apollographql/router:v1.46.0 \
--supergraph /app/supergraph.graphql \
--config /app/router.yaml
Apollo Router processes ~8M requests/second on a single core for simple queries - orders of magnitude faster than the Node.js gateway.
Query Planning and Performance
Apollo Router's query planner generates an execution plan for every incoming operation. You can inspect plans with:
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ order(id: \"123\") { user { name } items { product { name } } } }"}' \
-H "Apollo-Query-Plan-Experimental: true"
Optimize query plans by:
- Reducing entity jumps: Design schemas so common queries need fewer cross-subgraph hops
- Co-locating frequently joined data: If
order.useris always fetched together, consider addingUserfields directly to the Orders subgraph instead - Using
@interfaceObject: For polymorphic types that span subgraphs (Federation v2.3+)
Schema Governance at Scale
With multiple teams owning subgraphs, schema governance prevents drift and breaking changes:
- Breaking change detection: Apollo Studio's schema checks block deployments that break existing operations
- Schema proposals: Use Apollo Schema Proposals for cross-team RFC-style review of large changes
- Demand-driven schema: Each team owns types matching their service domain; no cross-team mutations
- Contracts: Define filtered schema views per client (mobile, web, partner API) using Apollo Contracts
Test Your Knowledge
A company is migrating from a monolithic GraphQL API to a federated supergraph using Apollo Federation v2. The `Product` type, initially owned by the `Inventory` subgraph, needs to have a new field `reviewsCount` added, which is calculated by the `Reviews` subgraph. What is the most appropriate way to implement this change while minimizing coupling and ensuring proper entity resolution?
Frequently Asked Questions
Federation v1 vs v2 - should I migrate?
Yes, if possible. Federation v2 adds @interfaceObject, @authenticated, @requiresScopes, improved @override for gradual migrations, and better composition error messages. Apollo Gateway (v1 runtime) is in maintenance mode; Apollo Router is the current standard.
Can I use Federation without Apollo Studio? Yes. Rover CLI composes schemas locally. Apollo Router runs without Apollo Studio connectivity. Studio adds managed federation (push subgraph schemas, automatic router updates) which is very useful at scale but not required.
How do I handle authentication across subgraphs?
Propagate the Authorization header from the router to all subgraphs (configure headers.all.request.propagate). Each subgraph validates the token independently, or you can use a shared auth library. Never trust userId claims in the request body - always validate the JWT.
What's the performance overhead of federation vs a monolith? Expect 5–15ms additional latency per subgraph hop for simple queries (network RTT inside your cluster). For complex multi-subgraph queries, parallel execution minimizes this. Apollo Router's Rust query planner adds <1ms overhead. At scale, the organizational benefits (team autonomy, independent deployments) far outweigh the latency cost.
Wrapping Up
GraphQL Federation is not just a technical pattern - it's an organizational scaling strategy. Teams that master it can deploy subgraph changes independently, iterate on their schemas without coordination overhead, and evolve their portion of the API at their own pace.
The key principles: own your entities cleanly, implement __resolveReference with DataLoaders, declare @requires dependencies explicitly, and let the router handle the stitching. Composition validation in CI prevents the most common cross-team breakage.
You Might Also Like
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Advanced GraphQL API Design
Design production-grade GraphQL APIs: solve the N+1 problem with DataLoader, implement cursor-based pagination, field-level auth, and schema federation.
Read more
GraphQL vs. gRPC: Choosing the Right API Paradigm in 2026
GraphQL vs gRPC in 2026: architectural trade-offs, Protobuf binary encoding vs JSON, HTTP/2 multiplexing, and the optimal BFF hybrid pattern.
Read more
Temporal Workflow Orchestration: Building Resilient Distributed State Machines in TypeScript
Architecting durable distributed state machines in TypeScript with Temporal: activity retries, determinism constraints, and saga orchestration patterns.
Read more