How GraphQL federation works: subgraphs, composition and the router
In GraphQL federation, each team runs its own subgraph: a GraphQL service that publishes a subgraph schema describing the types and fields it owns. A composition step takes every subgraph schema and merges them into one supergraph schema. Composition fails if the schemas conflict, for example when two subgraphs define the same field with incompatible types. A router loads the supergraph schema, splits each client query into a plan of sub-requests, sends them to the right subgraphs and assembles the response.
Clients (web, mobile, partners)
│
▼
┌────────┐ loads ┌──────────────────┐
│ Router │◄───────────│ Supergraph schema│◄── composition
└────────┘ └──────────────────┘ (CI / registry)
┌─────┼─────────┐
▼ ▼ ▼
┌───────┐┌───────┐┌─────────┐
│ Users ││Orders ││Inventory│ independently deployed subgraphs
└───────┘└───────┘└─────────┘
Teams adopt this model because it separates ownership from the API surface. The orders team can change and ship its subgraph on its own schedule, and clients still see a single schema at a single endpoint. Because composition runs before deploy, schema conflicts are found as build errors, not as runtime failures.
Schema stitching also merges several schemas, but the merge logic lives in the gateway. The gateway team writes code that links types across services, so every cross-service change passes through that team. Federation puts the linking information in each subgraph schema, and the router only executes what composition produced.
| Aspect | Federation | Schema stitching |
|---|---|---|
| Where merge logic lives | Subgraph schemas, validated at composition | Gateway code |
| Who changes cross-service links | Owning subgraph team | Gateway team |
| When conflicts surface | Build time (composition) | Often at gateway runtime |
| Gateway role | Generic query planner and executor | Custom per-schema logic |
┌──────────────────────────────────────────────┐
│ Clients (web, mobile, partners) │
└──────────────────────┬───────────────────────┘
│ one query, one endpoint
▼
┌──────────────────────────────────────────────┐ ┌─────────────┐
│ Router: plan query, call subgraphs, assemble │◄──loads───│ Supergraph │
└──────┬─────────────┬──────────────┬──────────┘ │ schema │
│ │ sub-requests │ └──────▲──────┘
▼ ▼ ▼ │
┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────┴───────┐
│ Users │ │ Orders │ │ Inventory │ │ Composition │
│subgraph │ │subgraph │ │ subgraph │ │ (CI/registry) │
└────┬────┘ └────┬────┘ └─────┬─────┘ │ merges; fails │
│ publishes │ publishes │ publishes │ on conflicts │
│ schema │ schema │ schema └───────▲───────┘
└─────────────┴──────────────┴─────────────────────────────┘
Each subgraph publishes its schema. Composition merges the schemas into a supergraph schema and fails on conflicts. The router loads the supergraph schema, turns each client query into sub-requests to the subgraphs and assembles the response.
Entities, keys and contributed fields
An entity is a type that more than one subgraph can contribute fields to, and @key is what makes it one. Suppose a products subgraph declares Product @key(fields: "id") with name and price, and a reviews subgraph declares its own Product @key(fields: "id") that adds a reviews field. When a query crosses from reviews into product data, the router does not call a product-specific endpoint. It sends the owning subgraph a list of representations, objects such as { __typename: "Product", id: "42" }, through the _entities field. The owning subgraph turns each one back into a full object.
That list is why the reference resolver must batch. A page of 50 reviews produces 50 representations in a single _entities call. If you resolve each one with its own query, the N+1 problem moves from the client into your subgraph. The usual fix is a request-scoped loader that collects the keys and issues one WHERE id IN (...) lookup.
The key itself is the most consequential decision. Every cross-subgraph hop resolves through it, so it should be immutable, already known to the referencing subgraph, and indexed in the owning store. A composite or derived key turns every join into extra work, and changing it later touches every subgraph.
The remaining directives encode ownership:
@externalmarks a field another subgraph defines.@requiresdeclares that computing a field needs certain external fields, which the router fetches first.@providessays a subgraph can return specific external fields on a given path, which saves a hop.@shareablepermits several subgraphs to resolve the same field. Composition rejects that by default because duplicate resolvers can return values that disagree.
import DataLoader from "dataloader";
// products subgraph: owns Product, so it alone resolves references to it.
// (Federation 2 SDL also needs the schema-level @link import, omitted here.)
export const productsSDL = /* GraphQL */ `
type Product @key(fields: "id") { id: ID! name: String! price: Float! }
`;
// reviews subgraph: contributes `reviews` to the same entity via the same key.
export const reviewsSDL = /* GraphQL */ `
type Product @key(fields: "id") { id: ID! reviews: [Review!]! }
type Review { id: ID! body: String! product: Product! }
`;
type Product = { id: string; name: string; price: number };
// One query for the whole batch, e.g. SELECT ... FROM products WHERE id IN (...)
declare function fetchProductsByIds(ids: readonly string[]): Promise<Product[]>;
// Build a fresh loader per request so batching and caching never cross users.
export function createContext() {
return {
productLoader: new DataLoader<string, Product | null>(async (ids) => {
const rows = await fetchProductsByIds(ids);
const byId = new Map(rows.map((r) => [r.id, r]));
// DataLoader requires results in the same order as the requested keys.
return ids.map((id) => byId.get(id) ?? null);
}),
};
}
export const productResolvers = {
Product: {
// Runs once per representation in the _entities list, e.g. { __typename: "Product", id: "42" }.
// Each call only enqueues a key; DataLoader collapses them into one fetch.
__resolveReference: (
ref: { id: string },
ctx: ReturnType<typeof createContext>,
) => ctx.productLoader.load(ref.id),
},
};
Two subgraphs share Product through @key(fields: "id"), and the owning products subgraph resolves the batch of representations from one _entities call with a request-scoped DataLoader, so it issues a single lookup instead of one query per review.
Query planning and latency: why plan depth matters
The router does not forward an operation as written. It compiles it into a query plan, a tree of fetches to individual subgraphs. Fetches that share no data dependency go into a Parallel node. A fetch that needs entity keys returned by an earlier fetch goes into a Sequence node. A Flatten node marks where the router merges entity results back into the response at a given path. Each step in a Sequence is a full network round trip: the router waits for the response, extracts the keys and only then issues the next _entities call.
That makes plan depth, the number of sequential steps, the main driver of latency. The number of subgraphs involved matters much less.
Illustrative estimate. Assume 20 ms per subgraph fetch and 5 ms of router overhead. Both plans touch four subgraphs.
| Plan shape | Sequential steps | Latency (illustrative estimate) |
|---|---|---|
| Root fetch, then three chained entity hops | 4 | 4 × 20 + 5 = 85 ms |
| Root fetch, then three entity fetches in parallel | 2 | 2 × 20 + 5 = 45 ms |
To spot a waterfall, read the plan for nested Sequence nodes whose fetches each depend on the previous Flatten path, for example products.@.reviews.@.author. Each level of nesting is one more round trip. Fixes include returning the needed keys from an earlier subgraph so the router can fetch in parallel, and using @provides to skip a hop. Moving a field to the subgraph that already holds the parent entity also removes a hop.
Implementing it: composition as a CI gate and subgraph boundary design
Those fixes are cheap to make before a schema ships and expensive afterward, which is why composition belongs in CI rather than at router startup.
Composition as a gate
Treat the supergraph as a build artifact. Every pull request that touches a subgraph schema should compose that schema with the current published versions of all other subgraphs. If composition fails, the change cannot merge. Composition catches problems no single subgraph can detect alone. Two common ones are a shared field declared Int in one subgraph and String in another, and an entity whose @key fields no subgraph can resolve.
Composition only proves the schemas fit together. It does not prove that clients still work. A second check compares the proposed schema against operations clients actually sent, collected from router traces or a registry of persisted queries. Removing a field nobody queries is safe. Making an argument non-nullable while mobile clients still omit it is a breaking change, even though the schema composes cleanly.
A generic pipeline looks like this:
- Lint the changed subgraph schema.
- Compose it against the published subgraphs.
- Check operations against recent client traffic.
- Publish the subgraph and the resulting supergraph only after all three steps pass.
Exact commands and flags differ between tooling versions, so verify them against the version you run.
Drawing boundaries
Split subgraphs by domain and owning team, not by database table. A subgraph that mirrors one table forces the router to join across the network work that SQL used to do in one query. That adds a fetch, and often a level of plan depth, to every request. Data that is usually read together, such as orders and their line items, belongs in one subgraph. A boundary is in the right place when one team can change everything behind it without coordinating a deploy with another team.
# Generic CI pipeline sketch (not tied to any specific CI system or vendor CLI).
# Each step calls a wrapper script so the exact composition tooling and flags
# can change without changing the pipeline. Verify the commands inside each
# script against the tooling version you actually run.
pipeline:
trigger: pull_request
paths:
- "subgraphs/orders/schema.graphql"
steps:
- name: lint
# Catch naming, style and deprecation-policy problems in this subgraph alone.
run: ./ci/lint-schema.sh subgraphs/orders/schema.graphql
- name: compose
# Compose the proposed orders schema with the currently PUBLISHED versions
# of every other subgraph. Fails on conflicting field types (Int vs String)
# or entities whose @key fields no subgraph can resolve.
run: ./ci/compose-supergraph.sh --proposed orders=subgraphs/orders/schema.graphql --out build/supergraph.graphql
- name: check-operations
# Validate real client operations (from router traces or the persisted
# query registry, e.g. the last 14 days) against the composed supergraph.
# A clean composition can still break clients, such as a newly
# non-nullable argument that mobile apps omit.
run: ./ci/check-operations.sh --supergraph build/supergraph.graphql --operations ops/recent-operations.json
- name: publish
# Runs only on the main branch after all previous steps pass, so the
# router never receives a supergraph that has not been checked.
when: branch == "main"
needs: [lint, compose, check-operations]
run: ./ci/publish.sh --subgraph orders --supergraph build/supergraph.graphql
A vendor-neutral CI pipeline that lints a changed subgraph, composes it against the published subgraphs, checks it against recent client operations, and publishes only after all three steps pass.
Running a supergraph in production
Distributing the supergraph schema
Routers need the composed schema to plan queries. There are two ways to deliver it:
| Approach | How it updates | Strength | Risk |
|---|---|---|---|
| Static file baked into the router deploy | Redeploy the router | Every change is reviewed and versioned like code | Each schema change requires a router rollout |
| Pulled from a registry at runtime | Router polls and hot-reloads | Subgraph teams ship without touching the router | A bad publish reaches every router quickly |
Rollout order depends on the direction of the change. Additive changes deploy the subgraph first and publish the supergraph second, so the router never plans against a field that does not exist yet. Removals go the other way: publish a supergraph without the field, then remove it from the subgraph.
Cross-cutting concerns
The router should validate credentials once and forward identity to subgraphs as headers. Subgraphs then trust those headers, so they must be reachable only from the router. Propagate trace context on every subgraph fetch so that one trace shows the full query plan, including which hop was slow. Set timeouts per subgraph rather than one global value, because a recommendations service and an orders service have different latency budgets.
Partial failure
When a subgraph times out, the router returns whatever data it has, plus entries in errors. Nullability decides how far the failure spreads. A failed nullable field becomes null. A failed non-null field nulls its nearest nullable ancestor, which can be the entire response. Make fields that cross subgraph boundaries nullable unless losing the parent is acceptable.
Federation also gives you no cross-subgraph transactions. A mutation that touches two subgraphs can succeed in one and fail in the other, so compensation logic has to live in the services.
When to use federation and when to avoid it
Federation pays off when the org chart already looks like a graph. Several teams own distinct domains, such as orders, catalog and identity, and clients need to query across them through one endpoint. Each team ships its subgraph schema on its own schedule, and the supergraph absorbs the integration work that client teams would otherwise do by hand. It also needs a platform team that can own the router, the composition pipeline and schema governance. Without that owner, the shared layer becomes a commons nobody maintains.
It is premature for a small team working in a single domain. One well-structured monolithic schema gives you the same client experience without a router, a composition step or a registry. It is also a poor fit when latency budgets are tight and typical queries need deep joins across services. Each level of entity resolution adds a sequential round trip, and no query planner can parallelize a true data dependency.
Trade-offs to accept up front
- An extra hop on the critical path. Every request passes through the router, which adds latency and another component to scale.
- Shared infrastructure as a single point of failure. A router outage or a bad composed schema takes down every domain at once.
- Coordination moves rather than disappears. Teams stop negotiating REST contracts and start negotiating shared types, key fields and ownership of contributed fields.
- Harder debugging. One slow query can span many services, so you need distributed tracing that follows the query plan.
- Hidden load. A popular query can fan out into large batches of entity lookups against a subgraph that never sees the original client request.
- Tooling divergence. Router and library implementations from different vendors differ in directive support and composition rules. Pin your toolchain, and test any migration between implementations.
Alternatives and the impact on cost and engineering time
| Approach | Ownership | Latency | Operational burden | Coordination cost |
|---|---|---|---|---|
| Modular monolithic GraphQL server | One codebase, module owners by convention | In-process resolvers, no network hops between domains | One service to deploy and scale | Shared release train; merge conflicts in the schema |
| Schema stitching | Gateway team owns the merge logic | Extra hop per delegated field | Gateway code grows with every service | Gateway changes for most cross-service links |
| REST or BFF aggregation | Each BFF owned by a client team | Depends on how calls fan out | Many small aggregators | Endpoint changes negotiated per client |
| Federation | Each subgraph team owns its types and fields | Extra hops set by query plan depth | Router, schema registry, tracing | Composition checks replace most negotiation |
Federation saves engineering time when many teams ship against one graph. Each team deploys independently, so a release no longer waits for the slowest team in a shared train. Composition checks reject breaking changes before merge, not after a client fails.
It adds cost in three places. Someone has to own the router and registry, which usually means a platform team. You need distributed tracing to debug plans that cross several services. You also have to plan capacity for entity lookups, because one popular entity can multiply load on the subgraph that resolves it.
Illustrative estimate (assumed numbers, not a benchmark): break-even = (teams × hours lost per team per week to release coordination) − platform hours per week. With 8 teams × 3 hours = 24 hours saved, against 40 hours of platform work, federation costs 16 hours a week net. At 20 teams it saves 20 hours a week net.
If the estimate comes out negative, start with a monolith built around clear domain types. Those type boundaries become your subgraph boundaries when you split it later.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to graphql federation, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.