Skip to main content

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

· 8 min read
GraphQL Guy

GraphQL Federation

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:

  1. Accept it - Federation handles cycles
  2. Break the cycle - Move one field to a third service
  3. 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.