Federation - When One Graph Isn't Enough (A Tale of Microservices)

You've heard the promise: "One graph to rule them all." But your organization has 47 microservices, 12 teams, and zero patience for a monolithic schema. Enter Federation - GraphQL's answer to "it's complicated."
The Monolith Problem
Picture this: You're at BigCorp Inc. Your GraphQL API started beautifully:
type Query {
user(id: ID!): User
product(id: ID!): Product
order(id: ID!): Order
}
Three types. One team. Life was good.
Fast-forward two years:
type Query {
# User team
user(id: ID!): User
users(filter: UserFilter): [User!]!
currentUser: User
# Product team
product(id: ID!): Product
products(filter: ProductFilter): [Product!]!
productsByCategory(category: String!): [Product!]!
searchProducts(query: String!): [Product!]!
# Orders team
order(id: ID!): Order
orders(userId: ID!): [Order!]!
ordersByStatus(status: OrderStatus!): [Order!]!
# Inventory team
inventory(productId: ID!): Inventory
lowStockProducts: [Product!]!
# Reviews team
reviews(productId: ID!): [Review!]!
userReviews(userId: ID!): [Review!]!
# ... 50 more query fields
}
The schema file is 3,000 lines. Merge conflicts happen daily. The "GraphQL team" has become a bottleneck. Teams are angry. Developers are leaving. The CTO wants answers.
The Federation Promise
Federation lets each team own their slice of the graph:
┌─────────────────────────────────────────────────────────────────────┐
│ FEDERATED ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ Gateway │ │
│ │ (Router) │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────────────────────┼──────────────────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ Users │ │Products │ │ Orders │ │
│ │ Service │ │ Service │ │ Service │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ Team A Team B Team C │
│ │
│ Each service: │
│ - Owns its types │
│ - Extends shared types │
│ - Deploys independently │
│ │
└─────────────────────────────────────────────────────────────────────┘
Federation Concepts
Concept 1: Entity Types
An entity is a type that can be referenced across services. It has a key:
# Users Service
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
The @key directive says: "This type can be looked up by id from any service."
Concept 2: Extending Types
Other services can extend entities:
# Orders Service
extend type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}
type Order @key(fields: "id") {
id: ID!
user: User!
total: Money!
}
The Orders Service doesn't know how to fetch a user's name - but it can add orders to the User type.
Concept 3: The Gateway
A gateway (Apollo Router, Netflix DGS, etc.) stitches services together:
# Client's view - seamless!
query {
user(id: "123") {
name # From Users Service
email # From Users Service
orders { # From Orders Service
total
}
reviews { # From Reviews Service
rating
}
}
}
The client sees one graph. Under the hood, three services collaborate.
Implementing Federation with Spring
Setup: The Users Subgraph
<!-- pom.xml -->
<dependency>
<groupId>com.apollographql.federation</groupId>
<artifactId>federation-spring-graphql-starter</artifactId>
<version>4.0.0</version>
</dependency>
# src/main/resources/graphql/schema.graphqls
type Query {
user(id: ID!): User
users: [User!]!
}
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
createdAt: DateTime!
}
@Controller
public class UserController {
private final UserRepository userRepository;
@QueryMapping
public User user(@Argument String id) {
return userRepository.findById(id).orElse(null);
}
@QueryMapping
public List<User> users() {
return userRepository.findAll();
}
}
// Entity resolver - for federation lookups
@Controller
public class UserEntityController {
private final UserRepository userRepository;
@EntityMapping
public User user(@Argument("id") String id) {
return userRepository.findById(id).orElse(null);
}
}
Setup: The Orders Subgraph
# schema.graphqls
type Query {
order(id: ID!): Order
ordersByUser(userId: ID!): [Order!]!
}
type Order @key(fields: "id") {
id: ID!
userId: ID!
items: [OrderItem!]!
total: Money!
status: OrderStatus!
createdAt: DateTime!
}
# Extend User from Users Service
extend type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}
type OrderItem {
productId: ID!
quantity: Int!
price: Money!
}
type Money {
amount: Float!
currency: String!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
}
@Controller
public class OrderController {
private final OrderRepository orderRepository;
@QueryMapping
public Order order(@Argument String id) {
return orderRepository.findById(id).orElse(null);
}
@QueryMapping
public List<Order> ordersByUser(@Argument String userId) {
return orderRepository.findByUserId(userId);
}
// Resolve User.orders
@SchemaMapping(typeName = "User", field = "orders")
public List<Order> ordersForUser(User user) {
return orderRepository.findByUserId(user.getId());
}
}
Setup: The Gateway
Using Apollo Router (Rust-based, high performance):
# router.yaml
supergraph:
introspection: true
listen: 0.0.0.0:4000
subgraphs:
users:
routing_url: http://users-service:8080/graphql
products:
routing_url: http://products-service:8080/graphql
orders:
routing_url: http://orders-service:8080/graphql
reviews:
routing_url: http://reviews-service:8080/graphql
headers:
all:
request:
- propagate:
named: authorization
The Query Execution Dance
Let's trace a federated query:
query GetUserWithOrders {
user(id: "123") {
name
email
orders {
id
total
items {
productId
}
}
}
}
┌─────────────────────────────────────────────────────────────────────┐
│ QUERY EXECUTION FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Client sends query to Gateway │
│ │
│ 2. Gateway creates query plan: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Fetch(Users) { │ │
│ │ user(id: "123") { id name email } │ │
│ │ } │ │
│ │ └─▶ Flatten(Orders) { │ │
│ │ _entities(representations: [...]) { │ │
│ │ ... on User { orders { id total items } } │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 3. Gateway executes plan: │
│ a) Call Users Service: GET user, get {id, name, email} │
│ b) Call Orders Service: _entities query with user ID │
│ c) Merge responses │
│ │
│ 4. Return merged response to client │
│ │
└─────────────────────────────────────────────────────────────────────┘
Federation Patterns
Pattern 1: Shared Types
Types that multiple services need to understand:
# Shared schema (published to all services)
type Money @shareable {
amount: Float!
currency: Currency!
}
enum Currency {
USD
EUR
GBP
}
Each service can use Money without owning it.
Pattern 2: Computed Fields
Add computed fields to entities you don't own:
# Analytics Service
extend type User @key(fields: "id") {
id: ID! @external
# Computed field
engagementScore: Float! @requires(fields: "id")
}
extend type Product @key(fields: "id") {
id: ID! @external
# Computed from analytics data
popularityRank: Int!
conversionRate: Float!
}
Pattern 3: The Reference Resolver
When one service needs minimal data from another:
# Orders Service needs product names for display
type OrderItem {
quantity: Int!
product: Product! # Just a reference
}
extend type Product @key(fields: "id") {
id: ID! @external
name: String! @external # Will be resolved by Products Service
}
The Orders Service stores productId. Federation fetches name from Products Service.
Pattern 4: Override
When you need to change a field's resolver:
# Products Service defines base inventory
type Product @key(fields: "id") {
id: ID!
name: String!
inventory: Int! # Basic count
}
# Inventory Service has real-time data
extend type Product @key(fields: "id") {
id: ID! @external
inventory: Int! @override(from: "products") # Takes over!
}
Federation Challenges
Challenge 1: N+1 in Disguise
query {
users { # Users Service: 1 call
name
orders { # Orders Service: N calls? Or batched?
total
}
}
}
Solution: The gateway batches entity lookups:
# Gateway calls Orders Service ONCE with all user IDs
_entities(representations: [
{ __typename: "User", id: "1" },
{ __typename: "User", id: "2" },
{ __typename: "User", id: "3" }
]) {
... on User { orders { total } }
}
Make sure your service handles batches efficiently:
@EntityMapping
public List<User> users(@Argument List<Map<String, Object>> representations) {
List<String> ids = representations.stream()
.map(rep -> (String) rep.get("id"))
.toList();
Map<String, User> usersById = userRepository.findAllById(ids).stream()
.collect(Collectors.toMap(User::getId, u -> u));
return ids.stream()
.map(usersById::get)
.toList();
}
Challenge 2: Circular Dependencies
# Users Service
type User @key(fields: "id") {
id: ID!
favoriteProduct: Product # Depends on Products
}
# Products Service
type Product @key(fields: "id") {
id: ID!
topReviewer: User # Depends on Users
}
This creates a circular dependency. Solutions:
- Accept it - Federation handles cycles
- Break the cycle - Move one field to a third service
- Use interfaces - Abstract the dependency
Challenge 3: Schema Coordination
Who decides what User looks like when 5 services extend it?
Solution: Schema governance
# .github/workflows/schema-check.yml
name: Schema Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Rover Schema Check
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
run: |
rover subgraph check my-graph@production \
--schema ./schema.graphqls \
--name users
Migration Strategy
Phase 1: Identify Boundaries
┌─────────────────────────────────────────────────────────────────────┐
│ SERVICE BOUNDARY ANALYSIS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Current Monolith → Potential Services │
│ ──────────────────────────────────────────────────────────────── │
│ User types → Users Service (Team A) │
│ Product types → Products Service (Team B) │
│ Order types → Orders Service (Team C) │
│ Review types → Reviews Service (Team D) │
│ Payment types → Payments Service (Team E) │
│ │
│ Shared types: │
│ - Money → Shared library / copied │
│ - Address → Shared library / copied │
│ │
└─────────────────────────────────────────────────────────────────────┘
Phase 2: Gateway Introduction
Run federation alongside your monolith:
Client → Gateway → Monolith (all queries)
└─────→ New Users Service (shadowed)
Compare responses. Ensure compatibility.
Phase 3: Traffic Shifting
Week 1: Gateway → 100% Monolith
Week 2: Gateway → 90% Monolith, 10% Users Service
Week 3: Gateway → 50% Monolith, 50% Users Service
Week 4: Gateway → 0% Monolith (for users), 100% Users Service
Phase 4: Extract More Services
Repeat for Products, Orders, etc.
When Not to Federate
Federation isn't free. Skip it if:
❌ Small team - Coordination overhead > benefits ❌ Simple schema - < 50 types, why complicate? ❌ Same codebase - Services share a repo anyway ❌ Sync-heavy - Types change together frequently
Use federation when:
✅ Multiple teams - Need independent deployment ✅ Large schema - 100+ types, growing ✅ Different tech stacks - Java, Node, Python services ✅ Different data stores - Each service owns its data
Conclusion
Federation is GraphQL's answer to microservices. It lets teams own their domains while presenting clients with a unified graph.
But it's not magic. It adds complexity: gateways, entity resolvers, schema coordination, debugging across services.
The question isn't "Is federation good?" It's "Do we have the problems federation solves?"
If the answer is yes - if you're fighting merge conflicts daily, if teams are blocked on the GraphQL team, if your schema has become unwieldy - federation is your path to sanity.
If not, enjoy your monolith. There's nothing wrong with a well-designed single service.
This blog post was written by a team of microservices, federated through one author's brain. Query plan available upon request.