GraphQL Federation for Microservices at Scale
How GraphQL Federation unifies distributed microservice graphs into a single API surface — subgraph design, entity resolution, performance patterns, and…
Contents
- The Problem Federation Solves
- Federation Architecture
- Defining Subgraph Schemas
- Users Subgraph
- Orders Subgraph
- Entity Resolution
- Query Planning and Execution
- The N+1 Problem at Subgraph Boundaries
- Schema Composition and CI/CD
- Performance at Scale
- Response Caching at the Router
- Subgraph Performance Monitoring
- Operational Patterns
- Gradual Migration from Monolith
- Schema Governance
GraphQL Federation solves one of the hardest problems in microservice architecture: providing clients with a unified API without creating a monolithic API gateway that becomes a bottleneck and a development bottleneck. With federation, each service owns and publishes its portion of the graph. A router stitches them together at query time.
The Problem Federation Solves
In a non-federated microservice environment, a mobile client wanting to display a user's recent orders with product details must either:
- Call the Users service, then the Orders service, then the Products service separately — three round trips, complex client-side joins
- Go through a hand-written API gateway that orchestrates these calls — which every team must coordinate to modify
GraphQL Federation gives you option three: a single query that crosses service boundaries transparently, with each service team maintaining independent control over their schema slice.
Federation Architecture
A federated system has three layers:
Subgraphs — individual GraphQL services that expose their domain entities. Each subgraph is a complete, independently deployable GraphQL server.
Router — the Apollo Router (or compatible implementation) that receives client queries, plans how to split them across subgraphs, executes the plan, and merges the results.
Supergraph schema — the composed schema that combines all subgraph schemas. Built by the Apollo Rover CLI during CI/CD, not at runtime.
Client → Router (supergraph) → Users Subgraph
→ Orders Subgraph
→ Products Subgraph
Defining Subgraph Schemas
Each subgraph defines its entities using @key directives. The @key marks which field(s) uniquely identify an entity so other subgraphs can reference and extend it.
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!
email: String!
displayName: String!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
me: User
}
Orders Subgraph
The Orders subgraph references User without importing the full Users schema. It defines a "stub" of User with only the fields it needs to resolve:
# orders-subgraph/schema.graphql
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3",
import: ["@key", "@external", "@requires"])
type User @key(fields: "id") {
id: ID!
orders(limit: Int = 10, status: OrderStatus): [Order!]!
}
type Order @key(fields: "id") {
id: ID!
status: OrderStatus!
totalAmountCents: Int!
placedAt: DateTime!
lineItems: [LineItem!]!
}
type LineItem {
productId: ID!
quantity: Int!
unitPriceCents: Int!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
}
type Query {
order(id: ID!): Order
}
Entity Resolution
When the router needs to fetch orders for a specific user, it calls the Orders subgraph's __resolveReference function with just the user's id:
// orders-subgraph/resolvers.ts
const resolvers = {
User: {
__resolveReference: async (reference: { id: string }, context: Context) => {
// reference contains only the @key fields from the other subgraph
return { id: reference.id }
},
orders: async (user: { id: string }, args: OrdersArgs, context: Context) => {
return context.orderRepository.findByUserId(user.id, {
limit: args.limit,
status: args.status,
})
},
},
Query: {
order: async (_: unknown, args: { id: string }, context: Context) => {
return context.orderRepository.findById(args.id)
},
},
}
Query Planning and Execution
Given this client query:
query UserDashboard($userId: ID!) {
user(id: $userId) {
displayName
email
orders(limit: 5, status: PAID) {
id
totalAmountCents
placedAt
}
}
}
The router generates a query plan:
- Fetch
user.displayNameanduser.emailfrom Users subgraph - Fetch
user.ordersfrom Orders subgraph usinguser.idfrom step 1
This is two subgraph calls, not three separate client round trips. The router executes them with the minimum number of network calls required by the dependency graph.
The N+1 Problem at Subgraph Boundaries
If a query returns a list of orders and each order needs product details from a Products subgraph, a naive implementation makes one request to Products for each order. The router batches these automatically using the @key mechanism — all product IDs are collected and sent in a single batched request.
Implement DataLoader in each subgraph to handle within-subgraph N+1 problems:
// products-subgraph/loaders.ts
export function createProductLoader(db: Database) {
return new DataLoader<string, Product>(async (productIds) => {
const products = await db.products.findMany({
where: { id: { in: productIds as string[] } },
})
const productMap = new Map(products.map(p => [p.id, p]))
return productIds.map(id => productMap.get(id) ?? new Error(`Product ${id} not found`))
})
}
Schema Composition and CI/CD
The supergraph schema is composed by Rover during CI, not at router startup. This means schema incompatibilities between subgraphs are caught before deployment:
# .github/workflows/schema-check.yml
- name: Check subgraph schema compatibility
run: |
rover subgraph check my-graph@production \
--name orders \
--schema ./orders-subgraph/schema.graphql \
--routing-url https://orders.internal/graphql
- name: Publish to Apollo Registry
if: github.ref == 'refs/heads/main'
run: |
rover subgraph publish my-graph@production \
--name orders \
--schema ./orders-subgraph/schema.graphql \
--routing-url https://orders.internal/graphql
The Apollo Registry stores the composed supergraph and pushes schema updates to the router via uplink — no router restart required for schema changes.
Performance at Scale
Response Caching at the Router
The Apollo Router supports full-response and partial-response caching via Redis:
# router.yaml
supergraph:
listen: 0.0.0.0:4000
plugins:
experimental.cache_control:
enabled: true
redis:
urls: ["redis://redis.internal:6379"]
experimental.response_caching:
enabled: true
private: false
max_age: "30s"
Subgraphs signal cache control via HTTP headers or @cacheControl directives. Product catalog queries can cache for minutes; user-specific data should not cache at the router level.
Subgraph Performance Monitoring
Each subgraph call emits a span to your distributed tracing system. Monitor:
- Fetch duration per subgraph — identifies slow downstream services
- Query plan complexity — deeply nested queries requiring many sequential subgraph fetches have high latency floors
- Entity resolution batch size — very small batches (< 5) indicate the DataLoader window is too small
Operational Patterns
Gradual Migration from Monolith
Federation excels as a migration path. Start with your existing GraphQL monolith registered as a single subgraph. Extract domains one by one:
- Run the monolith as subgraph
legacy - Build the
userssubgraph, move User types with@overrideto claim ownership - Build the
orderssubgraph, move Order types - Deprecate the
legacysubgraph
The @override directive tells the router to use the new subgraph for a field, falling back to the legacy subgraph. This enables incremental migration without a flag day.
Schema Governance
At scale, schema governance prevents the federated graph from becoming inconsistent. Establish conventions enforced in CI:
- All mutations must be namespaced:
usersCreateUser,ordersPlaceOrder(not justcreateUser) - Dates must use
DateTimescalar, notString - Pagination must follow Relay cursor spec
- All entities must have
@key(fields: "id")with a globally unique ID
A Rover custom check or a custom linter applied to every subgraph schema PR enforces these conventions automatically.
GraphQL Federation matures a microservice API strategy from "we expose many APIs" to "we expose one coherent API that happens to be implemented by many teams." The client experience becomes the first-class concern, and the service decomposition becomes an implementation detail.