- Published on
GraphQL Federation — Composing a Unified API From Multiple Microservices
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
GraphQL Federation decouples your API layer from service implementations. Instead of one monolithic schema, each microservice publishes its own subgraph, and a router composes them into a unified API. This enables independent scaling, deployment, and ownership. This post covers subgraph setup with Apollo Federation, @key directives for entity reference, entity resolution, router configuration, avoiding N+1 queries with DataLoader, query planning, cost limits, and distributed tracing.
- Apollo Federation Subgraph Setup
- Federation Directives: @key and @external
- Entity Resolution and Query Planning
- Avoiding N+1 Queries with DataLoader
- Query Planning and Cost Limits
- Router Configuration and Federation
- Performance: Observability and Tracing
- Federation vs Schema Stitching
- Federation Checklist
- Conclusion
Apollo Federation Subgraph Setup
Each microservice defines its own schema and publishes a subgraph to the Apollo Router.
// services/posts/schema.ts
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql, ApolloServer } from 'apollo-server-express';
// Define Post subgraph schema
const typeDefs = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0")
# Reference User from users service
type User @key(fields: "id") @external {
id: ID!
name: String @external
}
# Post entity with @key for federation
type Post @key(fields: "id") {
id: ID!
title: String!
content: String!
published: Boolean!
createdAt: DateTime!
# Field that requires User entity resolution
author: User!
tags: [String!]!
viewCount: Int!
}
# Root query
type Query {
post(id: ID!): Post
posts(limit: Int = 20, cursor: String): [Post!]!
# _entities query for federation
_entities(representations: [_Any!]!): [_Entity]!
}
# Mutation
type Mutation {
createPost(input: CreatePostInput!): Post!
publishPost(id: ID!): Post!
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
tags: [String!]
}
# For federation reference resolution
union _Entity = Post | User
scalar DateTime
`;
// Entity resolvers - resolve references from other services
const resolvers = {
Post: {
// Reference resolver: given a Post with just id, resolve full Post
__resolveReference: async (reference: any, context: any) => {
const post = await context.db.post.findById(reference.id);
if (!post) {
throw new Error(`Post ${reference.id} not found`);
}
return post;
},
// Field resolver for author relation
author: async (post: any, _args: any, context: any) => {
return context.userService.getUser(post.authorId);
},
},
User: {
// External field - resolved by users service
__resolveReference: async (reference: any) => {
// This is handled by users service, we just return the reference
return reference;
},
},
Query: {
post: async (_parent: any, args: any, context: any) => {
return context.db.post.findById(args.id);
},
posts: async (_parent: any, args: any, context: any) => {
return context.db.post.findMany({
limit: args.limit,
cursor: args.cursor,
});
},
_entities: async (_parent: any, args: any, context: any) => {
return args.representations.map((rep: any) => {
if (rep.__typename === 'Post') {
return context.db.post.findById(rep.id);
}
return null;
});
},
},
Mutation: {
createPost: async (_parent: any, args: any, context: any) => {
const post = await context.db.post.create({
title: args.input.title,
content: args.input.content,
authorId: args.input.authorId,
tags: args.input.tags,
});
return post;
},
publishPost: async (_parent: any, args: any, context: any) => {
return context.db.post.update(args.id, { published: true });
},
},
};
// Build subgraph schema
const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
// Create Apollo Server for subgraph
const server = new ApolloServer({
schema,
plugins: {
didResolveOperation(requestContext) {
console.log(`Resolved operation: ${requestContext.operationName}`);
},
},
});
export default server;
Federation Directives: @key and @external
Use @key to identify entities and @external to reference fields from other services.
# posts/schema.graphql
# Identify Post entity by id
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}
# userId is external - defined and managed by users service
type Post @key(fields: "id") {
id: ID!
# ... other fields
authorId: ID! # External field from users service
}
# Composite keys for complex entities
type OrderLine @key(fields: "orderId lineNumber") {
orderId: ID!
lineNumber: Int!
product: Product!
quantity: Int!
}
# Reference User from another service
type User @key(fields: "id") @external {
id: ID!
name: String @external # Field we don't own
email: String @external
}
# Extend User with additional fields from posts service
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]! # Field defined in posts service
postCount: Int!
}
# Fragment fields: resolve one entity in terms of another
extend type Post {
author: User! @requires(fields: "authorId")
}
# Provides: this service calculates a field that user service needs
type User {
id: ID!
averageRating: Float @provides(fields: "id")
}
Implement entity reference resolution:
// services/users/resolvers.ts
const resolvers = {
User: {
// Required: resolve references from other services
__resolveReference: async (reference: any, context: any) => {
// Called when another service references User with { __typename: 'User', id: '123' }
return context.db.user.findById(reference.id);
},
// Calculate derived fields
averageRating: async (user: any, _args: any, context: any) => {
const posts = await context.db.post.findByAuthor(user.id);
const ratings = posts.map((p: any) => p.averageRating);
return ratings.reduce((a, b) => a + b, 0) / ratings.length || 0;
},
},
Query: {
user: async (_parent: any, args: any, context: any) => {
return context.db.user.findById(args.id);
},
// Return references for entity batch loading
_entities: async (_parent: any, args: any, context: any) => {
return args.representations.map((rep: any) => {
if (rep.__typename === 'User') {
// Return reference which triggers __resolveReference
return rep;
}
return null;
});
},
},
};
Entity Resolution and Query Planning
The router plans queries across subgraphs, resolving entities efficiently.
// apollo-router configuration
// router.yaml
supergraph:
listen: 127.0.0.1:4000
plugins:
experimental.acl:
enabled: true
# Entity cache settings
entity_cache:
in_memory:
limit: 10000
# Log subgraph requests
apollo:
logging:
level: info
# Query planning
query_planning:
# Cache query plans
cache:
redis:
urls:
- "redis://localhost:6379"
# Subgraph configuration
subgraphs:
users:
routing_url: http://users-service:4001/graphql
# Define how to batch entity requests
batch:
enabled: true
size: 100
posts:
routing_url: http://posts-service:4002/graphql
batch:
enabled: true
size: 100
comments:
routing_url: http://comments-service:4003/graphql
batch:
enabled: true
size: 100
Avoiding N+1 Queries with DataLoader
Batch requests to resolve entities efficiently.
import DataLoader from 'dataloader';
// Create DataLoaders for each entity type
class DataLoaders {
userLoader: DataLoader<string, User>;
postLoader: DataLoader<string, Post>;
commentLoader: DataLoader<string, Comment>;
constructor(db: Database) {
// Users loader: batches IDs, returns users in order
this.userLoader = new DataLoader(
async (ids: readonly string[]) => {
const users = await db.user.findByIds(Array.from(ids));
// Return in same order as requested
return ids.map(id => users.find(u => u.id === id));
},
{
cache: true,
batchScheduleFn: (callback) => {
// Batch within single event loop tick
process.nextTick(callback);
},
}
);
// Posts loader
this.postLoader = new DataLoader(
async (ids: readonly string[]) => {
const posts = await db.post.findByIds(Array.from(ids));
return ids.map(id => posts.find(p => p.id === id));
}
);
// Comments loader
this.commentLoader = new DataLoader(
async (ids: readonly string[]) => {
const comments = await db.comment.findByIds(Array.from(ids));
return ids.map(id => comments.find(c => c.id === id));
}
);
}
clearAll() {
this.userLoader.clearAll();
this.postLoader.clearAll();
this.commentLoader.clearAll();
}
}
// Use in resolvers to avoid N+1
const resolvers = {
Post: {
author: async (post: any, _args: any, context: any) => {
// Instead of direct query, use loader
return context.loaders.userLoader.load(post.authorId);
},
},
User: {
posts: async (user: any, _args: any, context: any) => {
// Batch load all posts for all queried users
const posts = await context.db.post.findByAuthor(user.id);
return posts;
},
},
Comment: {
author: (comment: any, _args: any, context: any) => {
return context.loaders.userLoader.load(comment.authorId);
},
post: (comment: any, _args: any, context: any) => {
return context.loaders.postLoader.load(comment.postId);
},
},
Query: {
user: (_parent: any, args: any, context: any) => {
return context.loaders.userLoader.load(args.id);
},
},
};
// Create loaders for each request
const server = new ApolloServer({
schema,
context: async ({ req }) => ({
db,
loaders: new DataLoaders(db),
userId: req.headers['x-user-id'],
}),
plugins: {
didResolveOperation: async (context) => {
context.context.loaders.clearAll();
},
},
});
Query Planning and Cost Limits
Prevent expensive queries from overwhelming services.
// Query complexity analysis
import {
GraphQLSchema,
GraphQLField,
isObjectType,
isInterfaceType,
isUnionType,
} from 'graphql';
class QueryAnalyzer {
private costMap = new Map<string, number>([
['Query.user', 1],
['Query.users', 5], // List queries cost more
['Post.author', 2], // Cross-service reference
['User.posts', 5], // Expensive relation
['Post.comments', 3],
]);
analyze(document: any, maxCost: number = 1000): number {
let totalCost = 0;
const visit = (node: any, depth: number = 0) => {
if (node.kind === 'Field') {
const fieldName = `${node.parentType}.${node.name.value}`;
const cost = this.costMap.get(fieldName) || 1;
// Exponential cost for nested queries
const nestedCost = cost * Math.pow(1.5, depth);
totalCost += nestedCost;
if (node.arguments) {
node.arguments.forEach((arg: any) => {
if (arg.name.value === 'limit' || arg.name.value === 'first') {
const limit = arg.value.value;
totalCost *= Math.min(limit, 100) / 10; // Cap multiplier
}
});
}
}
if (node.selectionSet) {
node.selectionSet.selections.forEach((selection: any) => {
visit(selection, depth + 1);
});
}
};
visit(document);
if (totalCost > maxCost) {
throw new Error(
`Query cost ${totalCost} exceeds maximum ${maxCost}`
);
}
return totalCost;
}
}
// Use in router
const server = new ApolloServer({
schema,
plugins: {
didResolveOperation: async (context) => {
const analyzer = new QueryAnalyzer();
const cost = analyzer.analyze(context.document, 1000);
console.log(`Query cost: ${cost}`);
},
},
});
Router Configuration and Federation
Configure Apollo Router to compose subgraphs.
// main.ts - Setup router
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';
import { ApolloServer } from 'apollo-server-express';
import express from 'express';
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://users-service:4001/graphql' },
{ name: 'posts', url: 'http://posts-service:4002/graphql' },
{ name: 'comments', url: 'http://comments-service:4003/graphql' },
],
pollIntervalInMs: 10000, // Refresh schema every 10s
}),
buildService: (subgraph) => {
// Add auth headers to subgraph requests
return new DataSourceAPI({
url: subgraph.url,
willSendRequest: async ({ request, context }) => {
request.http?.headers.set(
'authorization',
context.req.headers.authorization || ''
);
},
});
},
// Enable query planning optimization
experimental_didResolveQueryPlan: (plan) => {
console.log('Query plan:', JSON.stringify(plan, null, 2));
},
});
const server = new ApolloServer({
gateway,
context: async ({ req }) => ({
userId: req.headers['x-user-id'],
}),
});
const app = express();
app.use(express.json());
server.start().then(() => {
server.applyMiddleware({ app });
app.listen(4000, () => {
console.log('Router listening on :4000');
});
});
Performance: Observability and Tracing
Trace queries across federated services.
// Distributed tracing with OpenTelemetry
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { JaegerExporter } from '@opentelemetry/exporter-trace-jaeger';
const sdk = new NodeSDK({
traceExporter: new JaegerExporter({
host: 'localhost',
port: 6831,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
// Track federation request timing
const server = new ApolloServer({
schema,
plugins: {
willSendSubgraphRequest: ({ request, subgraphKey }) => {
const tracer = context.tracer || require('@opentelemetry/api').trace.getTracer('apollo');
const span = tracer.startSpan(`subgraph.${subgraphKey}`);
request.http?.headers.set('traceparent', span.spanContext().traceId);
},
didResolveOperation: ({ operationName, document }) => {
console.log(`Operation: ${operationName}`);
console.log(`Fields: ${extractFieldNames(document)}`);
},
},
});
// Query metrics
const metrics = {
operationCount: 0,
errorCount: 0,
averageLatency: 0,
};
server.plugins?.push({
didResolveOperation: () => {
metrics.operationCount++;
},
didEncounterErrors: () => {
metrics.errorCount++;
},
});
Federation vs Schema Stitching
Compare federation approaches for composing schemas.
| Feature | Federation | Schema Stitching |
|---|---|---|
| Entity references | @key directive | Custom type merging |
| Batch entity resolution | Built-in | Manual |
| Schema composition | Compile-time or runtime | Runtime only |
| Subgraph discovery | Dynamic | Static |
| Performance | Optimized for federation | Can be slower |
| Developer experience | Better tooling | More manual work |
| Type sharing | Strong contracts | Weaker contracts |
Use Federation for production systems. Use Schema Stitching only for legacy systems difficult to migrate.
Federation Checklist
- Each service has a @key for entity identity
- Reference entities use @external for owned-elsewhere fields
- DataLoaders batch entity resolution to avoid N+1
- Router configured to introspect subgraph schemas
- Query cost analysis prevents expensive queries
- Authentication headers propagated to subgraphs
- Subgraph errors include proper error codes
- Query plans cached in Redis
- Distributed tracing enabled for cross-service visibility
- Subgraph schemas versioned, backward compatible
Conclusion
GraphQL Federation decouples API composition from service ownership. Each team manages their subgraph independently while the router stitches them into a unified schema. Use @key directives for entity references, DataLoader for efficient batch resolution, and query cost analysis to prevent denial of service. Monitor distributed traces to identify performance bottlenecks across services.