I’ve been working with GraphQL for years, and recently I hit a wall with monolithic APIs. As our team scaled, coordinating changes across different domains became a nightmare. That’s when I discovered GraphQL Federation, and it completely transformed how we build APIs. Today, I want to share my experience building a federated gateway with Apollo Server and type-safe schema stitching.
Have you ever struggled with coordinating API changes across multiple teams? Federation solves this by letting each team own their domain while composing everything into a single API.
GraphQL Federation allows multiple GraphQL services to work together as one unified API. Each service manages its own schema and data, but the gateway combines them seamlessly. This approach eliminates the tight coupling found in traditional monolithic GraphQL servers.
Here’s how it differs from schema stitching:
// Traditional approach - everything in one service
type User {
id: ID!
email: String!
orders: [Order!]!
reviews: [Review!]!
}
// Federation approach - distributed ownership
type User @key(fields: "id") {
id: ID!
email: String!
}
// Other services extend the User type
extend type User @key(fields: "id") {
orders: [Order!]! # Managed by order service
reviews: [Review!]! # Managed by review service
}
Setting up the project structure is straightforward. I prefer using a monorepo with separate packages for each service. This keeps things organized and makes development smoother.
// Root package.json
{
"name": "federation-project",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "concurrently \"npm run dev --workspace=user-service\" ...",
"build": "npm run build --workspaces"
}
}
Why do we need separate services? Imagine your user service crashing shouldn’t take down product searches. Federation provides this isolation while maintaining a unified interface.
Let’s create a user service. Notice how it defines the base User type and marks it with @key for federation:
const typeDefs = `
type User @key(fields: "id") {
id: ID!
email: String!
firstName: String!
lastName: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
`;
const resolvers = {
User: {
__resolveReference: async (user, { userLoader }) => {
return await userLoader.load(user.id);
}
}
};
The __resolveReference method is crucial here. It tells the gateway how to fetch a User when another service references it. This is where DataLoader comes in handy for batching requests.
Now, how do we connect services? The gateway acts as the entry point. It collects schemas from all services and creates a unified API.
import { ApolloGateway } from '@apollo/gateway';
import { ApolloServer } from 'apollo-server';
const gateway = new ApolloGateway({
serviceList: [
{ name: 'users', url: 'http://localhost:4001/graphql' },
{ name: 'products', url: 'http://localhost:4002/graphql' },
{ name: 'orders', url: 'http://localhost:4003/graphql' }
]
});
const server = new ApolloServer({ gateway });
server.listen(4000);
What happens when you need user data in the orders service? You extend the User type and add order-specific fields:
// In orders service
const typeDefs = `
extend type User @key(fields: "id") {
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
}
`;
const resolvers = {
User: {
orders: async (user) => {
return await OrderRepository.findByUserId(user.id);
}
}
};
Authentication and authorization require careful planning. I implement a shared auth context that passes through the gateway to all services.
// Gateway context
const server = new ApolloServer({
gateway,
context: ({ req }) => {
const token = req.headers.authorization;
const user = verifyToken(token);
return { user };
}
});
// Service context
const server = new ApolloServer({
schema,
context: ({ req }) => {
return { userId: req.user?.id };
}
});
Performance optimization is where federation shines. The gateway’s query planner automatically batches requests and minimizes round trips. But you can optimize further with DataLoader in your resolvers.
Have you considered how caching works across services? Each service can implement its own caching strategy while the gateway handles query distribution.
Testing federated services requires a different approach. I recommend testing each service independently and then running integration tests against the complete gateway.
Deployment can be challenging initially. I use Docker to containerize each service and deploy them separately. Monitoring becomes crucial here—I set up distributed tracing to track requests across services.
Common pitfalls include circular dependencies between services and inconsistent error handling. Always validate your federated schema during CI/CD to catch issues early.
Type safety with TypeScript is a game-changer. I generate types from each service’s schema and use them throughout the resolvers. This catches errors at compile time rather than runtime.
// Generated types
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
}
// Type-safe resolver
const userResolver: Resolver<User> = (parent, args, context) => {
// TypeScript knows the shape of User
return context.userLoader.load(parent.id);
};
One thing I’ve learned: start simple. Don’t over-engineer your first federation setup. Begin with two services and gradually add more as you understand the patterns.
Remember, the goal isn’t just technical—it’s about enabling teams to work independently while delivering a cohesive experience. Federation empowers teams to move faster without stepping on each other’s toes.
What challenges have you faced with microservices? I’d love to hear your experiences in the comments below. If this guide helped you, please share it with your team and colleagues—spreading knowledge helps everyone build better systems. Your feedback and questions are always welcome!