GraphQL Federation Gateway Tutorial: Build a Unified Supergraph from Independent Services

Build a production-ready GraphQL federation gateway: compose subgraphs, resolve entities, add auth, performance, and deployment patterns.

ASOasis
9 min read
GraphQL Federation Gateway Tutorial: Build a Unified Supergraph from Independent Services

Image used for representation purposes only.

Overview

GraphQL federation lets you compose multiple GraphQL services (subgraphs) into a single, unified API (the supergraph) that clients query through a gateway. This tutorial walks you end‑to‑end: designing federated subgraphs, wiring a gateway, adding entity resolution, propagating auth, and preparing for production.

You’ll build a simple marketplace composed of three subgraphs:

  • Products: catalog data
  • Users: profiles
  • Reviews: user reviews that link users and products

We’ll use a Node.js gateway for clarity, and note an alternative with a high‑performance router.

Prerequisites

  • Node.js 18+ and npm or Yarn
  • Basic familiarity with GraphQL schemas and resolvers
  • Three terminal windows (or a process manager) to run services concurrently

Folder layout (suggested):

federation-tutorial/
  gateway/
  subgraphs/
    products/
    users/
    reviews/

Federation concepts in 90 seconds

  • Subgraph: An independently owned GraphQL service with its own schema and resolvers.
  • Entity: A type that can be referenced across subgraphs; identified by one or more key fields.
  • Key: A directive that marks the unique identity of an entity (for example, @key(fields: “id”)).
  • Reference resolver: Code that fetches an entity instance in the subgraph that owns it.
  • Supergraph: The composed schema produced from all subgraphs.
  • Gateway/Router: The single endpoint that plans, validates, and executes client queries across subgraphs.

Step 1 — Bootstrap three subgraphs

Each subgraph is just a GraphQL server. Below are minimal Node.js services using Apollo Server and federation helpers.

Create subgraphs/products/index.js:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';

// In-memory data for the demo
const products = [
  { id: 'p1', name: 'Noise-Cancelling Headphones', price: 199 },
  { id: 'p2', name: 'Ergonomic Keyboard', price: 129 },
];

const typeDefs = gql`
  # Federation v2 directive imports are commonly handled automatically by tooling.
  # Many setups still work without an explicit @link, but you can add it if your tooling requires.
  type Product @key(fields: "id") {
    id: ID!
    name: String!
    price: Int
  }

  type Query {
    product(id: ID!): Product
    topProducts(first: Int = 5): [Product!]!
  }
`;

const resolvers = {
  Query: {
    product: (_, { id }) => products.find(p => p.id === id) || null,
    topProducts: (_, { first }) => products.slice(0, first),
  },
  Product: {
    // Required by federation to resolve references from other subgraphs
    __resolveReference: (ref) => products.find(p => p.id === ref.id) || null,
  },
};

const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) });
const { url } = await startStandaloneServer(server, { listen: { port: 4001 } });
console.log(`Products subgraph ready at ${url}`);

Create subgraphs/users/index.js:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';

const users = [
  { id: 'u1', username: 'ada' },
  { id: 'u2', username: 'grace' },
];

const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    username: String!
  }

  type Query {
    user(id: ID!): User
    me: User
  }
`;

const resolvers = {
  Query: {
    user: (_, { id }) => users.find(u => u.id === id) || null,
    me: () => users[0],
  },
  User: {
    __resolveReference: (ref) => users.find(u => u.id === ref.id) || null,
  },
};

const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) });
const { url } = await startStandaloneServer(server, { listen: { port: 4002 } });
console.log(`Users subgraph ready at ${url}`);

Create subgraphs/reviews/index.js:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';

const reviews = [
  { id: 'r1', body: 'Great sound!', rating: 5, productId: 'p1', authorId: 'u1' },
  { id: 'r2', body: 'Comfortable typing', rating: 4, productId: 'p2', authorId: 'u2' },
];

const typeDefs = gql`
  type Review {
    id: ID!
    body: String!
    rating: Int!
    author: User
    product: Product
  }

  extend type User @key(fields: "id") {
    id: ID! @external
    reviews: [Review!]!
  }

  extend type Product @key(fields: "id") {
    id: ID! @external
    reviews: [Review!]!
  }

  type Query {
    review(id: ID!): Review
  }
`;

const resolvers = {
  Query: {
    review: (_, { id }) => reviews.find(r => r.id === id) || null,
  },
  Review: {
    author: (r) => ({ __typename: 'User', id: r.authorId }),
    product: (r) => ({ __typename: 'Product', id: r.productId }),
  },
  User: {
    reviews: (user) => reviews.filter(r => r.authorId === user.id),
  },
  Product: {
    reviews: (product) => reviews.filter(r => r.productId === product.id),
  },
};

const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) });
const { url } = await startStandaloneServer(server, { listen: { port: 4003 } });
console.log(`Reviews subgraph ready at ${url}`);

Run each service in its own terminal:

node subgraphs/products/index.js
node subgraphs/users/index.js
node subgraphs/reviews/index.js

Step 2 — Understand entities and references

  • Product and User are entities (@key on id) and “owned” by their defining subgraphs.
  • The Reviews subgraph extends those types to add cross‑cutting fields (reviews) and returns references like { __typename: 'Product', id } so the gateway can delegate to the owning subgraph when necessary.

That’s the heart of federation: each domain owns its data, and other subgraphs can extend those types without tightly coupling code or databases.

Step 3 — Compose a supergraph and run a gateway

Create gateway/index.js:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { ApolloGateway, IntrospectAndCompose, RemoteGraphQLDataSource } from '@apollo/gateway';

// Compose the supergraph by introspecting subgraphs in dev.
// In production, consider a precomposed supergraph SDL and a registry.
const gateway = new ApolloGateway({
  supergraphSdl: new IntrospectAndCompose({
    subgraphs: [
      { name: 'products', url: 'http://localhost:4001/' },
      { name: 'users', url: 'http://localhost:4002/' },
      { name: 'reviews', url: 'http://localhost:4003/' },
    ],
  }),
  buildService: ({ url }) => new RemoteGraphQLDataSource({
    url,
    // Will use this in a later step for auth/header propagation
    willSendRequest({ request, context }) {
      if (context?.authToken) request.http.headers.set('authorization', context.authToken);
      if (context?.requestId) request.http.headers.set('x-request-id', context.requestId);
    },
  }),
});

const server = new ApolloServer({
  gateway,
  // Turn off Apollo Server’s default landing page in production
  // introspection: process.env.NODE_ENV !== 'production',
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => ({
    authToken: req.headers['authorization'] || '',
    requestId: req.headers['x-request-id'] || `req-${Date.now()}`,
  }),
});

console.log(`Gateway ready at ${url}`);

Start the gateway:

node gateway/index.js

Step 4 — Query the supergraph

Send a query to http://localhost:4000/ with your favorite client or curl:

query TopProductsWithReviews($first: Int!) {
  topProducts(first: $first) {
    id
    name
    price
    reviews { id rating body author { id username } }
  }
}

Example JSON response:

{
  "data": {
    "topProducts": [
      {
        "id": "p1",
        "name": "Noise-Cancelling Headphones",
        "price": 199,
        "reviews": [
          { "id": "r1", "rating": 5, "body": "Great sound!", "author": { "id": "u1", "username": "ada" } }
        ]
      },
      {
        "id": "p2",
        "name": "Ergonomic Keyboard",
        "price": 129,
        "reviews": [
          { "id": "r2", "rating": 4, "body": "Comfortable typing", "author": { "id": "u2", "username": "grace" } }
        ]
      }
    ]
  }
}

The gateway planned one query that fan‑outs to the Products subgraph for the list, then to Reviews for each product’s reviews, and to Users for authors.

Step 5 — Add authentication and header propagation

Federation typically uses a front‑door auth policy at the gateway, with downstream context/header propagation to subgraphs.

  • Validate tokens at the edge (gateway) and attach claims to context.
  • Use buildService + willSendRequest (shown above) to forward only the headers or claims subgraphs need.
  • In each subgraph, read headers from the request to enforce domain‑specific permissions.

Example (Products subgraph) to read forwarded headers:

import { startStandaloneServer } from '@apollo/server/standalone';
// ... existing server
await startStandaloneServer(server, {
  listen: { port: 4001 },
  context: async ({ req }) => ({
    userId: req.headers['x-user-id'] || null,
    // or parse claims from a JWT propagated by the gateway
  }),
});

Tips:

  • Keep PII and sensitive claims minimal; prefer opaque IDs.
  • Consider a centralized auth service that the gateway consults during request lifecycle.

Step 6 — Performance patterns

  • Dataloader per request: Batch and cache entity lookups in each subgraph to reduce N+1 issues.
  • Bounded timeouts: Configure subgraph timeouts and fallbacks (e.g., default to partial data when reviews are slow).
  • Cache where it counts:
    • CDN cache for GET queries if your gateway supports APQ or GET.
    • Subgraph‑level memoization for expensive lookups.
  • Persisted/whitelisted operations: Enable APQ or a safelist to reduce planning and improve security.

Example Dataloader sketch inside Products:

import DataLoader from 'dataloader';

function makeProductLoader() {
  return new DataLoader(async (ids) => {
    // Replace with a single DB query: SELECT * FROM products WHERE id IN (...)
    return ids.map(id => products.find(p => p.id === id) || null);
  });
}

// in context
const loader = makeProductLoader();
// in resolvers use loader.load(id)

Step 7 — Local composition, checks, and CI/CD

  • Local dev: Compose via gateway introspection (as above) for quick iteration.
  • Precomposed supergraph: In delivery pipelines, compose subgraphs into a supergraph SDL artifact and deploy it with the gateway/router. This enables schema checks, reproducibility, and rollbacks.
  • Contract tests: For each subgraph, add tests that validate entity resolvers and selection sets required by other subgraphs.
  • Breaking change checks: Fail the build if changes remove fields used by clients or other subgraphs.

Step 8 — Observability and error handling

  • Structured errors: Return domain‑specific codes from subgraphs; the gateway will surface partial data + errors[] by path.
  • Tracing/metrics: Enable per‑subgraph timing and top resolvers; track error rates, P95 latency, and cold paths.
  • Correlation: Pass a request ID from gateway to subgraphs and include it in logs.

Optional — Use a high‑performance router

A Rust‑based GraphQL router can replace the Node.js gateway for higher throughput. Typical flow:

  1. Compose a supergraph SDL as part of CI (from your subgraph SDLs).
  2. Start the router with that supergraph file and a YAML config (timeouts, CORS, header forwarding, caching, etc.).

Minimal router config example (conceptual):

# router.yaml
cors:
  allow_any_origin: true
headers:
  all:
    request:
      - propagate: ["authorization", "x-request-id"]
timeouts:
  subgraph: 2s

# Start (example):
# router --supergraph ./supergraph.graphql --config ./router.yaml

Use this path when you need native performance, advanced retries, circuit‑breaking, or WASM plugins.

Troubleshooting checklist

  • Composition fails: Ensure each entity has a @key and extension types mark external fields with @external.
  • Failing reference resolvers: Implement __resolveReference in the owning subgraph and return null when not found.
  • Infinite or slow queries: Add max depth/complexity at the gateway; consider persisted operations.
  • Mixed versions: Align your federation library versions across subgraphs and gateway/router.
  • CORS: If calling the gateway from browsers, configure CORS at the gateway (and disable it on internal subgraphs).

Hardening for production

  • Run the gateway/router close to subgraphs to minimize tail latency.
  • Separate read vs. write paths if needed (writes might bypass heavy caching).
  • Rate limiting and abuse prevention at the edge.
  • Blue/green deploys for subgraphs and the gateway; validate with synthetic checks.

Recap

You created three federated subgraphs, composed them into a supergraph, and exposed a single endpoint through a gateway. You learned how entities, keys, and reference resolvers let teams own their domains while delivering a unified API. From here, add real data sources, enforce auth, and invest in composition checks to safely evolve your supergraph at scale.

Related Posts