Skip to main content

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

· 10 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:

info

Each service owns its types, extends shared types, and 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 add fields to entities owned elsewhere. In Federation 2 (the default since 2022), you redeclare the type with @key and add the new field; you don't need extend type or @external on the key in the owning-by-reference subgraph. The examples below use Fed 2 syntax. (If you're on Fed 1, the same examples need extend type ... { id: ID! @external; ... } instead - migrate to Fed 2 if you can.)

# Orders Service (Federation 2)
type User @key(fields: "id") {
id: ID!
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 or router (Apollo Router, Cosmo Router, GraphQL Mesh, Hive Gateway, etc.) stitches services together:

# Client's view - seamless!
query UserWithOrdersAndReviews {
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-graphql-java-support</artifactId>
<!-- Use the latest release; check Maven Central -->
<version>6.1.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. Spring for GraphQL has
// a dedicated @EntityMapping annotation for exactly this (backed by
// federation-graphql-java-support; you also declare a
// FederationSchemaFactory bean). The method name matches the entity
// type, and the @key fields arrive as arguments:
@Controller
public class UserEntityController {

private final UserRepository userRepository;

// Resolves the User entity when another subgraph references it
// by its @key fields.
@EntityMapping
public User user(@Argument 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!
}

# Add `orders` to User (owned by Users Service); Fed 2 syntax
type User @key(fields: "id") {
id: ID!
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). Note that subgraph routing URLs live in supergraph.yaml (consumed by rover supergraph compose), not in router.yaml. Routing URLs are baked into the composed supergraph schema. The subgraphs: key in router.yaml is only for per-subgraph runtime overrides like headers and timeouts.

# supergraph.yaml - lists subgraphs and their routing URLs
federation_version: 2
subgraphs:
users:
routing_url: http://users-service:8080/graphql
schema:
subgraph_url: http://users-service:8080/graphql
products:
routing_url: http://products-service:8080/graphql
schema:
subgraph_url: http://products-service:8080/graphql
orders:
routing_url: http://orders-service:8080/graphql
schema:
subgraph_url: http://orders-service:8080/graphql
# router.yaml - runtime configuration
supergraph:
introspection: true
listen: 0.0.0.0:4000

headers:
all:
request:
- propagate:
named: authorization

You compose with rover supergraph compose --config supergraph.yaml > supergraph.graphql, then run the router with --supergraph supergraph.graphql --config router.yaml.

The Query Execution Dance​

Let's trace a federated query:

query UserWithOrders {
user(id: "123") {
name
email
orders {
id
total
items {
productId
}
}
}
}

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. @requires declares non-key fields the resolver needs from another subgraph; those fields must be marked @external here. Fed 2 doesn't need extend type; the type is just redeclared with @key.

# Analytics Service (Federation 2)
type User @key(fields: "id") {
id: ID!
signupDate: DateTime! @external
# Score is computed using signupDate fetched from the Users subgraph
engagementScore: Float! @requires(fields: "signupDate")
}

type Product @key(fields: "id") {
id: ID!
# Pure analytics-computed fields; no @requires because they're
# derived from this subgraph's own analytics data.
popularityRank: Int!
conversionRate: Float!
}

Pattern 3: The Reference Resolver​

When one service needs minimal data from another, you reference the entity by its key. You don't redeclare fields you're not extending or requiring - the gateway handles cross-subgraph field resolution automatically:

# Orders Service - just reference Product by its @key, no @external needed
type OrderItem {
quantity: Int!
product: Product! # Reference; Product fields are resolved by their owning subgraph
}

type Product @key(fields: "id") {
id: ID!
}

The Orders Service stores productId. The router calls Products Service's entity resolver for any Product fields the client requests.

Pattern 4: Override​

When you need to migrate a field's resolver from one subgraph to another. The original subgraph must mark the field @shareable (or be migrated atomically), and the new subgraph claims ownership with @override:

# Products Service (original owner)
type Product @key(fields: "id") {
id: ID!
name: String!
inventory: Int! @shareable # Allow another subgraph to define this too
}

# Inventory Service - takes over inventory at compose time
type Product @key(fields: "id") {
id: ID!
inventory: Int! @override(from: "products")
}

Federation Challenges​

Challenge 1: N+1 in Disguise​

query UsersWithOrders {
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. The actual wiring depends on which library you use - federation-graphql-java-support exposes a batched entity-fetcher hook; the example below shows the conceptual shape instead of a specific annotation:

// Batched entity resolver - adapt to your federation library's API
public List<User> resolveUsers(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​

Current MonolithPotential Service
User typesUsers Service (Team A)
Product typesProducts Service (Team B)
Order typesOrders Service (Team C)
Review typesReviews Service (Team D)
Payment typesPayments Service (Team E)
Money (shared)Shared library / copied
Address (shared)Shared library / copied

Phase 2: Gateway Introduction​

Run federation alongside your monolith:

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.