What federation and schema stitching actually are
Both models give clients one GraphQL endpoint backed by many services. They differ in where the knowledge of how types fit together lives.
Schema stitching builds the combined schema at a gateway. The gateway introspects or loads each remote schema, merges them, and holds the configuration that says how a User from one service relates to a User from another: which field identifies it, which root query fetches it, and how to delegate a sub-selection to the right backend. The services themselves can be unaware they are being combined.
Federation moves that knowledge into the services. Each subgraph's SDL declares the entities it owns or contributes fields to, using directives such as @key to name the fields that identify an entity across subgraphs. A separate composition step validates all subgraph schemas together and produces a supergraph schema. A router then plans and executes queries against that artifact without any hand-written merge logic.
STITCHING FEDERATION
[accounts] [orders] [accounts SDL] [orders SDL]
\ / @key(id) @key(id)
\ / \ /
+------------------+ [ composition step ]
| GATEWAY | |
| merge config | supergraph schema
| delegation rules | |
+------------------+ [ ROUTER ]
knowledge: central knowledge: in each subgraph,
checked at composition
Teams adopt either model for the same reason. Clients get one coherent graph, while services stay independently deployable. The real choice is who maintains the glue: a gateway owner in stitching, or every subgraph team in federation.
STITCHING FEDERATION
[ client ] [ client ]
| |
v v
+---------------------------+ +---------------------------+
| GATEWAY | | ROUTER |
| - remote schemas | | - loads supergraph schema |
| - type-merging config | | - plans + calls subgraphs |
| - delegation rules | | - no hand-written merges |
+---------------------------+ +---------------------------+
| | ^
v v | supergraph schema
+----------+ +----------+ +---------------------------+
| accounts | | orders | | COMPOSITION STEP |
| schema | | schema | | validates all subgraphs |
| as-is | | as-is | +---------------------------+
+----------+ +----------+ ^ ^
| SDL | SDL
+------------+ +------------+
| accounts | | orders |
| type User | | type User |
| @key(id) | | @key(id) |
+------------+ +------------+
knowledge: central, in gateway knowledge: in each subgraph's SDL,
config checked at composition time
Stitching keeps type-merging and delegation rules in central gateway config, while federation puts entity declarations such as @key in each subgraph's SDL and composes them into a supergraph schema that a router executes.
The same User type both ways: accounts and orders
Take two teams. Accounts owns users: id, email, displayName. Orders owns purchases and wants clients to query user { orders { total } } without calling two services.
Federation
The accounts subgraph declares User as an entity by adding @key(fields: "id") to the type. That directive is a contract: any subgraph can refer to a User by id, and accounts promises to resolve the rest. Accounts backs the promise with an entity resolver (a reference resolver, in Apollo Federation terms) that takes a representation like { __typename: "User", id: "42" } and returns the full user.
The orders subgraph declares its own minimal User with the same key and adds orders: [Order!]!. It never stores emails or names. It only needs the id to look up orders. Depending on the federation version, this is a plain type declaration or an extend type. Both teams write federation-aware schemas, and composition fails if their key declarations disagree.
Stitching
Each service publishes an ordinary schema with no federation directives. Accounts exposes a root field such as userById(id: ID!). Orders exposes its own User { id orders } and a lookup root field that returns that type for a given ID.
The gateway's merge configuration holds the knowledge. For User it records that the key selection is { id }, that accounts resolves it through userById, that orders resolves it through its own lookup field, and how to map the key into each field's arguments. Neither service knows it is being merged.
Directive spelling and config shape vary by library and version, so check your implementation's docs before copying either pattern.
// Conceptual stitching gateway, modeled on @graphql-tools/stitch.
// Option names and shapes vary by version, so check your library's docs.
import { stitchSchemas } from '@graphql-tools/stitch';
import { accountsSchema, ordersSchema } from './remote-schemas'; // plain SDL, no federation directives
// accounts: type User { id: ID! email: String! displayName: String! }
// type Query { userById(id: ID!): User }
// orders: type User { id: ID! orders: [Order!]! }
// type Query { ordersUser(id: ID!): User }
export const gatewaySchema = stitchSchemas({
subschemas: [
{
schema: accountsSchema,
merge: {
User: {
selectionSet: '{ id }', // the key, defined by the gateway, not by accounts
fieldName: 'userById', // root field that fetches a User from accounts
args: (user) => ({ id: user.id }), // map the key into the field's arguments
},
},
},
{
schema: ordersSchema,
merge: {
User: {
selectionSet: '{ id }', // same key, so the gateway can join the two User types
fieldName: 'ordersUser', // orders' own lookup for its partial User
args: (user) => ({ id: user.id }),
},
},
},
],
});
// Under federation, the same knowledge lives in the subgraphs instead:
// accounts adds @key(fields: "id") on User plus a reference resolver,
// and orders declares User with the same @key and adds orders.
A conceptual schema-stitching gateway: the gateway's merge config, not the two unmodified services, defines how User is keyed and which root field each service uses to fetch it.
How entity resolution and query planning work
When a query crosses a service boundary, both approaches need a way to fetch "the rest" of an object from another service.
In federation, the router collects key representations: objects containing __typename plus the key fields, such as { __typename: "User", id: "42" }. It sends them in one call to the subgraph's _entities(representations: ...) field, which the subgraph library resolves through your entity reference resolver. In stitching, the gateway delegates to a root field you configured, such as usersByIds(ids: [ID!]!), and maps the keys into its arguments.
Either way, batching is what keeps this affordable. Suppose a list field such as orders returns 50 items and each item needs its User resolved individually. That produces 50 lookups. Federation sends all representations in one request, but the subgraph's reference resolver still runs once per representation unless it uses a dataloader. Stitching needs a batched root field and batching enabled in the gateway configuration. Without them, the N+1 problem moves from inside one service to across the network.
A simplified plan
For { me { orders { total user { name } } } }, the plan is roughly:
- Fetch accounts:
me { id }, which gets the key. - Fetch orders, which depends on step 1:
_entitiesforUser, returningorders { total userId }. - Fetch accounts, which depends on step 2:
_entitiesfor each order'sUser, returningname.
Each step needs keys from the step before it, so the steps run one after another. Every additional cross-service hop in the nesting adds another round trip.
Illustrative estimate: assume 15 ms per subgraph fetch and 5 ms router overhead per step. Three sequential steps take about 60 ms. Nesting one more service level deeper adds about 20 ms, no matter how fast each individual service is.
import DataLoader from "dataloader";
type User = { id: string; name: string };
// Placeholder data access: one query for many ids (e.g. WHERE id = ANY($1)).
declare function findUsersByIds(ids: readonly string[]): Promise<User[]>;
// Create one loader per request so cached results never leak between users.
export function createContext() {
return {
userLoader: new DataLoader<string, User | null>(async (ids) => {
const rows = await findUsersByIds(ids);
const byId = new Map(rows.map((u) => [u.id, u]));
// DataLoader requires one result per key, in the same order as the keys.
return ids.map((id) => byId.get(id) ?? null);
}),
};
}
type Ctx = ReturnType<typeof createContext>;
export const resolvers = {
User: {
// Federation: runs once per representation passed to
// _entities(representations: [...]). The loader coalesces those calls,
// so 50 order users become one database query instead of 50.
__resolveReference(ref: { __typename: "User"; id: string }, ctx: Ctx) {
return ctx.userLoader.load(ref.id);
},
},
Query: {
// Stitching: the gateway maps collected keys into this root field's
// arguments. Reusing the same loader keeps the lookup batched here too.
usersByIds(_: unknown, args: { ids: string[] }, ctx: Ctx) {
return ctx.userLoader.loadMany(args.ids);
},
},
};
A per-request DataLoader backs both the federation entity reference resolver and a stitching-style batched root field, so a list of 50 users becomes one database query instead of 50.
Shared types, field ownership, and operational concerns
Shared types and field ownership
Value types such as Money or an OrderStatus enum often appear in several subgraphs. Federation's composition requires these definitions to be compatible across subgraphs. For enums, the exact rule depends on whether the enum is used as an input, an output, or both. In schema stitching, the gateway's merge configuration decides which definition wins. That makes conflicts quieter but no less real. In both cases, adding an enum value becomes a change that needs coordination between teams.
Field ownership matters more. When two subgraphs can both resolve User.email, someone has to decide which one the planner uses. Federation makes this explicit by requiring shared fields to be marked as shareable. Each field without a clear owner turns into a recurring negotiation: who fixes a stale value, and whose deploy broke it. Assign one owning team per field and record it next to the schema.
Operations
A single response can contain values from three teams' resolvers. Without distributed tracing that runs from the router into each subgraph, plus a way to inspect the query plan, you cannot tell which service produced a wrong value. Treat both as prerequisites for running the supergraph.
Forward auth context and headers explicitly. Do not assume subgraphs will see the client's original request.
When one subgraph fails, the router returns a partial response: nulls in the affected fields plus entries in errors. A null in a non-nullable field propagates up to the nearest nullable parent, so a failure in an optional orders field can wipe out the whole User. Make cross-subgraph fields nullable on purpose so failures stay contained.
Every request passes through the router, which makes it a single point of failure. Run multiple stateless replicas and load-test it as you would any core service.
Making composition a CI gate
Composition errors are cheap to catch before a merge and expensive to catch at runtime. A conflicting field type or a missing @key that reaches production turns into failed query plans or a router that refuses to load the new supergraph. The goal is a check that runs on every subgraph pull request and fails the build when the change would break the whole graph, even if the changed service looks fine in isolation.
With a schema registry
The pipeline publishes the subgraph's schema as a candidate without making it live. The registry composes that candidate with the current version of every other subgraph and reports composition errors. Where the registry records client operations, it also checks the candidate against real traffic. Removing a field nobody queries passes. Removing one a mobile client still sends fails. That traffic check is the main reason to use a registry, because static diffing cannot tell the two cases apart. After the deploy succeeds, a separate step promotes the candidate to current, so the registry always reflects what is actually running.
Without one
Collect every subgraph's SDL. You can keep the schema files in a shared repo, or fetch them from each service's last-deployed artifact during CI. Then run composition locally. If it succeeds, diff the resulting supergraph against the last deployed composition and fail on removed fields, narrowed types, or nullable-to-non-null changes on arguments. Store the deployed supergraph as a versioned artifact so this diff always has a reliable baseline. You lose the usage data, so treat every breaking diff as blocking unless someone explicitly approves it.
# Generic CI pipeline sketch. Each step calls a team-owned script, so no vendor flags are assumed.
name: subgraph-composition-gate
on: [pull_request]
jobs:
composition-check:
steps:
- checkout
# Build the changed subgraph's SDL from source.
- run: ./scripts/export-sdl.sh > build/orders.graphql
# Path A, registry available: publish as a candidate (not live).
# The registry composes it with the current version of every other subgraph
# and, if it records client operations, checks it against real traffic.
- if: env.SCHEMA_REGISTRY_URL != ''
run: ./scripts/registry-check-candidate.sh orders build/orders.graphql
# Path B, no registry: gather every other subgraph's SDL from the shared repo
# or from each service's last-deployed artifact.
- if: env.SCHEMA_REGISTRY_URL == ''
run: ./scripts/fetch-deployed-sdl.sh build/subgraphs/
# Compose locally. Any conflicting field type or missing @key fails here.
- if: env.SCHEMA_REGISTRY_URL == ''
run: ./scripts/compose.sh build/subgraphs/ build/orders.graphql > build/supergraph.graphql
# Diff against the last deployed supergraph, stored as a versioned artifact.
# Fail on removed fields, narrowed types, or arguments changed from nullable to non-null,
# unless the PR carries an explicit approval label.
- if: env.SCHEMA_REGISTRY_URL == ''
run: ./scripts/diff-supergraph.sh artifacts/supergraph-deployed.graphql build/supergraph.graphql
# Runs only after a successful deploy, so the baseline reflects what is actually live.
promote-after-deploy:
needs: [deploy]
steps:
- run: ./scripts/promote.sh orders # registry: candidate -> current; else upload supergraph artifact
A generic CI pipeline that gates each subgraph PR. It runs a registry candidate check when a registry exists, or local composition plus a diff against the last deployed supergraph when it does not, and it promotes the baseline only after deploy.
Choosing by ownership: who should use which, and when to avoid both
The deciding question is who owns each part of the graph and who is allowed to change it. Federation fits when several autonomous teams each own distinct entities and need to ship without asking a central group first. Each team declares its own keys and extensions, so join logic travels with the code that owns the data. The cost is that every subgraph must run a federation-aware library. A service you don't control needs a wrapper subgraph in front of it.
Stitching fits a small team that integrates existing or third-party GraphQL APIs it cannot modify, or that needs gateway-side transforms such as renaming, filtering, or namespacing. Because all join logic lives in the gateway config, every cross-service change routes through whoever owns that config. That recreates the coordination problem of a monolith, just at the gateway instead of in the codebase.
For one team or a few services, a single well-modularized GraphQL service is often the better choice. Module boundaries give you most of the separation without network hops or query planning. If clients mainly need tailored payloads, REST aggregation behind a backend-for-frontend can be simpler still.
| Federation | Stitching | Modular monolith | |
|---|---|---|---|
| Ownership model | Distributed, per entity | Central gateway team | Single team |
| Join logic lives | In subgraphs | In gateway config | In-process resolvers |
| Subgraph requirements | Federation-aware library | Any GraphQL API | None |
| Failure modes | Composition errors, query-plan fan-out | Config drift, gateway bottleneck | Deploy coupling |
| Vendor coupling risk | Higher (spec and router tooling) | Lower | Lowest |
| Best fit | Many independent teams | Unmodifiable upstream APIs | One team, few domains |
Impact on cost and engineering time, and a migration path
Federation front-loads cost. Each team adopts a subgraph library and learns entity semantics. The platform team runs the router as tier-one infrastructure, and someone has to build CI checks and a schema registry before the first subgraph ships. Debugging also gets more expensive. A slow or null field may now trace through several subgraphs owned by different on-call rotations, so distributed tracing across the router and subgraphs becomes a requirement. The return is that teams stop waiting on each other. Once composition is automated, schema changes stop going through cross-team review meetings or a gateway owner's queue. A rough break-even test is to compare the coordination hours you currently lose per release with the ongoing platform and debugging hours federation will add. If your team count is small, federation's overhead often wins.
Weigh vendor coupling explicitly. Implementation-specific directives, a particular router, and a managed registry each make leaving harder. Prefer directives defined in the spec you target, and keep your composition checks runnable without a hosted service.
Migration path
- Wrap the monolith or stitched gateway as a single subgraph. Clients see no change, and you get a working router and composition pipeline before any ownership moves.
- Carve out one entity. Pick an entity with a clear owning team, such as
Order. Define its key in the new subgraph. - Keep the old resolvers authoritative. The legacy subgraph remains the owner until the new one has proven correct output under real traffic.
- Move field ownership deliberately. Transfer fields one at a time, and never leave two subgraphs resolving the same field without a deliberate decision to share it.
- Verify each step. Run composition checks in CI, and review the query plan for your hot operations so you catch added fetch hops before clients feel the latency.
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 vs schema stitching, 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.