Where composition logic lives: the core difference

Teams compose service APIs so clients see one graph instead of a dozen backends. A mobile screen that needs a user, their orders, and shipping status should send one query to one endpoint. It should not need to know which team owns which field. Both schema stitching and federation provide that single client-facing graph. They differ in where the knowledge of how types fit together is kept.

In schema stitching, the GraphQL gateway owns composition. It fetches or loads each service's schema. Gateway-side configuration then declares which types merge, which fields identify an entity, and how to delegate a field to the service that resolves it. The backend services can be ordinary GraphQL servers with no awareness that they are being combined.

In Apollo Federation, each subgraph owns its share of composition. Subgraphs annotate their own schemas with directives such as @key, which declares how an entity is identified and extended across services. A composition step merges these into a supergraph schema. The router loads that artifact and plans queries against it, without holding hand-written merge logic.

 Schema stitching                 Federation

 Service A   Service B            Subgraph A   Subgraph B
 (plain)     (plain)              (@key ...)   (@key ...)
     \         /                       \          /
      v       v                         v        v
 +----------------------+          [ composition step ]
 | Gateway              |                  |
 |  - merge config      |                  v
 |  - delegation rules  |          supergraph artifact
 +----------------------+                  |
           |                               v
        clients                  +-------------------+
                                 | Router            |
                                 |  - query planner  |
                                 +-------------------+
                                           |
                                        clients

The rest of the comparison follows from this split. It determines who edits what when a type changes, and when mistakes surface.

 SCHEMA STITCHING                      APOLLO FEDERATION

 +-----------+  +-----------+         +-------------+  +-------------+
 | Service A |  | Service B |         | Subgraph A  |  | Subgraph B  |
 | (plain    |  | (plain    |         | type User   |  | type User   |
 |  schema)  |  |  schema)  |         | @key(id)    |  | @key(id)    |
 +-----------+  +-----------+         +-------------+  +-------------+
       |  schemas     |                      |   SDL + directives  |
       v              v                      v                     v
 +----------------------------+        +----------------------------+
 | Gateway                    |        | Composition step           |
 |  - type merge config       |        |  (build time, not runtime) |
 |  - entity key selection    |        +----------------------------+
 |  - delegation rules        |                      |
 |  (composition lives HERE)  |                      v
 +----------------------------+        +----------------------------+
       ^              |                | Supergraph schema artifact |
       | query        | delegate       +----------------------------+
       |              v                              | loaded by
 +-----------+   Service A / B                       v
 |  Clients  |   (runtime calls)       +----------------------------+
 +-----------+                         | Router                     |
                                       |  - plans queries from      |
                                       |    supergraph, no merge    |
                                       |    logic of its own        |
                                       +----------------------------+
                                             ^               |
                                       query |               | fetch
                                       +-----------+   Subgraph A / B
                                       |  Clients  |   (runtime calls)
                                       +-----------+

In schema stitching the gateway holds the merge and delegation config. In federation each subgraph declares its part with directives such as @key, and the router runs a prebuilt supergraph artifact.

How it works: one User type across two services in both models

Federation

In the accounts service, User is declared with @key(fields: "id"). That marks it as an entity and makes id the field other services use to refer to it. The orders service declares a stub User with the same key and adds orders: [Order]. It never sees a user's name or email. It only needs the id.

When a query asks for user { name orders { total } }, the router fetches name from accounts. It then sends orders a list of representations, each a typename plus key, such as { __typename: "User", id: "42" }, through the subgraph's entity field. Each subgraph implements a reference resolver that turns one representation into an object. That resolver is the contract the router depends on.

Stitching

In stitching, the gateway carries the equivalent knowledge as merge configuration. For each subschema that contributes to User, the config names a root field that fetches users by key, for example usersByIds(ids: [ID!]!). It also names the key selection ({ id }) and how to turn collected keys into arguments. The services themselves need no directives.

Batching and N+1

Both models can group N keys into one downstream request. Federation sends a list of representations. Stitching can batch keys into one list-valued call. This does not fix N+1 on its own. If the reference resolver looks up each representation individually, one router request still becomes N database queries. A per-request batching loader is still required.

Tests rarely catch this. A single-user test looks fine. In production, clients nest lists (orders → customer → recentOrders) and the call count multiplies at every level.

import DataLoader from "dataloader";

type Order = { id: string; userId: string; total: number };
type Db = { ordersForUsers(ids: readonly string[]): Promise<Order[]> };

// Orders subgraph: build one loader per request so every User the router
// asks about in that request shares a single DB query instead of N.
export function makeContext(db: Db) {
  return {
    ordersByUser: new DataLoader<string, Order[]>(async (userIds) => {
      // One query, e.g. SELECT * FROM orders WHERE user_id = ANY($1)
      const rows = await db.ordersForUsers(userIds);
      // Results must line up with the keys; [] for users with no orders.
      return userIds.map((id) => rows.filter((r) => r.userId === id));
    }),
  };
}
type Ctx = ReturnType<typeof makeContext>;

export const resolvers = {
  User: {
    // Router sends { __typename: "User", id: "42" }. Keep this cheap:
    // the orders subgraph only owns the key, not name or email.
    __resolveReference: (ref: { id: string }) => ({ id: ref.id }),
    // N+1 trap: calling the DB directly here runs once per user and only
    // shows up when clients nest lists. Go through the loader instead.
    orders: (user: { id: string }, _args: unknown, ctx: Ctx) =>
      ctx.ordersByUser.load(user.id),
  },
};

// Stitching equivalent: the gateway holds this merge config for the
// accounts subschema; the service itself needs no directives.
export const accountsMerge = {
  User: {
    fieldName: "usersByIds",            // upstream root field that fetches by key
    selectionSet: "{ id }",             // key the gateway collects first
    key: (user: { id: string }) => user.id,
    argsFromKeys: (ids: string[]) => ({ ids }), // N keys -> one list-valued call
  },
};

Conceptual sketch of a federation reference resolver that batches per-request lookups through a loader to avoid N+1, alongside the equivalent stitching merge config naming the usersByIds root field and the id key.

The supergraph, build-time composition, and query planning

In federation, the gateway never discovers this at runtime. Composition runs before deployment. Each subgraph publishes its schema, and a composition step merges them into the supergraph: one schema that also records which service owns each field and which keys join entities across services. If two subgraphs define User.email with incompatible types, or one references User without a resolvable key, the merge fails in CI and blocks the deploy. The same mistake under stitching often shows up only when a query hits the broken path in production.

The supergraph is a single file, so it can be versioned, diffed, and rolled back like any other release. This depends on a schema registry or a CI pipeline that composes and checks every subgraph change against the current production supergraph. Teams that skip this step still carry federation's directives and gateway, but they lose the main safety benefit.

Reading a query plan

The gateway turns each operation into a plan of fetches. Fetches in a parallel block run concurrently. Fetches in a sequence block wait for earlier results. For orders { customer { name } }, a typical plan looks like this:

  1. Sequence
    1. Fetch from orders: return order IDs and customer { id }.
    2. Fetch from users, with entities supplied as representations: resolve name for those IDs.

Step 2 can't start until step 1 returns, because it needs the keys. Every entity boundary in a query path adds one sequential hop. When you review a plan, count the nested sequence levels, not the total number of fetches.

Illustrative estimate (assumed numbers, not a benchmark): latency ≈ sequential hops × per-hop round trip. If you assume 4 hops at 25 ms each, the cost is about 100 ms before any resolver work starts. Flattening one boundary removes about 25 ms.

Sequences that cross back into a subgraph the plan already called are waterfalls. Review them first.

# Query plan for: query { orders { id customer { name } } }
# Annotations (#) are added for reading; the gateway does not emit them.
QueryPlan {
  Sequence {                          # hop 1 must finish before hop 2 starts
    Fetch(service: "orders") {        # hop 1: root field owned by orders
      {
        orders {
          id
          customer {
            __typename                # orders only knows the key, not name
            id                        # @key(fields: "id") on User
          }
        }
      }
    },
    Flatten(path: "orders.@.customer") {  # merge results back into each order
      Fetch(service: "users") {       # hop 2: entity fetch via _entities
        {
          ... on User {               # representations sent: {__typename, id}
            __typename
            id
          }
        } =>
        {
          ... on User {
            name                      # field owned by users
          }
        }
      },
    },
  },
}
# Review: 1 Sequence with 2 levels = 2 sequential hops.
# Each extra entity boundary in the path (e.g. customer -> address)
# adds another Flatten/Fetch level inside the Sequence.
# Sibling fetches with no data dependency appear in a Parallel block
# and cost one hop together, not one hop each.

An annotated federation query plan showing how the entity boundary between the orders and users subgraphs forces a sequential second fetch, and which nesting levels to count when looking for waterfalls.

Ownership, change management, and services you don't control

Federation distributes the schema across teams. Each team publishes its subgraph schema on its own schedule, and composition runs as a check: if a change removes a field another subgraph depends on, or declares an entity key that conflicts with another service's, composition fails before anything reaches the gateway. The supergraph changes when a subgraph publishes. Nobody else has to edit a shared file.

Stitching keeps the schema in one place. Type merges, key selections, and delegation rules live in the gateway's configuration. When the orders team adds a field that resolves through users, someone has to edit gateway code. That makes the gateway a coordination point. Two unrelated changes can collide in the same merge config, and a review queue on one repository ends up gating releases for every team.

The trade-off reverses for services you don't control. Stitching can wrap a third-party or legacy GraphQL API as it stands, because the merge logic lives at the gateway. Federation requires subgraphs to expose federation-specific fields and directives, such as entity keys and the entity resolution entry point. If you can't modify the upstream, you usually put an adapter subgraph in front of it, which is another service to run and keep in sync.

Federation support also differs across language ecosystems. Before committing, check that your server libraries implement the directives you need.

Both models let the gateway hide the real upstream contract. Stitching transforms and renames can make the public schema differ from what services actually expose. When something breaks, debugging means translating between the two.

Shared risks: latency, authorization, and partial failures

Both models resolve a single client query by calling several services, so every field that crosses a service boundary adds at least one network hop. The gateway can batch entity lookups and run independent fetches in parallel. It cannot parallelize a dependency chain: if resolving Order.customer requires the orders service to answer before the users service can be asked, those calls are sequential. Deeply nested cross-service selections turn into waterfalls, and caching or batching only shortens each step.

Choosing entity boundaries

Draw boundaries around data that is usually read together, not around org charts. A useful test is to take your five most common queries and count the sequential service hops each one needs. If a frequent query hops back and forth between two services, the boundary is probably in the wrong place. Contributing a field to another service's entity is cheap when the key is already in hand. It is expensive when computing it needs another lookup.

Authorization and errors span services

The gateway can check identity, but field-level rules usually belong to the owning service. Propagate the caller's identity to every subgraph and enforce access there, so that a service is never relying on the gateway to have filtered its data.

When one service fails, the response can contain both data and errors. Clients should:

  • Read each error's path to find out which fields are missing, rather than treating any error as a total failure.
  • Expect null where a field failed. Declare cross-service fields nullable, because a null in a non-null field propagates up to the nearest nullable parent and can wipe out unrelated data.
  • Render degraded UI for the affected sections and keep the rest.

Who should use which, when to avoid both, and the alternatives

Five signals usually decide this:

  • Owning teams. Federation pays off when several teams each own part of the graph and need to ship without coordinating every release. With one team, its contracts and tooling are mostly overhead.
  • Can you modify upstream services? Federation requires each subgraph to declare entity keys and resolvers. If a service belongs to a vendor or a frozen legacy system, stitching can wrap it as it is.
  • Build-time schema checks. If breaking changes must be caught in CI before deploy, federation's composition step gives you that check. Stitching usually surfaces conflicts when the gateway starts or at query time, unless you build your own validation.
  • Tolerance for gateway logic. Stitching places merge and delegation code in the gateway, so one team ends up owning everyone's integration logic. Federation keeps that logic in the subgraphs.
  • Existing tooling. A working stitching gateway with stable services rarely justifies a rewrite.

Avoid both if one team runs two or three services. A single GraphQL service that calls those services internally, or a thin backend-for-frontend, removes the gateway hop, the query planner, and the composition pipeline.

Approach Best fit Upstream changes needed Schema checks Where complexity lives
Federation Many teams, shared entities Yes Build-time composition Subgraphs and router
Stitching Services you can't change No Startup or custom Gateway
Single GraphQL service One team, few services No Ordinary CI One codebase
Backend-for-frontend One client with specific screens No Contract tests Per-client layer

Impact on cost and engineering time, and migration paths

Federation charges you up front. Each subgraph team learns the entity and key directives, someone runs a schema registry, and CI has to compose the supergraph and check it against recent client operations before any deploy. Stitching starts cheaper: one gateway team writes merge configuration and nothing changes in the services. The catch is that every cross-service change then waits on that one team, so coordination cost grows with the number of teams contributing.

The time federation saves comes from two places. Composition errors and breaking field removals fail in CI instead of in production. Teams also deploy their subgraphs without asking the gateway owners for a release.

Illustrative estimate (assumed numbers, not a benchmark). Assume 6 contributing teams, each making 4 cross-service schema changes per month. Assume each change costs 3 hours of gateway-team coordination under stitching and 0.5 hours under federation, and that federation adds 40 hours per month of registry and CI upkeep.

Monthly savings = teams × changes × (stitching hours − federation hours) − upkeep

6 × 4 × (3 − 0.5) − 40 = 20 hours per month in federation's favor. With 2 teams, the same formula gives −20 hours, so stitching is cheaper at that size.

Migration paths

Whichever direction you migrate, start the same way: list the shared entities and agree on their key fields with every team that owns part of them. If two services identify a User differently, neither model can join them cleanly.

Stitching to federation. Convert services to subgraphs one at a time. During the transition, keep the stitching gateway in front, and have it treat the federated subgraphs as one more remote schema while the remaining services catch up.

Federation to stitching. Move the entity resolution rules into gateway merge configuration, then remove the federation directives from each service.

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.