Back to Blog
GraphQLApolloMicroservicesAPIFederationTypeScript

GraphQL Federation — Multi-Team Schema Composition with Apollo Router

A practical guide to GraphQL Federation 2 with Apollo Router: defining subgraphs with @key entities, composing supergraphs with Rover CLI, configuring Apollo Router for authentication and header forwarding, solving the N+1 problem with DataLoader in federated resolvers, and schema CI/CD checks for safe multi-team schema evolution.

2026-05-06

The Scaling Problem with Monolithic GraphQL

A single GraphQL schema worked fine when one team owned the API and the graph was small. As the organisation grew — three teams, six teams, twelve — the monolith became a coordination bottleneck. Every new field touched the same repository. Schema reviews blocked releases. Type names collided. A breaking change by the Payments team broke the Products team's clients. The graph stopped being an asset and became a shared liability.

Apollo Federation solves this by letting each team own a subgraph — a standalone GraphQL service that exposes its domain's types and resolvers. A central Apollo Router composes all subgraphs into a unified supergraph and routes client queries to the right services. Clients see one endpoint, one schema, one API — teams work independently.

Independent Schema Ownership

Each team deploys its subgraph on its own schedule. Schema changes go through composition validation before reaching the router — breaking changes are caught at CI time, not at client runtime.

Unified Client API

Clients query a single endpoint regardless of how many subgraphs exist behind it. Apollo Router handles query planning, splitting a client query into subgraph fetches and merging results — transparently and in parallel where possible.

Cross-Service Type Composition

The @key directive lets one subgraph extend a type defined in another. The Reviews subgraph can add reviews to the Product type owned by the Catalog subgraph — without modifying the Catalog service at all.

Federation 2 Architecture

Apollo Federation 2 has four components. Subgraphs are individual GraphQL services, each with its own schema, resolvers, and deployment. The supergraph is the composed schema that represents the union of all subgraph schemas — it exists as a compiled artifact (SDL + query planning metadata). Apollo Router loads the supergraph and routes incoming client queries. Rover CLI is the command-line tool that validates, composes, and publishes schemas.

The composition step — merging subgraph schemas into the supergraph — is where conflicts are detected. If two subgraphs define the same type differently without a compatible merge rule, composition fails. This is intentional: schema conflicts surface in CI rather than at query time. Federation 2 introduced shareable types (@shareable) and value type merging, making it much easier for multiple subgraphs to contribute to the same type.

Note

Federation 1 required a gateway written in Node.js. Federation 2 replaced it with Apollo Router, a high-performance binary written in Rust. Router handles 10–50× more requests per second than the JS gateway at the same hardware, and supports coprocessors (external HTTP services) for custom auth and middleware logic. If you are starting a new federation project, use Federation 2 and Router from day one.

Defining Subgraph Services

Each subgraph is a GraphQL server that uses the @apollo/subgraph package. The key difference from a regular GraphQL server is that subgraph schemas include federation directives — primarily @key, which marks the fields that uniquely identify a type across the graph. Subgraphs also expose a special _service query that the Router uses to introspect the subgraph schema during composition.

The canonical example is an e-commerce graph with three subgraphs: Catalog (products), Orders (orders and line items), and Reviews (product reviews and ratings). Each owns its domain. The Product entity bridges all three.

# Install dependencies for a subgraph service
# npm install @apollo/subgraph @apollo/server graphql express
# npm install -D typescript @types/node @types/express tsx

# Catalog subgraph — owns Product, Category
# catalog-subgraph/src/schema.ts

import { gql } from "graphql-tag";

export const typeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"])

  type Query {
    product(id: ID!): Product
    products(first: Int = 20, after: String): ProductConnection!
    category(slug: String!): Category
  }

  # @key marks the field(s) that uniquely identify this entity across the supergraph.
  # Other subgraphs can reference Product by providing just the id field.
  type Product @key(fields: "id") {
    id: ID!
    sku: String!
    name: String!
    description: String
    price: Float!
    currency: String! @shareable
    category: Category!
    inStock: Boolean!
    imageUrl: String
    createdAt: String!
  }

  # Compound key: both fields together identify the entity
  type ProductVariant @key(fields: "productId variantId") {
    productId: ID!
    variantId: ID!
    size: String
    color: String
    price: Float!
    inStock: Boolean!
  }

  type Category {
    id: ID!
    slug: String!
    name: String!
    products(first: Int = 20): [Product!]!
  }

  type ProductConnection {
    edges: [ProductEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type ProductEdge {
    cursor: String!
    node: Product!
  }

  type PageInfo @shareable {
    hasNextPage: Boolean!
    endCursor: String
  }
`;
// catalog-subgraph/src/resolvers.ts
import { ProductService } from "./services/product-service";

const productService = new ProductService();

export const resolvers = {
  Query: {
    product: (_: unknown, { id }: { id: string }) =>
      productService.findById(id),

    products: (_: unknown, { first, after }: { first: number; after?: string }) =>
      productService.paginate({ first, after }),

    category: (_: unknown, { slug }: { slug: string }) =>
      productService.findCategoryBySlug(slug),
  },

  Product: {
    // __resolveReference is called when another subgraph references this entity.
    // The Router sends a batch of { id } representations; this resolver resolves each one.
    __resolveReference: (ref: { id: string }) =>
      productService.findById(ref.id),

    category: (product: { categoryId: string }) =>
      productService.findCategoryById(product.categoryId),
  },

  ProductVariant: {
    __resolveReference: (ref: { productId: string; variantId: string }) =>
      productService.findVariant(ref.productId, ref.variantId),
  },
};
// catalog-subgraph/src/server.ts
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { buildSubgraphSchema } from "@apollo/subgraph";
import express from "express";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";

const app = express();

const server = new ApolloServer({
  schema: buildSubgraphSchema({ typeDefs, resolvers }),

  // Recommended for production subgraphs: disable introspection unless debugging
  introspection: process.env.NODE_ENV !== "production",

  plugins: [
    // Health check endpoint for Kubernetes probes
    {
      async serverWillStart() {
        return {
          async drainServer() {
            // graceful shutdown logic
          },
        };
      },
    },
  ],
});

await server.start();

app.use(
  "/graphql",
  express.json(),
  expressMiddleware(server, {
    context: async ({ req }) => ({
      // Forward auth headers from Router to resolver context
      userId: req.headers["x-user-id"] as string | undefined,
      userRole: req.headers["x-user-role"] as string | undefined,
      requestId: req.headers["x-request-id"] as string | undefined,
    }),
  })
);

app.get("/health", (_req, res) => res.json({ status: "ok" }));

app.listen(4001, () => {
  console.log("Catalog subgraph running at http://localhost:4001/graphql");
});

Entities and Cross-Subgraph References

An entity is a type decorated with @key. Any subgraph that knows the key fields can reference the entity without re-defining its fields. The Reviews subgraph wants to attach reviews to a Product — but Product lives in the Catalog subgraph. Federation handles this through a concept called stub entities: the Reviews subgraph declares a minimal representation of Product (just the key fields) and extends it with the reviews field.

At query time, if a client requests { product(id: "p1") { name reviews { rating } } }, the Router's query planner knows to fetch name from Catalog and reviews from Reviews. It first fetches from Catalog to get the product ID, then passes that ID to Reviews as an entity representation — a batch call to _entities.

# Reviews subgraph — owns Review, references Product and User entities
# reviews-subgraph/src/schema.ts

import { gql } from "graphql-tag";

export const typeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@external", "@requires"])

  type Query {
    review(id: ID!): Review
    # Top reviews globally — doesn't need Product context
    featuredReviews(limit: Int = 10): [Review!]!
  }

  type Mutation {
    createReview(input: CreateReviewInput!): CreateReviewPayload!
  }

  # Stub entity: only key fields needed; Reviews doesn't own Product
  # The @external directive marks fields owned by another subgraph
  type Product @key(fields: "id") {
    id: ID! @external
    # Extend Product with reviews — this field is owned by Reviews subgraph
    reviews(first: Int = 10, after: String): ReviewConnection!
    averageRating: Float
    reviewCount: Int!
  }

  # Reviews also references User — stub entity from Users subgraph
  type User @key(fields: "id") {
    id: ID! @external
    # User's reviews — resolved by Reviews subgraph
    reviews(first: Int = 10): [Review!]!
  }

  type Review @key(fields: "id") {
    id: ID!
    product: Product!
    author: User!
    rating: Int!           # 1-5
    title: String
    body: String!
    verified: Boolean!     # purchased before reviewing
    helpfulVotes: Int!
    createdAt: String!
  }

  type ReviewConnection {
    edges: [ReviewEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
    averageRating: Float
  }

  type ReviewEdge {
    cursor: String!
    node: Review!
  }

  type PageInfo @shareable {
    hasNextPage: Boolean!
    endCursor: String
  }

  input CreateReviewInput {
    productId: ID!
    rating: Int!
    title: String
    body: String!
  }

  type CreateReviewPayload {
    review: Review
    errors: [UserError!]!
  }

  type UserError {
    message: String!
    field: String
  }
`;
// reviews-subgraph/src/resolvers.ts
import { ReviewService } from "./services/review-service";
import { createReviewLoaders } from "./loaders";

const reviewService = new ReviewService();

export const resolvers = {
  Query: {
    review: (_: unknown, { id }: { id: string }) =>
      reviewService.findById(id),
    featuredReviews: (_: unknown, { limit }: { limit: number }) =>
      reviewService.featured(limit),
  },

  Mutation: {
    createReview: async (
      _: unknown,
      { input }: { input: { productId: string; rating: number; title?: string; body: string } },
      { userId }: { userId?: string }
    ) => {
      if (!userId) {
        return { review: null, errors: [{ message: "Authentication required" }] };
      }
      const review = await reviewService.create({ ...input, authorId: userId });
      return { review, errors: [] };
    },
  },

  // Entity resolver: Router sends batches of { id } representations
  Review: {
    __resolveReference: (ref: { id: string }) =>
      reviewService.findById(ref.id),

    // product is stored as productId in the database; return stub entity for Router
    product: (review: { productId: string }) => ({ id: review.productId }),

    // author is stored as authorId; return stub entity for Router
    author: (review: { authorId: string }) => ({ id: review.authorId }),
  },

  // Product entity: Reviews subgraph resolves reviews and rating for a product
  Product: {
    __resolveReference: async (
      ref: { id: string },
      _: unknown,
      { loaders }: { loaders: ReturnType<typeof createReviewLoaders> }
    ) => {
      // DataLoader batches all Product entity resolutions into a single DB query
      return loaders.productReviewStats.load(ref.id);
    },

    reviews: (product: { id: string }, { first, after }: { first: number; after?: string }) =>
      reviewService.forProduct(product.id, { first, after }),

    averageRating: (product: { averageRating?: number }) =>
      product.averageRating ?? null,

    reviewCount: (product: { reviewCount?: number }) =>
      product.reviewCount ?? 0,
  },

  User: {
    __resolveReference: (ref: { id: string }) =>
      // Return a minimal object; User's own fields are resolved by Users subgraph
      ({ id: ref.id }),

    reviews: (user: { id: string }, { first }: { first: number }) =>
      reviewService.forUser(user.id, { first }),
  },
};

Composing the Supergraph with Rover CLI

The Rover CLI is the tool that composes subgraph schemas into a supergraph SDL. In development, you compose locally from SDL files. In production, each subgraph publishes its schema to the Apollo Schema Registry, and composition runs automatically in Apollo GraphOS. The Router fetches the latest composed supergraph on startup and polls for updates — zero-downtime schema updates without restarting the router.

# Install Rover CLI
curl -sSL https://rover.apollo.dev/nix/latest | sh

# supergraph.yaml — defines the subgraph services for local composition
federation_version: =2.3.5

subgraphs:
  catalog:
    routing_url: http://catalog-service:4001/graphql
    schema:
      file: ./catalog-subgraph/schema.graphql   # or subgraph_url for introspection

  orders:
    routing_url: http://orders-service:4002/graphql
    schema:
      file: ./orders-subgraph/schema.graphql

  reviews:
    routing_url: http://reviews-service:4003/graphql
    schema:
      file: ./reviews-subgraph/schema.graphql

  users:
    routing_url: http://users-service:4004/graphql
    schema:
      file: ./users-subgraph/schema.graphql

# ── Compose locally (development) ────────────────────────────────────────────
rover supergraph compose --config supergraph.yaml > supergraph.graphql

# ── Validate composition without generating output ────────────────────────────
rover supergraph compose --config supergraph.yaml --elv2-license accept --output -

# ── Publish a subgraph to Apollo GraphOS (CI/CD) ─────────────────────────────
# APOLLO_KEY and APOLLO_GRAPH_REF must be set in CI environment
rover subgraph publish my-graph@production   --name catalog   --schema ./catalog-subgraph/schema.graphql   --routing-url https://catalog.internal/graphql

# ── Check a subgraph schema for breaking changes before merge ─────────────────
rover subgraph check my-graph@production   --name catalog   --schema ./catalog-subgraph/schema.graphql

# Output:
# Checking the proposed schema for subgraph 'catalog' against my-graph@production...
# Compared 1 schema change against 2,431 operations over the last 7 days
# PASS  No breaking changes detected in schema or composition

Note

Rover subgraph check compares your proposed schema against the operation usage data in GraphOS. It knows which clients use which fields, so it can distinguish a breaking change that actually affects production clients from one that only removes an unused field. This makes GraphQL schema evolution far safer than REST API versioning — you have data-driven evidence of client impact before you merge.

Apollo Router: Configuration and Performance

Apollo Router is distributed as a standalone binary. It loads a supergraph SDL at startup and routes queries using a built-in Rust query planner. Configuration is a YAML file (router.yaml) that controls every aspect of routing behaviour: header propagation, CORS, TLS, traffic shaping, telemetry, and coprocessor hooks.

# router.yaml — Apollo Router production configuration

supergraph:
  # Listen address
  listen: 0.0.0.0:4000

  # Introspection: disable in production unless needed
  introspection: false

  # Query depth limit (prevents deeply nested abuse queries)
  query_depth_limit: 15

# ── Header propagation ────────────────────────────────────────────────────────
# Forward specific headers from client to all subgraphs
headers:
  all:
    request:
      # Forward auth headers set by the coprocessor (see below)
      - propagate:
          named: "x-user-id"
      - propagate:
          named: "x-user-role"
      - propagate:
          named: "x-request-id"
      # Forward tracing headers
      - propagate:
          named: "traceparent"
      - propagate:
          named: "tracestate"

# ── CORS ─────────────────────────────────────────────────────────────────────
cors:
  origins:
    - https://app.example.com
    - https://admin.example.com
  allow_credentials: true
  methods:
    - POST
    - OPTIONS

# ── Traffic limits ────────────────────────────────────────────────────────────
limits:
  # Max request body size in bytes (10 MB)
  http_max_request_bytes: 10485760
  # Max operation complexity (sum of field weights)
  max_depth: 15

# ── Subgraph connection settings ──────────────────────────────────────────────
traffic_shaping:
  all:
    # Timeout for each subgraph fetch
    timeout: 30s
    # Retry on subgraph 5xx errors (idempotent queries only)
    experimental_retry:
      min_per_sec: 10
      retry_percent: 0.2
      retry_mutations: false

# ── Telemetry: OpenTelemetry ──────────────────────────────────────────────────
telemetry:
  instrumentation:
    spans:
      router:
        attributes:
          http.request.headers.x-request-id:
            request_header: "x-request-id"
  exporters:
    tracing:
      otlp:
        enabled: true
        endpoint: http://otel-collector:4317
        protocol: grpc
    metrics:
      prometheus:
        enabled: true
        path: /metrics
        listen: 0.0.0.0:9090

# ── Coprocessor: external service for auth ────────────────────────────────────
coprocessor:
  url: http://auth-coprocessor:8080
  router:
    request:
      headers: true    # send request headers to coprocessor
      body: false      # don't send body (auth only needs headers)
  router:
    response:
      headers: true    # allow coprocessor to add response headers
# Start Apollo Router with a locally composed supergraph (development)
./router   --supergraph supergraph.graphql   --config router.yaml

# In production: Router fetches supergraph from Apollo GraphOS
# APOLLO_KEY and APOLLO_GRAPH_REF are set in the container environment
./router --config router.yaml

# Docker image (official)
# docker pull ghcr.io/apollographql/router:v1.46.0
# docker run -p 4000:4000 #   -e APOLLO_KEY="${APOLLO_KEY}" #   -e APOLLO_GRAPH_REF="${APOLLO_GRAPH_REF}" #   -v $(pwd)/router.yaml:/dist/config/router.yaml #   ghcr.io/apollographql/router:v1.46.0

# Kubernetes deployment (Helm chart)
# helm repo add apollo https://charts.apollographql.com
# helm install router apollo/router #   --set router.configuration.supergraph.introspection=false #   --set managedFederation.apiKey="${APOLLO_KEY}" #   --set managedFederation.graphRef="${APOLLO_GRAPH_REF}"

Authentication at the Router: JWT Coprocessor

Apollo Router does not natively validate JWT tokens, but its coprocessor hook makes adding auth straightforward. A coprocessor is an external HTTP service that receives the router request, performs validation, adds headers, and returns a modified request or an error. The router continues to the subgraphs only if the coprocessor returns a 200. Subgraphs receive the decoded user context as request headers — they trust the router and never validate tokens themselves.

// auth-coprocessor/src/server.ts
// Lightweight Fastify service that validates JWTs for Apollo Router
// npm install fastify @fastify/jwt

import Fastify from "fastify";
import fastifyJwt from "@fastify/jwt";

const app = Fastify({ logger: true });

app.register(fastifyJwt, {
  secret: process.env.JWT_SECRET!,
  // Or use RS256 with a JWKS endpoint:
  // secret: { public: jwksClient({ uri: process.env.JWKS_URI }) }
});

interface CoprocessorPayload {
  version: number;
  stage: string;
  control: "continue" | { break: number };
  headers?: Record<string, string[]>;
}

app.post<{ Body: CoprocessorPayload }>("/", async (request, reply) => {
  const { headers = {} } = request.body;

  const authHeader = headers["authorization"]?.[0] ?? "";

  // Public routes: pass through without auth check
  const body = (request.body as { body?: string }).body;
  if (body && isPublicOperation(body)) {
    return { ...request.body, control: "continue" };
  }

  if (!authHeader.startsWith("Bearer ")) {
    return reply.status(200).send({
      ...request.body,
      control: { break: 401 },
      body: JSON.stringify({ errors: [{ message: "Authentication required" }] }),
    });
  }

  try {
    const token = authHeader.slice(7);
    const payload = app.jwt.verify<{ sub: string; role: string; exp: number }>(token);

    // Add decoded user context as headers for subgraphs
    return {
      ...request.body,
      control: "continue",
      headers: {
        ...headers,
        "x-user-id":   [payload.sub],
        "x-user-role": [payload.role],
      },
    };
  } catch (err) {
    return reply.status(200).send({
      ...request.body,
      control: { break: 401 },
      body: JSON.stringify({ errors: [{ message: "Invalid or expired token" }] }),
    });
  }
});

function isPublicOperation(body: string): boolean {
  try {
    const { query } = JSON.parse(body);
    // Allow introspection and a specific public query
    return query?.includes("__schema") || query?.includes("featuredReviews");
  } catch {
    return false;
  }
}

await app.listen({ port: 8080, host: "0.0.0.0" });
console.log("Auth coprocessor running on port 8080");

The N+1 Problem and DataLoader in Federation

The N+1 query problem is amplified in federation. When the Router resolves a list of Products and each Product needs its review stats from the Reviews subgraph, a naive resolver fires one database query per product. For a list of 50 products, that's 50 individual DB round-trips inside the Reviews service — triggered by a single client request.

The Federation protocol already batches cross-subgraph entity fetches into a single _entities call per subgraph — the Router sends all 50 product IDs in one request to Reviews. The remaining problem is inside the subgraph itself: the __resolveReference resolver is called once per entity, but those calls should be batched into one DB query. This is exactly what DataLoader solves.

// reviews-subgraph/src/loaders.ts
// DataLoader batches individual __resolveReference calls into single DB queries
// npm install dataloader

import DataLoader from "dataloader";
import { ReviewRepository } from "./repositories/review-repository";

const repo = new ReviewRepository();

export function createReviewLoaders() {
  return {
    // Batch: load review stats for many products in one query
    productReviewStats: new DataLoader<string, ProductReviewStats>(
      async (productIds: readonly string[]) => {
        // Single DB query for all product IDs
        const stats = await repo.getStatsForProducts([...productIds]);

        // DataLoader requires results in the same order as the input keys
        return productIds.map(
          (id) =>
            stats.find((s) => s.productId === id) ?? {
              id,
              productId: id,
              averageRating: null,
              reviewCount: 0,
            }
        );
      },
      {
        // Cache within a single request (cleared per request in context factory)
        cache: true,
        // Batch window: collect all calls within the same event loop tick
        batchScheduleFn: (callback) => setTimeout(callback, 1),
        maxBatchSize: 500,
      }
    ),

    // Batch: load reviews by their IDs
    reviewById: new DataLoader<string, Review | null>(
      async (ids: readonly string[]) => {
        const reviews = await repo.findByIds([...ids]);
        const map = new Map(reviews.map((r) => [r.id, r]));
        return ids.map((id) => map.get(id) ?? null);
      }
    ),
  };
}

interface ProductReviewStats {
  id: string;
  productId: string;
  averageRating: number | null;
  reviewCount: number;
}

interface Review {
  id: string;
  productId: string;
  authorId: string;
  rating: number;
  body: string;
  createdAt: string;
}

// reviews-subgraph/src/server.ts — wire DataLoaders into context
// context factory creates fresh DataLoaders per request (important for cache isolation)
expressMiddleware(server, {
  context: async ({ req }) => ({
    userId: req.headers["x-user-id"] as string | undefined,
    userRole: req.headers["x-user-role"] as string | undefined,
    // Fresh DataLoaders per request — prevents cache leakage between users
    loaders: createReviewLoaders(),
  }),
});

Note

Always create DataLoader instances inside the context factory, not at module scope. A module-scoped DataLoader caches results globally — subsequent requests see stale or wrong data, and cached results from one user leak to another. The DataLoader cache is a per-request optimisation, not a shared application cache. If you need cross-request caching, use Redis and keep DataLoaders request-scoped.

CI/CD: Schema Checks and Safe Deployment

Schema changes in a federated graph require a two-step CI gate: first validate that composition succeeds (the subgraph's schema merges cleanly with all others), then validate that no operation used by real clients in the last 7 days is broken by the change. Both checks run with rover subgraph check and both must pass before a PR can merge.

# .github/workflows/schema-check.yml
# Runs on every PR that modifies a subgraph schema

name: GraphQL Schema Check

on:
  pull_request:
    paths:
      - "**/schema.graphql"
      - "**/*.typedefs.ts"

jobs:
  schema-check:
    name: Check subgraph schema
    runs-on: ubuntu-latest

    env:
      APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
      APOLLO_GRAPH_REF: my-graph@production

    steps:
      - uses: actions/checkout@v4

      - name: Install Rover CLI
        run: curl -sSL https://rover.apollo.dev/nix/latest | sh -s -- --elv2-license accept
        env:
          APOLLO_TELEMETRY_DISABLED: "true"

      - name: Export subgraph schema (from TypeScript source)
        run: |
          cd catalog-subgraph
          npm ci
          # Script that prints the SDL to stdout
          npx tsx scripts/print-schema.ts > schema.graphql

      - name: Check catalog subgraph schema
        run: |
          ~/.rover/bin/rover subgraph check ${{ env.APOLLO_GRAPH_REF }}             --name catalog             --schema ./catalog-subgraph/schema.graphql

      # Repeat for other subgraphs that changed
      - name: Check reviews subgraph schema
        if: contains(github.event.pull_request.changed_files, 'reviews-subgraph')
        run: |
          cd reviews-subgraph && npm ci && npx tsx scripts/print-schema.ts > schema.graphql
          ~/.rover/bin/rover subgraph check ${{ env.APOLLO_GRAPH_REF }}             --name reviews             --schema ./reviews-subgraph/schema.graphql

  publish-schema:
    name: Publish subgraph schema
    runs-on: ubuntu-latest
    needs: schema-check
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    env:
      APOLLO_KEY: ${{ secrets.APOLLO_KEY }}

    steps:
      - uses: actions/checkout@v4
      - name: Install Rover CLI
        run: curl -sSL https://rover.apollo.dev/nix/latest | sh -s -- --elv2-license accept

      - name: Publish catalog subgraph schema
        run: |
          cd catalog-subgraph && npm ci && npx tsx scripts/print-schema.ts > schema.graphql
          ~/.rover/bin/rover subgraph publish my-graph@production             --name catalog             --schema ./schema.graphql             --routing-url https://catalog.internal/graphql

Printing Schema from TypeScript

If your subgraph schema is defined in TypeScript (not a static SDL file), you need a script that instantiates the schema and prints it to stdout. Rover can also introspect a running service with rover subgraph introspect, but generating from source avoids the need for a running service in CI.

// catalog-subgraph/scripts/print-schema.ts
// Prints the subgraph SDL to stdout for rover CLI
// npx tsx scripts/print-schema.ts > schema.graphql

import { printSchemaWithDirectives } from "@graphql-tools/utils";
import { buildSubgraphSchema } from "@apollo/subgraph";
import { typeDefs } from "../src/schema";
import { resolvers } from "../src/resolvers";

const schema = buildSubgraphSchema({ typeDefs, resolvers });
process.stdout.write(printSchemaWithDirectives(schema));

Production Checklist

Federation 2 directive imports

Every subgraph schema starts with @link importing the Federation 2 spec. Missing this causes composition errors or silent fallback to Federation 1 semantics.

__resolveReference on every @key entity

Any type marked @key must implement __resolveReference. Without it, cross-subgraph entity resolution silently returns null for that entity's fields.

DataLoader per request

DataLoader instances are created in the context factory — never at module scope. Request-scoped loaders prevent cache poisoning between concurrent users.

Header propagation configured

Auth headers set by the coprocessor are listed in router.yaml headers.all.request. Subgraphs read user context from these headers — not from tokens they verify themselves.

rover subgraph check in CI

Every PR that modifies a subgraph schema runs a schema check against production operation data. Breaking changes with live client traffic are blocked from merging.

Managed federation for zero-downtime updates

In production, subgraphs publish schemas to Apollo GraphOS. The Router polls for updates and hot-reloads the supergraph without restart — schema deploys are decoupled from Router deploys.

Query depth and complexity limits

router.yaml sets query_depth_limit to prevent deeply nested abuse queries. Add a complexity plugin for operations with large fan-out (lists of lists).

Introspection disabled in production

Set introspection: false in router.yaml for production. Enable it only on an internal network or after authentication if needed for tooling.

Work with us

Building a GraphQL federation or migrating from a monolithic GraphQL API to a federated architecture?

We design and implement GraphQL federation architectures — from subgraph service boundaries and entity design to Apollo Router configuration, authentication coprocessors, and schema CI/CD pipelines. Let’s talk.

Get in touch

Related Articles