As GraphQL adoption grows, so do the challenges of maintaining a single schema across multiple teams. GraphQL Federation solves this by allowing teams to own parts of the schema while presenting a unified API to clients.
Here’s how to implement GraphQL Federation effectively.
The Problem Federation Solves
Monolithic GraphQL
Traditional approach: one schema, one team:
# monolith/schema.graphql
type User {
id: ID!
email: String!
orders: [Order!]! # Users team doesn't own orders
reviews: [Review!]! # Users team doesn't own reviews
}
type Order {
id: ID!
user: User! # Orders team doesn't own users
items: [Item!]!
}
type Review {
id: ID!
user: User! # Reviews team doesn't own users
product: Product!
}
Problems:
- Single team bottleneck
- Schema changes require coordination
- Hard to scale development
- Difficult to deploy independently
Federated GraphQL
Multiple subgraphs, one unified schema:
┌─────────────────────────────────────────────────────────────┐
│ Gateway │
│ (Apollo Router) │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Users │ │ Orders │ │ Reviews │
│ Subgraph │ │ Subgraph │ │ Subgraph │
└──────────────┘ └──────────────┘ └──────────────┘
Federation Concepts
Entities
Types that span multiple subgraphs:
# users-subgraph/schema.graphql
type User @key(fields: "id") {
id: ID!
email: String!
name: String!
}
# orders-subgraph/schema.graphql
type User @key(fields: "id") {
id: ID!
orders: [Order!]! # Extends User with orders
}
type Order @key(fields: "id") {
id: ID!
total: Float!
user: User!
}
The @key directive identifies how to resolve entities across subgraphs.
Reference Resolvers
Each subgraph resolves entity references:
// users-subgraph/resolvers.js
const resolvers = {
User: {
__resolveReference(user, context) {
// Called by gateway to resolve User entity
return context.dataSources.users.findById(user.id);
}
}
};
Extending Types
Add fields to types owned by other subgraphs:
# reviews-subgraph/schema.graphql
# Extend the User type (owned by users-subgraph)
extend type User @key(fields: "id") {
id: ID! @external
reviews: [Review!]!
}
# Extend the Product type (owned by products-subgraph)
extend type Product @key(fields: "id") {
id: ID! @external
reviews: [Review!]!
}
type Review @key(fields: "id") {
id: ID!
text: String!
rating: Int!
author: User!
product: Product!
}
Implementation
Subgraph Setup (Apollo Server)
// users-subgraph/index.js
const { ApolloServer } = require('@apollo/server');
const { buildSubgraphSchema } = require('@apollo/subgraph');
const { gql } = require('graphql-tag');
const typeDefs = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0",
import: ["@key", "@shareable"])
type Query {
user(id: ID!): User
me: User
}
type User @key(fields: "id") {
id: ID!
email: String!
name: String!
}
`;
const resolvers = {
Query: {
user: (_, { id }, { dataSources }) => dataSources.users.findById(id),
me: (_, __, { user }) => user,
},
User: {
__resolveReference: (user, { dataSources }) =>
dataSources.users.findById(user.id),
},
};
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
Gateway Setup (Apollo Router)
# router.yaml
supergraph:
listen: 0.0.0.0:4000
subgraphs:
users:
routing_url: http://users-service:4001/graphql
orders:
routing_url: http://orders-service:4002/graphql
reviews:
routing_url: http://reviews-service:4003/graphql
Schema Composition
# Compose supergraph from subgraphs
rover supergraph compose --config supergraph.yaml > supergraph.graphql
# supergraph.yaml
subgraphs:
users:
schema:
subgraph_url: http://users-service:4001/graphql
orders:
schema:
subgraph_url: http://orders-service:4002/graphql
Query Execution
Query Planning
Client query:
query GetUserOrders($userId: ID!) {
user(id: $userId) {
id
name # From users subgraph
email # From users subgraph
orders { # From orders subgraph
id
total
items { # From orders subgraph
name
price
}
}
reviews { # From reviews subgraph
rating
text
}
}
}
Gateway execution plan:
1. Fetch user from users-subgraph
user(id: $userId) { id, name, email }
2. Parallel fetch from other subgraphs:
a. orders-subgraph: User(id) { orders { ... } }
b. reviews-subgraph: User(id) { reviews { ... } }
3. Merge results and return
Avoiding N+1 Problems
Use DataLoader pattern in reference resolvers:
// orders-subgraph/resolvers.js
const DataLoader = require('dataloader');
const resolvers = {
User: {
orders: async (user, _, { loaders }) => {
return loaders.ordersByUserId.load(user.id);
}
}
};
// Create loader per request
const createLoaders = (dataSources) => ({
ordersByUserId: new DataLoader(async (userIds) => {
const orders = await dataSources.orders.findByUserIds(userIds);
// Group by user ID
const ordersByUser = groupBy(orders, 'userId');
return userIds.map(id => ordersByUser[id] || []);
})
});
Schema Design
Key Selection
Choose natural, stable identifiers:
# Good: stable business identifier
type User @key(fields: "id") {
id: ID! # UUID
}
# Good: composite key when needed
type CartItem @key(fields: "cartId productId") {
cartId: ID!
productId: ID!
quantity: Int!
}
# Avoid: non-unique or changing fields
type User @key(fields: "email") { # Email can change!
email: String!
}
Shareable Fields
Fields that multiple subgraphs can resolve:
# Both subgraphs can resolve the same field
type Product @key(fields: "id") {
id: ID!
name: String! @shareable
}
Computed Fields
Fields calculated from other fields:
# orders-subgraph
type Order @key(fields: "id") {
id: ID!
subtotal: Float!
tax: Float!
total: Float! # Computed from subtotal + tax
}
# Resolver
Order: {
total: (order) => order.subtotal + order.tax
}
Interface Types
Shared across subgraphs:
# Shared interface in all subgraphs
interface Node {
id: ID!
}
type User implements Node @key(fields: "id") {
id: ID!
# ...
}
type Order implements Node @key(fields: "id") {
id: ID!
# ...
}
Operational Concerns
Schema Registry
Track schema versions and changes:
# Publish subgraph schema
rover subgraph publish my-graph@production \
--name users \
--schema ./users-subgraph/schema.graphql \
--routing-url http://users-service/graphql
# Check for breaking changes
rover subgraph check my-graph@production \
--name users \
--schema ./users-subgraph/schema.graphql
Gradual Migration
Migrate from monolith incrementally:
Phase 1: Users subgraph (extract User type)
Phase 2: Orders subgraph (extract Order, extend User)
Phase 3: Reviews subgraph (extract Review, extend User/Product)
Phase 4: Retire monolith
Monitoring
Track per-subgraph metrics:
// Custom plugin for metrics
const metricsPlugin = {
async requestDidStart() {
return {
async willSendResponse(requestContext) {
const { metrics } = requestContext;
// Track query plan, subgraph latencies
recordMetrics({
operation: requestContext.operationName,
subgraphLatencies: metrics.subgraphLatencies,
totalDuration: metrics.totalDuration,
});
}
};
}
};
Error Handling
Handle partial failures gracefully:
# Query with nullable extension fields
query GetUserSafe($userId: ID!) {
user(id: $userId) {
id
name
orders { # If orders-subgraph fails, still return user
id
}
}
}
Key Takeaways
- Federation enables team autonomy while maintaining a unified API
- Entities with
@keydirective are the foundation; they span subgraphs - Reference resolvers handle entity resolution across subgraph boundaries
- Use DataLoader to avoid N+1 query problems in resolvers
- Choose stable identifiers for keys; avoid fields that can change
- Schema registry catches breaking changes before deployment
- Migrate incrementally from monolith to federation
- Monitor per-subgraph latency and errors
- Design for partial failure; extension fields can fail independently
Federation works best when teams have clear ownership boundaries. The schema becomes a contract between teams, enforced by the composition process.