GraphQL Federation: Scaling GraphQL Across Teams

August 17, 2020

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:

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 works best when teams have clear ownership boundaries. The schema becomes a contract between teams, enforced by the composition process.