Skip to content

GraphQL Federation: Splitting a Schema Without Splitting Teams

A practical guide to GraphQL Federation for breaking up a monolithic schema across services without duplicating resolvers or gateway logic.

4 min read
Diagram showing multiple GraphQL subgraphs composed into a single supergraph behind a gateway

Every GraphQL monolith reaches the same wall. One schema, one repo, one deploy pipeline, and eventually one team blocking every other team's release because their resolver touched a type everyone else depends on. The instinct is to split the schema the way you'd split a REST API — by service boundary — but GraphQL clients expect one graph, not five. GraphQL Federation solves that specific problem: multiple teams own independent subgraphs, and a gateway composes them into a single queryable supergraph without anyone hand-writing stitching logic.

This isn't a general endorsement of microservices. It's a narrow tool for a narrow problem: you already have service boundaries, and you need the graph to reflect them without forcing clients to know that boundary exists.

The Problem with a Single Schema

A monolithic GraphQL server centralizes ownership of every type. User, Order, Product, and Inventory all live in one codebase, resolved by one process. That's fine at ten types. At two hundred, with five teams committing to the same schema.graphql, you get:

  • Merge conflicts on shared types nobody clearly owns
  • A single point of failure — one bad resolver deploy takes down the entire graph
  • No way to scale the Inventory service independently of User auth checks

The naive fix — schema stitching — required a coordinating process to manually merge remote schemas and rewrite queries. It worked, but every new subgraph needed hand-written merge logic. Federation, as specified by Apollo's Federation spec, replaces that manual merge with a declarative composition step: each subgraph declares what it owns, and a composition tool builds the supergraph automatically.

How a Subgraph Declares Ownership

Each service exposes a normal GraphQL schema, annotated with federation directives. The key one is @key, which tells the gateway how to identify an entity across subgraphs.

graphqlgraphql
# subgraph: products
type Product @key(fields: "id") {
  id: ID!
  name: String!
  priceCents: Int!
}
 
# subgraph: inventory
type Product @key(fields: "id") {
  id: ID! @external
  stockCount: Int!
  warehouseId: ID!
}
 
extend type Query {
  lowStockProducts(threshold: Int!): [Product!]!
}

Two services both define Product, but neither owns the whole type. products owns name and priceCents; inventory owns stockCount. The @key(fields: "id") directive tells the composition layer these are the same entity, joined on id. No shared database, no shared codebase — just a shared identifier.

Resolving References Across Services

When a query asks for stockCount on a Product that originated from the products subgraph, the gateway needs the inventory subgraph to resolve it. That's what __resolveReference is for:

tstypescript
// inventory subgraph resolver
const resolvers = {
  Product: {
    async __resolveReference(reference: { id: string }) {
      // ❌ Bad: N+1 query per product, one at a time
      // return db.inventory.findOne({ productId: reference.id });
 
      // ✅ Good: batch reference resolution with a dataloader
      return inventoryLoader.load(reference.id);
    },
  },
  Query: {
    async lowStockProducts(_: unknown, args: { threshold: number }) {
      const rows = await db.inventory.find({
        stockCount: { $lt: args.threshold },
      });
      return rows.map((row) => ({ id: row.productId }));
    },
  },
};

The gateway batches all __resolveReference calls for a given subgraph into a single request per query, so this runs once per query, not once per entity — but only if you batch inside the resolver too. Skip the dataloader and you've just moved the N+1 problem from the gateway into the subgraph.

!

__resolveReference is called once per entity per query by default. Without batching, a query returning 50 products triggers 50 round trips to the inventory subgraph even though federation itself batches at the network layer.

Composing and Serving the Supergraph

The gateway doesn't merge schemas at runtime by guessing — it consumes a precomputed supergraph schema, produced by a composition step that validates all subgraphs agree on shared types.

tstypescript
// gateway.ts
import { ApolloGateway, IntrospectAndCompose } from "@apollo/gateway";
import { ApolloServer } from "@apollo/server";
 
const gateway = new ApolloGateway({
  supergraphSdl: new IntrospectAndCompose({
    subgraphs: [
      { name: "products", url: "http://products.internal:4001/graphql" },
      { name: "inventory", url: "http://inventory.internal:4002/graphql" },
      { name: "orders", url: "http://orders.internal:4003/graphql" },
    ],
  }),
});
 
const server = new ApolloServer({ gateway });

IntrospectAndCompose is convenient for development but re-composes on every gateway restart by hitting live subgraphs — a bad idea in production, where a subgraph being briefly unreachable shouldn't take down the whole graph. Production setups instead publish a composed supergraph SDL to a schema registry and have the gateway load a static, pre-validated artifact.

ymlyaml
# supergraph-config.yaml — used by rover to compose ahead of deploy time
federation_version: 2
subgraphs:
  products:
    routing_url: https://products.internal/graphql
    schema:
      file: ./products/schema.graphql
  inventory:
    routing_url: https://inventory.internal/graphql
    schema:
      file: ./inventory/schema.graphql

Composition happens in CI, against schema files, not live traffic. A broken subgraph schema fails the build before it ever reaches the gateway. This is the same discipline you'd want for any GraphQL schema shared across teams — validate the contract before it ships, not after.

Migrating a Monolith Incrementally

You don't need to federate everything on day one. The practical path mirrors the strangler fig pattern: wrap the existing monolith as a single subgraph, then peel off types one at a time.

graphqlgraphql
# legacy-monolith subgraph — everything not yet extracted
extend type Query {
  legacyUsers: [User!]!
}
 
type User @key(fields: "id") {
  id: ID!
  email: String!
  # ownership will move to a dedicated `identity` subgraph later
}

The gateway doesn't care that one "subgraph" is actually your entire old server. Clients see one graph the whole time; the migration is invisible to them. That's the real value of federation over a rewrite — it decouples the org-chart problem from the API-contract problem.

Query Planning Cost

Every federated query gets planned before execution: the gateway figures out which subgraphs to call, in what order, and how to stitch results. That planning has real latency cost, and it compounds with subgraph count.

Subgraphs touchedTypical added latencyMitigation
1–2negligiblenone needed
3–55–20msquery plan caching
6+20ms+reduce entity fan-out, colocate hot paths

Query plan caching (keyed on the operation's normalized query string) removes re-planning cost for repeated queries, which covers most production traffic since clients tend to send a small, stable set of operations.

Key Takeaways

  1. Federation solves an org problem — independent team ownership — not a performance problem. Don't reach for it just to split files.
  2. @key directives define entity identity across subgraphs; get this wrong and composition either fails or silently produces the wrong join.
  3. Batch __resolveReference with a dataloader — federation batches at the network layer, not inside your resolver.
  4. Compose the supergraph in CI against schema files, not at gateway startup against live traffic.
  5. Migrate incrementally by wrapping the existing monolith as one subgraph and peeling off types over time.
  6. Cache query plans; re-planning identical operations on every request is wasted latency you don't need to pay.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX