What problem do federation and schema stitching solve?

Once a company splits its backend into services, the types clients care about stop living in one place. The users service owns a User's name and email. The orders service owns Order records and knows which user placed each one. A mobile client asking for "this user and their last five orders" doesn't care about that split, and it shouldn't have to make two round trips and join the results itself.

Federation and schema stitching both put a GraphQL gateway in front of those services, which are called subgraphs. The gateway exposes one schema to clients. For each incoming query, it builds a plan: which fields come from which subgraph, in what order, and which keys (such as a user ID) to pass between them. It runs those subqueries and merges the responses into the shape the client requested.

                 query { user(id: 1) { name orders { total } } }
  Client ───────────────────────────────►  Gateway
                                          │      │
                     { user(id:1){name} } │      │ { ordersByUser(1){total} }
                                          ▼      ▼
                                 Users service   Orders service

Both approaches produce this same runtime shape. The real difference is where the composition logic lives and which team owns it. In federation, each subgraph declares how its types join the graph. In stitching, the gateway holds that knowledge in its merge configuration.

There are two other options. One is a single monolithic schema that calls the services internally. The other is a hand-written backend-for-frontend (BFF) that aggregates data per client. Both work, but every cross-service join then becomes code that one team writes and maintains.

                        ┌──────────────┐
                        │    Client    │
                        └───┬────────┬─┘
                            │        ▲
  1. user(id: 1) { name     │        │  4. one merged response
     orders { total } }     │        │
                            ▼        │
          ┌──────────────────────────────────────────────┐
          │                GraphQL Gateway               │
          │  ┌──────────────┐        ┌────────────────┐  │
          │  │ Query planner│ ─────► │ Result merger  │  │
          │  └──────────────┘        └────────────────┘  │
          └───────┬────────────────────────────┬─────────┘
   2. user(id: 1) │                            │ 3. orders for user 1
      { id name } │                            │    { total }
                  ▼                            ▼
       ┌────────────────────┐       ┌────────────────────┐
       │   Users subgraph   │       │  Orders subgraph   │
       │   owns User.name,  │       │  owns Order,       │
       │   User.email       │       │  Order.userId      │
       └────────────────────┘       └────────────────────┘

The client sends one query, and the gateway plans subqueries to the users and orders subgraphs, passes the user ID between them, and merges the results into a single response.

How each model works: entities in subgraphs vs merge config in the gateway

Both models move that join logic out of hand-written resolvers and into a declarative description. They differ in who writes that description and where it lives.

Federation: ownership declared in the subgraph

In Apollo Federation, each subgraph marks some types as entities and declares their key fields, such as User keyed by id. Another subgraph can extend the same entity and contribute fields to it. For example, the orders service adds User.orders without owning User. Each subgraph also exposes an entity lookup resolver. Given a representation that holds a typename and its key values, this resolver returns the object.

A composition step merges all subgraph schemas into a single supergraph schema. It records which subgraph resolves which field. At runtime, a router or gateway turns each incoming query into a query plan and batches entity representations to each subgraph. The join knowledge sits next to the data it describes, so the team that owns a field also owns how that field is reached.

Stitching: ownership declared in the gateway

With schema stitching, the gateway loads subschemas. A subschema can be a local executable schema or a remote one reached through an executor function that sends requests over HTTP. Gateway-side merge configuration describes how each type is joined:

  • which root field fetches the type by key,
  • which selection set the gateway must fetch before calling that field,
  • how to build that field's arguments from the key.

The subservices can stay unaware that they are part of a larger graph.

Aspect Federation Schema stitching
Where join rules live Subgraph schemas Gateway merge config
Subgraph awareness Must implement the federation spec Can be plain GraphQL
Combined schema Composed supergraph artifact Built in gateway process
Entity fetch Standard lookup resolver Any root field you configure

Directive names and config options have changed across releases. Check current library documentation for exact syntax.

Implementing the same users and orders graph both ways

The two services are the same in both versions. A users service owns id, name, and email. An orders service owns id, total, and the ID of the user who placed each order. The snippets that follow are illustrative only. Directive names, helper functions, and config keys vary by library version.

Federated version

In the orders subgraph, the schema declares a User stub keyed on id, using @key(fields: "id"). Order.user returns that stub, and its resolver returns only { __typename: "User", id }. The users subgraph declares the same key and implements __resolveReference, which loads a full user from a reference. Both services know that User is an entity. The relationship is written into the subgraph schemas, so the gateway can work out the join from the composed supergraph without any join logic of its own.

Stitched version

Neither service imports a federation library. The users service exposes an ordinary root field, preferably a batched one like usersByIds(ids: [ID!]!). The orders service exposes Order.userId. The gateway's merge config says that User is fetched through usersByIds, keyed by id. The gateway also extends Order with a user field that requires userId from the orders service and delegates to the users service. The join exists only in gateway code.

Exercise

Add User.avatarUrl. In the federated version, the users team changes its subgraph and the next composition picks the field up. In the stitched version, the users team adds the field, and the gateway usually needs no change as long as it introspects schemas. Next, add Order.user.lastOrderAt. Trace which repository changes in each model, and who reviews that change.

// Illustrative stitched gateway (graphql-tools style); option names vary by version.
import { stitchSchemas } from '@graphql-tools/stitch';
import { batchDelegateToSchema } from '@graphql-tools/batch-delegate';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import { schemaFromExecutor } from '@graphql-tools/wrap';

const usersExec = buildHTTPExecutor({ endpoint: '' });
const ordersExec = buildHTTPExecutor({ endpoint: '' });

// Plain services: the gateway introspects them, so new fields show up without edits here.
const usersSubschema = {
  schema: await schemaFromExecutor(usersExec),
  executor: usersExec,
  merge: {
    // How the gateway fetches a User by id: one batched call per request.
    User: {
      selectionSet: '{ id }',
      fieldName: 'usersByIds',
      key: ({ id }) => id,
      argsFromKeys: (ids) => ({ ids }),
    },
  },
};
const ordersSubschema = { schema: await schemaFromExecutor(ordersExec), executor: ordersExec };

export const gatewaySchema = stitchSchemas({
  subschemas: [usersSubschema, ordersSubschema],
  // The Order -> User join lives only here, owned by whoever owns the gateway.
  typeDefs: 'extend type Order { user: User }',
  resolvers: {
    Order: {
      user: {
        selectionSet: '{ userId }', // always fetch userId from orders, even if the client didn't ask
        resolve: (order, _args, context, info) =>
          batchDelegateToSchema({
            schema: usersSubschema, operation: 'query', fieldName: 'usersByIds',
            key: order.userId, argsFromKeys: (ids) => ({ ids }), context, info,
          }),
      },
    },
  },
});

An illustrative schema-stitching gateway where the services stay plain and the gateway config declares how User is fetched by id through usersByIds and how Order.user is joined using userId; exact option names vary by graphql-tools version.

Where composition happens, when it fails, and how batching affects performance

Federation moves composition to build time. Each subgraph publishes its schema, and composition tooling merges them into a supergraph in CI before anything ships. If the orders team declares User.lastOrderAt with a type that conflicts with the users subgraph, or references a key that no subgraph can resolve, the pipeline fails and the change never reaches production. Stitching usually composes inside the gateway process at startup or on schema reload. A bad merge config can therefore pass review and only fail when the gateway boots, or it can boot cleanly and return nulls at query time. To get comparable safety, add a CI step that builds the stitched schema and runs representative queries against it.

Both models avoid N+1 fan-out by batching entity lookups. A federation gateway collects every User reference from the orders response and sends one _entities request with a list of representations. Stitching does the same through batch delegation, or through merge fields that accept a list of keys and return an array. If you skip that setup, stitching sends one request per record.

Illustrative estimate. Assume 20 ms per hop, a two-step plan (orders, then users), 50 orders, and 10 concurrent subrequests per step:

  • Batched: 2 × 20 ms = 40 ms
  • Unbatched: 20 ms + (50 ÷ 10) × 20 ms = 120 ms, plus 50 subrequests of load on the users service

For any important cross-service query, inspect the query plan or log the subrequests. Batching can't rescue a chatty boundary, though. If every orders query needs three sequential hops into users, composition will execute that plan correctly, and the fix is to redraw the service boundary.

Running it in production with Node.js

Both models fail the same way in production: one team ships a schema change that breaks the composed graph or a query clients already depend on. Catch it before deploy. In CI, compose the proposed subgraph schema against the currently published schemas of every other subgraph, then check the result against operations recently seen in production. Removing a field, changing its type, or making an argument required should fail the build. A schema registry centralizes this. Without one, a repository that holds each subgraph's published SDL is workable for a few teams.

The gateway also has to learn about the new schema. It can poll an artifact store and hot-swap, receive a push, or load the schema at startup and be restarted. Hot reload avoids redeploys, but replicas may briefly serve different schemas. A restart is easier to reason about and to roll back.

For tracing, one client query fans out into several subrequests. Propagate W3C trace context headers from the gateway to every subgraph so their spans nest under a single trace. Tag each span with the subgraph name so you can see which hop was slow.

When a subgraph is down, the gateway returns partial data plus errors entries for the failed paths. Nullability decides how far the damage spreads. A null in a non-null field bubbles up to the nearest nullable parent. If the chain is non-null all the way to the root, the entire response becomes null. Declaring cross-subgraph fields such as Order.user nullable means a users outage blanks one field instead of failing every orders query.

Managed registries and some router features may require a license or a hosted service. Evaluate those as a separate decision from the architecture.

Impact on development cost and engineering time

The real cost question is who pays for composition, and when.

Federation spreads the cost across teams. Every service team has to learn entities, @key fields, reference resolvers and the rules for shared types, and each new team pays that learning cost again. The payoff is autonomy. A team that adds a field to Order publishes its subgraph, composition checks pass, and the change ships without any other team involved.

Stitching concentrates the cost in one place. Service teams expose plain GraphQL schemas, and a single gateway team owns the merge config, type merging and transforms. This is cheaper to start. It is also the fastest way to wrap an existing or legacy schema you cannot change, because you can rename, filter or merge types in the gateway without touching the service. The cost grows with the number of merged types, since every subgraph change that touches one waits in the gateway team's queue.

Cost factor Federation Stitching
Who learns the model Every service team Gateway team
Cost to start Higher Lower
Cross-team handoffs per change Few One per merged-type change
Wrapping legacy schemas Requires subgraph changes Done in gateway config

Illustrative estimate (assumed numbers, not a benchmark): monthly wait = changes × share touching merged types × queue days. Assume 40 changes per month, 25% of them touching merged types, and a 2-day queue: 40 × 0.25 × 2 = 20 team-days of waiting per month under stitching. Compare that figure with federation's onboarding cost, which is new teams × ramp-up days.

Switching models later is expensive. Entity resolution logic has to move between the gateway config and the subgraph code, and every merged type has to be rewritten and retested. Treat the choice as a medium-term commitment.

When to use federation, when to use stitching, and when to avoid both

Start with who will own the composed schema, not with which library is newer. The ownership model decides whether the hard part lives in subgraph code or in gateway code.

Situation Federation Schema stitching Neither
Many autonomous teams, each owning a domain Strong fit. Teams declare entities and keys in their own subgraphs. Weak fit. Every cross-team change needs a gateway edit. No
One platform team, a handful of services Works, but spec overhead may outweigh benefits Strong fit. One team owns merge config end to end. Consider it
Third-party, legacy or non-GraphQL sources Needs an adapter subgraph per source Strong fit. Rename, filter and namespace types at the gateway. No
Preference for spec-bound tooling and composition checks Strong fit Weaker. Correctness lives in your code and tests. No
One backend or a single client app Overkill Overkill A single schema or a small BFF

When to avoid each

Avoid stitching when the gateway team cannot keep up with merge config. If every new field or key change waits in that team's queue, the gateway becomes the bottleneck that federation was designed to remove.

Avoid federation when most of your sources cannot implement the subgraph spec. Wrapping each one in an adapter service adds deployables, latency and on-call surface. Stitching handles those transformations in one place.

Avoid both when one schema or a small BFF is enough. Composition adds a network hop, a planner you have to understand, and failure modes across services. That cost only pays off when independent ownership or heterogeneous sources make one schema impractical.

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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.