StackPractices
advanced By Mathias Paulenko

GraphQL Schema Stitching Pattern

Merge multiple independent GraphQL schemas into a single unified schema that clients can query as one graph.

Overview

Your users team ships a GraphQL API. Your posts team ships another. Product wants a single endpoint where clients fetch a user and their posts in one query — and neither team wants to merge codebases. Schema stitching solves exactly that: you take two independent schemas, merge their type definitions into one gateway schema, and the gateway delegates each field back to the service that owns it.

Each subschema keeps its own types and resolvers, so teams deploy independently. The stitched gateway adds link types (extend type User { posts: [Post!]! }) that create relationships across service boundaries, then forwards field resolution via delegateToSchema. Clients see one graph; internally, the work fans out to the services.

This differs from federation: stitching works at the schema level (merging type definitions), while federation works at the service level (each service contributes slices of a shared graph, coordinated by a query planner). Stitching is lighter-weight but requires manual merge configuration — more on that trade-off below, and in the GraphQL federation guide.

Mermaid flowchart LR diagram

When to Use

  • Two or more teams own separate GraphQL APIs that need a unified entry point
  • Migrating from a monolithic GraphQL schema to distributed services gradually
  • Aggregating third-party GraphQL APIs behind a single gateway
  • When federation isn’t feasible (non-Apollo servers, legacy schemas you can’t modify)

When Not to Use

  • Greenfield projects where federation is available from the start — the query planner and composition tooling will save you manual work
  • Schemas with heavy type conflicts that require complex merge rules
  • Teams that need strict runtime isolation between services (the gateway is a shared process)

Solution

1. Define Subschemas

Each service exposes its own schema independently.

Users Service (users-api)

type User {
  id: ID!
  name: String!
  email: String!
}

type Query {
  user(id: ID!): User
  users: [User!]!
}

Posts Service (posts-api)

type Post {
  id: ID!
  title: String!
  body: String!
  authorId: ID!
}

type Query {
  post(id: ID!): Post
  posts: [Post!]!
}

2. Stitch with @graphql-tools/stitch

import { makeExecutableSchema } from '@graphql-tools/schema';
import { stitchSchemas } from '@graphql-tools/stitch';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';

// Create executors for each subschema
const usersExecutor = buildHTTPExecutor({
  endpoint: 'http://localhost:4001/graphql',
});

const postsExecutor = buildHTTPExecutor({
  endpoint: 'http://localhost:4002/graphql',
});

// Define subschema configs
const usersSubschema = {
  schema: makeExecutableSchema({
    typeDefs: usersTypeDefs,
    resolvers: usersResolvers,
  }),
  executor: usersExecutor,
};

const postsSubschema = {
  schema: makeExecutableSchema({
    typeDefs: postsTypeDefs,
    resolvers: postsResolvers,
  }),
  executor: postsExecutor,
};

3. Add Merged Types with Field Delegation

The gateway extends User to include posts and Post to include author. Field resolvers delegate to the owning subschema.

const linkTypeDefs = `
  extend type User {
    posts: [Post!]!
  }

  extend type Post {
    author: User!
  }
`;

const gatewaySchema = stitchSchemas({
  subschemas: [usersSubschema, postsSubschema],
  typeDefs: linkTypeDefs,
  resolvers: {
    User: {
      posts: {
        selectionSet: '{ id }',
        resolve: (user, _args, context, info) => {
          // Delegate to posts subschema
          return info.mergeInfo.delegateToSchema({
            schema: postsSubschema,
            operation: 'query',
            fieldName: 'posts',
            args: { authorId: user.id },
            context,
            info,
          });
        },
      },
    },
    Post: {
      author: {
        selectionSet: '{ authorId }',
        resolve: (post, _args, context, info) => {
          return info.mergeInfo.delegateToSchema({
            schema: usersSubschema,
            operation: 'query',
            fieldName: 'user',
            args: { id: post.authorId },
            context,
            info,
          });
        },
      },
    },
  },
});

4. Start the Gateway

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const server = new ApolloServer({ schema: gatewaySchema });

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

console.log(`Gateway ready at ${url}`);

5. Query the Unified Graph

Clients query both services through one request:

query {
  user(id: "1") {
    name
    email
    posts {
      title
      body
    }
  }
}

The gateway resolves name and email from the users service, then delegates posts to the posts service using the id field from the user.

Explanation

Trace that query through the gateway. It parses { user { name email posts { title } } } against the stitched schema. user, name, and email belong to the users subschema, so the executor forwards that part of the operation over HTTP. When the response comes back, the gateway sees posts isn’t a users-api field — it lives in the link types — so the User.posts resolver fires. delegateToSchema builds a new operation posts(authorId: "1") for the posts subschema, using the selectionSet: '{ id }' fields from the parent user object. The gateway merges both partial results and returns one JSON payload.

The pieces involved:

  • Subschemas: independent services, each with its own schema and resolvers. The gateway routes operations; it never runs their business logic.
  • Executor: whatever ships the operation to the remote endpoint. HTTP covers almost every case, but anything accepting a GraphQL operation qualifies.
  • Link types: extend type blocks that bolt one service’s fields onto another’s type — the owning service never learns they exist.
  • selectionSet: the contract between parent and delegated field — which fields the stitcher must fetch from the parent so the delegation has its arguments. Forgetting it’s the most common cause of merged fields coming back null.
  • delegateToSchema: forwards a field resolution to the owning subschema and merges the result back into the parent.

Errors and authentication

Errors from a subschema bubble up through the gateway’s errors array alongside data, so a failing posts service won’t take down the user data — but you should map and annotate them at the gateway (the GraphQL error extension pattern covers structured error handling). For auth, forward credentials explicitly: build the executor’s context from the incoming request headers and pass authorization through to each subschema, which enforces its own rules. The gateway is a pass-through, not an auth boundary.

One version note: the legacy graphql-tools mergeSchemas API and the old schema-stitching package are deprecated — @graphql-tools/stitch’s stitchSchemas shown here is the current API, and it pairs with Apollo Server 4.

Variants

Batch Delegation

Instead of resolving posts per user, batch the lookup:

User: {
  posts: {
    selectionSet: '{ id }',
    resolve: async (user, _args, context, info) => {
      const result = await info.mergeInfo.delegateToSchema({
        schema: postsSubschema,
        operation: 'query',
        fieldName: 'postsByAuthorIds',
        args: { authorIds: [user.id] },
        context,
        info,
      });
      return result;
    },
  },
},

For lists of users, use a batch resolver that collects all authorId values and makes one request — the batched resolver pattern covers the general N+1 fix in detail.

Computed Fields

Sometimes the field you want exists in no subschema — compute it at the gateway:

const gatewaySchema = stitchSchemas({
  subschemas: [usersSubschema, postsSubschema],
  resolvers: {
    User: {
      postCount: {
        selectionSet: '{ id }',
        resolve: async (user, _args, context, info) => {
          const posts = await info.mergeInfo.delegateToSchema({
            schema: postsSubschema,
            operation: 'query',
            fieldName: 'posts',
            args: { authorId: user.id },
            context,
            info,
          });
          return posts.length;
        },
      },
    },
  },
});

Type Merging

For schemas that share a type (e.g., both services define User), configure merge rules:

const usersSubschema = {
  schema: usersSchema,
  executor: usersExecutor,
  merge: {
    User: {
      selectionSet: '{ id }',
      fieldName: 'user',
      args: (originalResult) => ({ id: originalResult.id }),
    },
  },
};

Best Practices

  • Keep subschemas small and focused on one domain.
  • Use selectionSet on every merged field to avoid over-fetching.
  • Batch delegations when resolving list fields to avoid N+1 calls.
  • Cache executor results where possible to reduce gateway latency.
  • Document which service owns which type to avoid merge conflicts.
  • Monitor delegation latency — the gateway adds a network hop per delegation, so trace it like any other downstream call.
  • Treat the gateway schema as a real API surface: version link types carefully, because every team that extends a foreign type couples to that service’s release cycle.

Common Mistakes

  • Missing selectionSet: the delegation can’t find the fields it needs on the parent object, and merged fields come back null.
  • Circular dependencies: Service A extends Service B’s type, Service B extends Service A’s type — works but creates infinite resolution loops if not careful.
  • Overlapping root query fields: Two subschemas both defining Query.user(id: ID!) — configure merge rules or rename one.
  • Not batching list delegations: Resolving posts for 50 users one-by-one causes 50 requests to the posts service.
  • Ignoring error propagation: Errors from subschemas need proper mapping at the gateway level — an unmapped subschema failure looks like your gateway’s bug to clients.

See Also

Frequently Asked Questions

What's the difference between schema stitching and federation?

Federation (Apollo Federation) is the current standard for distributed GraphQL: services declare how their types compose and a query planner figures out execution. Stitching merges type definitions manually at the gateway — simpler to bolt onto APIs you don't control, but you own the merge configuration, delegation resolvers, and conflict rules yourself.

Is schema stitching deprecated?

The old graphql-tools mergeSchemas API and the standalone schema-stitching package are. stitchSchemas from @graphql-tools/stitch — used in this pattern — is actively maintained. For new projects where you control all services, evaluate federation first; stitching remains the practical choice for third-party or legacy schemas.

Can I stitch REST APIs?

Yes. Wrap REST endpoints as GraphQL subschemas using @graphql-tools/wrap and a custom executor that translates GraphQL operations to HTTP calls.

How does stitching handle authentication?

The gateway extracts auth tokens from incoming requests and forwards them to subschemas via the executor's context. Each subschema enforces its own authorization rules — the gateway passes credentials through, it doesn't make authorization decisions.

How does stitching affect performance?

Each delegation adds a network round-trip. Use batching, caching, and DataLoader at the gateway level to minimize calls. For high-traffic scenarios, consider federation with its query planning instead.