MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

GraphQL Schema Stitching: Combining Multiple APIs

Combine multiple GraphQL APIs into a unified schema with schema stitching and federation.

GraphQLSchema StitchingAPIMicroservices

By MinhVo

Introduction

Modern applications rarely rely on a single data source. They combine user data from one service, product catalogs from another, payment information from a third, and perhaps legacy REST APIs from a fourth. Schema stitching solves this problem by combining multiple GraphQL schemas into a single unified schema that clients can query as if it were one API.

While Apollo Federation has become the more popular choice for new projects, schema stitching remains a powerful technique—especially when you need to combine schemas you don't own, integrate REST APIs, or work with heterogeneous data sources. In this guide, we'll implement schema stitching from scratch, explore advanced patterns, and understand when to choose stitching over federation.

Microservices architecture diagram

Understanding Schema Stitching: Core Concepts

How Schema Stitching Works

Schema stitching takes multiple independent GraphQL schemas and merges them into a single schema. The gateway receives client queries, determines which sub-schemas are involved, routes requests to the appropriate services, and combines the results. Unlike federation, which requires special directives in sub-schemas, stitching can work with any GraphQL schema—including third-party APIs.

The stitching process involves three steps. First, the gateway introspects or loads all sub-schemas. Second, it merges the types, resolving conflicts and connecting related types across schemas. Third, it creates a merged schema with resolvers that delegate queries to the appropriate sub-schemas.

Stitching vs. Federation

Schema stitching and federation solve the same problem—combining distributed schemas—but with different tradeoffs. Stitching is more flexible: it works with any GraphQL schema without modifications. Federation requires sub-schemas to use federation directives. However, federation provides better entity resolution, automatic batching, and a more opinionated architecture.

FeatureSchema StitchingApollo Federation
Works with existing schemasYesNo (needs directives)
Entity resolutionManualAutomatic
BatchingManual (DataLoader)Built-in
Type conflictsManual resolutionAutomatic composition
Learning curveModerateSteep
Best forLegacy integrationNew microservices

Type Merging

Type merging is the core feature of schema stitching. When two schemas define the same type (like User), stitching merges them into a single type with fields from both schemas. The gateway needs to know how to resolve the merged type from either schema:

import { stitchSchemas } from "@graphql-tools/stitch";
 
const gatewaySchema = stitchSchemas({
  subschemas: [
    {
      schema: usersSchema,
      merge: {
        User: {
          fieldName: "user",
          selectionSet: "{ id }",
          args: (originalObject) => ({ id: originalObject.id }),
        },
      },
    },
    {
      schema: ordersSchema,
      merge: {
        User: {
          fieldName: "user",
          selectionSet: "{ id }",
          args: (originalObject) => ({ id: originalObject.id }),
        },
      },
    },
  ],
});

Architecture and Design Patterns

The Gateway Pattern

The gateway sits between clients and sub-schemas. It receives client queries, creates an execution plan, routes requests to the appropriate services, and assembles the results:

import { stitchSchemas } from "@graphql-tools/stitch";
import { schemaFromExecutor } from "@graphql-tools/wrap";
 
async function createGateway() {
  const usersSchema = await schemaFromExecutor(usersExecutor);
  const ordersSchema = await schemaFromExecutor(ordersExecutor);
  const productsSchema = await schemaFromExecutor(productsExecutor);
 
  return stitchSchemas({
    subschemas: [
      { schema: usersSchema, executor: usersExecutor },
      { schema: ordersSchema, executor: ordersExecutor },
      { schema: productsSchema, executor: productsExecutor },
    ],
    typeMergingConfig: {
      User: {
        selectionSet: "{ id }",
        fieldName: "user",
        args: (originalObject) => ({ id: originalObject.id }),
      },
    },
  });
}

The Executor Pattern

An executor is a function that takes a GraphQL request and returns a response. For remote schemas, this is typically an HTTP request. For local schemas, it's a direct execution:

import { fetch } from "cross-fetch";
import { print } from "graphql";
 
function createRemoteExecutor(url: string) {
  return async ({ document, variables, context }) => {
    const query = print(document);
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: context?.authToken || "",
      },
      body: JSON.stringify({ query, variables }),
    });
    return response.json();
  };
}

Schema Extension Pattern

When you need to add fields that span multiple schemas, use schema extensions to connect types across sub-schemas:

import { stitchSchemas } from "@graphql-tools/stitch";
 
const gatewaySchema = stitchSchemas({
  subschemas: [usersSchema, ordersSchema],
  typeDefs: `
    extend type User {
      orders: [Order!]!
      totalSpent: Float!
    }
  `,
  resolvers: {
    User: {
      orders: {
        selectionSet: "{ id }",
        resolve(user, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: ordersSchema,
            fieldName: "ordersByUserId",
            args: { userId: user.id },
            context,
            info,
          });
        },
      },
      totalSpent: {
        selectionSet: "{ id }",
        async resolve(user, args, context, info) {
          const orders = await info.mergeInfo.delegateToSchema({
            schema: ordersSchema,
            fieldName: "ordersByUserId",
            args: { userId: user.id },
            context,
            info,
          });
          return orders.reduce((sum, order) => sum + order.total, 0);
        },
      },
    },
  },
});

Schema stitching flow

Step-by-Step Implementation

Setting Up Sub-Schemas

Create independent GraphQL services that each own their domain:

// Users service (users-server.ts)
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { gql } from "graphql-tag";
 
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    avatar: String
    createdAt: String!
  }
 
  type Query {
    user(id: ID!): User
    users(limit: Int, offset: Int): [User!]!
  }
`;
 
const resolvers = {
  Query: {
    user: (_, { id }) => db.users.findById(id),
    users: (_, args) => db.users.findAll(args),
  },
};
 
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4001 } });
// Orders service (orders-server.ts)
const typeDefs = gql`
  type Order {
    id: ID!
    userId: ID!
    items: [OrderItem!]!
    total: Float!
    status: OrderStatus!
    createdAt: String!
  }
 
  type OrderItem {
    productId: ID!
    quantity: Int!
    price: Float!
  }
 
  enum OrderStatus {
    PENDING
    CONFIRMED
    SHIPPED
    DELIVERED
  }
 
  type Query {
    order(id: ID!): Order
    ordersByUserId(userId: ID!, limit: Int): [Order!]!
  }
`;

Creating the Stitched Gateway

Combine the sub-schemas into a unified gateway:

import { stitchSchemas } from "@graphql-tools/stitch";
import { createRemoteExecutor } from "./executors";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
 
async function createGateway() {
  const usersExecutor = createRemoteExecutor("http://localhost:4001/graphql");
  const ordersExecutor = createRemoteExecutor("http://localhost:4002/graphql");
  const productsExecutor = createRemoteExecutor("http://localhost:4003/graphql");
 
  const gatewaySchema = stitchSchemas({
    subschemas: [
      {
        schema: await schemaFromExecutor(usersExecutor),
        executor: usersExecutor,
        merge: {
          User: {
            selectionSet: "{ id }",
            fieldName: "user",
            args: (obj) => ({ id: obj.id }),
          },
        },
      },
      {
        schema: await schemaFromExecutor(ordersExecutor),
        executor: ordersExecutor,
      },
      {
        schema: await schemaFromExecutor(productsExecutor),
        executor: productsExecutor,
      },
    ],
    typeDefs: `
      extend type User {
        orders: [Order!]!
      }
      extend type Order {
        user: User!
      }
    `,
    resolvers: {
      User: {
        orders: {
          selectionSet: "{ id }",
          resolve(user, args, context, info) {
            return info.mergeInfo.delegateToSchema({
              schema: ordersExecutor,
              fieldName: "ordersByUserId",
              args: { userId: user.id },
              context,
              info,
            });
          },
        },
      },
      Order: {
        user: {
          selectionSet: "{ userId }",
          resolve(order, args, context, info) {
            return info.mergeInfo.delegateToSchema({
              schema: usersExecutor,
              fieldName: "user",
              args: { id: order.userId },
              context,
              info,
            });
          },
        },
      },
    },
  });
 
  return new ApolloServer({ schema: gatewaySchema });
}

Implementing DataLoader for Batching

Without DataLoader, stitched schemas suffer from N+1 queries. Implement batching to resolve multiple entities in a single request:

import DataLoader from "dataloader";
 
function createBatchedExecutor(executor) {
  const loader = new DataLoader(async (requests) => {
    // Batch multiple requests into a single query
    const batchedQuery = buildBatchedQuery(requests);
    const result = await executor(batchedQuery);
    return splitBatchedResult(result, requests);
  });
 
  return (args) => loader.load(args);
}
 
// In the gateway
const usersExecutor = createBatchedExecutor(createRemoteExecutor("http://localhost:4001/graphql"));

Adding Authentication Context

Pass authentication tokens through the gateway to sub-schemas:

const gateway = new ApolloServer({
  schema: gatewaySchema,
  context: async ({ req }) => ({
    authToken: req.headers.authorization,
    userId: extractUserId(req.headers.authorization),
  }),
});
 
function createRemoteExecutor(url: string) {
  return async ({ document, variables, context }) => {
    const query = print(document);
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: context?.authToken || "",
      },
      body: JSON.stringify({ query, variables }),
    });
    return response.json();
  };
}

DataLoader batching pattern

Real-World Use Cases

Legacy API Integration

A company with a legacy REST API wants to add GraphQL without rewriting the backend. They create a thin GraphQL wrapper around the REST API using tools like graphql-tools and stitch it with their new GraphQL services. Clients see a single GraphQL API while the legacy service continues to work unchanged.

Multi-Team Microservices

A large organization has multiple teams each running their own GraphQL service. Schema stitching combines them into a single gateway without requiring teams to adopt federation directives. Each team deploys independently, and the gateway is updated to reflect schema changes.

Third-Party API Aggregation

An application combines its own GraphQL API with third-party APIs (GitHub, Stripe, Shopify). Schema stitching merges these disparate schemas into a unified API, allowing clients to query all data sources through a single endpoint.

Best Practices for Production

  1. Use DataLoader for batching: Always implement DataLoader when stitching schemas to avoid N+1 query problems. This is the single most important optimization.

  2. Implement schema caching: Cache the stitched schema to avoid re-introspecting sub-schemas on every request. Only rebuild when sub-schemas change.

  3. Handle sub-schema failures gracefully: Implement circuit breakers and fallbacks when sub-schemas are unavailable. Return partial results when possible.

  4. Monitor delegation performance: Track how long each sub-schema takes to respond. Identify bottlenecks and optimize the slowest services.

  5. Use schema directives for metadata: Add custom directives to control stitching behavior, like @computed for fields that require data from multiple schemas.

  6. Version your gateway schema: Even though GraphQL doesn't have formal versioning, track gateway schema changes and test them before deployment.

  7. Implement request timeouts: Set timeouts for sub-schema requests to prevent slow services from blocking the entire gateway.

  8. Use managed schemas in production: Replace IntrospectAndCompose with static schema files or a schema registry in production for reliability.

Common Pitfalls and Solutions

PitfallImpactSolution
N+1 queries without DataLoaderSevere performance degradationAlways implement DataLoader batching
Type name conflictsSchema composition failsUse typeMergingConfig to resolve conflicts
Missing selection setsIncomplete entity resolutionAlways specify selectionSet for merged types
Authentication not forwardedUnauthorized sub-schema accessForward auth headers in executors
Schema introspection in productionSecurity risk, performance overheadUse static schema files in production

Performance Optimization

Optimize stitched schemas by caching executor responses, batching requests, and implementing query planning to minimize sub-schema calls:

import { createBatchDelegate } from "@graphql-tools/batch-delegate";
 
const UserOrdersField = createBatchDelegate({
  schema: ordersSchema,
  fieldName: "ordersByUserIds",
  key: "userId",
  argsFromKeys: (userIds) => ({ userIds }),
});

Comparison with Alternatives

FeatureSchema StitchingApollo FederationREST GatewayGraphQL Mesh
Source flexibilityAny GraphQLGraphQL onlyREST onlyAny source
Type mergingManualAutomaticN/AAutomatic
BatchingManualBuilt-inN/ABuilt-in
ConfigurationComplexModerateSimpleComplex
PerformanceGoodExcellentModerateGood
CommunityDecliningGrowingStableGrowing

Advanced Patterns

Computed Fields Across Schemas

Create fields that require data from multiple sub-schemas:

const resolvers = {
  User: {
    orderHistory: {
      selectionSet: "{ id }",
      async resolve(user, args, context, info) {
        const orders = await delegateToSchema({
          schema: ordersSchema,
          fieldName: "ordersByUserId",
          args: { userId: user.id },
          context,
          info,
        });
 
        const products = await delegateToSchema({
          schema: productsSchema,
          fieldName: "productsByIds",
          args: { ids: orders.flatMap((o) => o.items.map((i) => i.productId)) },
          context,
          info,
        });
 
        return enrichOrdersWithProducts(orders, products);
      },
    },
  },
};

Schema Transformation

Transform sub-schema types before merging them:

import { RenameTypes, FilterRootFields } from "@graphql-tools/wrap";
 
const transformedSchema = wrapSchema({
  schema: usersSchema,
  executor: usersExecutor,
  transforms: [
    new RenameTypes((name) => `Users_${name}`),
    new FilterRootFields((op, fieldName) => fieldName !== "secretField"),
  ],
});

Testing Strategies

Test stitched schemas by verifying that cross-schema queries work correctly:

describe("Stitched schema", () => {
  it("resolves User with orders from different schemas", async () => {
    const result = await executeQuery(`
      query {
        user(id: "1") {
          name
          email
          orders {
            id
            total
            items {
              product { name price }
            }
          }
        }
      }
    `);
    expect(result.data.user.name).toBeDefined();
    expect(result.data.user.orders).toHaveLength(2);
  });
});

Schema Stitching vs Federation: A Deep Comparison

When to Choose Stitching

Schema stitching excels in specific scenarios where federation falls short:

// Scenario 1: Integrating a third-party API you don't control
// You can't add federation directives to GitHub's GraphQL API
const gatewaySchema = stitchSchemas({
  subschemas: [
    {
      schema: githubSchema,  // Third-party schema - no modifications
      executor: githubExecutor,
    },
    {
      schema: internalUsersSchema,  // Your own schema
      executor: usersExecutor,
    },
  ],
});
 
// Scenario 2: Migrating from REST to GraphQL incrementally
// Stitch REST and GraphQL services together
const gatewaySchema = stitchSchemas({
  subschemas: [
    { schema: graphqlProductsSchema, executor: productsExecutor },
    { schema: restOrdersSchema, executor: restToGraphqlExecutor },  // REST wrapper
  ],
});

When to Choose Federation

Federation is better for greenfield microservices:

// Federation requires @key directives in sub-schemas
// Users service
const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    name: String!
    email: String!
  }
`;
 
// Orders service extends User
const typeDefs = gql`
  extend type User @key(fields: "id") {
    id: ID! @external
    orders: [Order!]!
  }
`;

Performance Comparison

OperationStitchingFederation
Entity resolutionManual (DataLoader)Automatic batching
Cross-schema queryN+1 without DataLoaderOptimized by gateway
Schema compositionRuntime or build-timeBuild-time (rover CLI)
Query planningManualAutomatic
Cache invalidationManualAutomatic

Advanced Stitching Patterns

Type Merging with Computed Fields

Computed fields require data from multiple schemas before they can be resolved:

const gatewaySchema = stitchSchemas({
  subschemas: [
    {
      schema: usersSchema,
      merge: {
        User: {
          selectionSet: "{ id }",
          fieldName: "user",
          args: (obj) => ({ id: obj.id }),
          // Computed field needs orders data
          computedFields: {
            totalSpent: { selectionSet: "{ id }", needs: { orders: "{ userId total }" } },
          },
        },
      },
    },
  ],
});

Batch Merging for Performance

Batch multiple entity lookups into a single request:

import { createBatchDelegate } from "@graphql-tools/batch-delegate";
 
const UserBatchDelegate = createBatchDelegate({
  schema: usersSchema,
  fieldName: "usersByIds",
  key: "id",
  argsFromKeys: (ids) => ({ ids }),
  mapKeysFn: (users) => {
    const map = new Map();
    users.forEach((user) => map.set(user.id, user));
    return map;
  },
});

Custom Merge Resolvers

Implement custom logic for resolving merged types:

const gatewaySchema = stitchSchemas({
  subschemas: [
    {
      schema: usersSchema,
      merge: {
        User: {
          selectionSet: "{ id }",
          fieldName: "user",
          args: (obj) => ({ id: obj.id }),
          // Custom resolver for conflict resolution
          resolve: (originalObject, context, info, schema) => {
            // Prefer data from users schema
            return info.mergeInfo.delegateToSchema({
              schema,
              fieldName: "user",
              args: { id: originalObject.id },
              context,
              info,
            });
          },
        },
      },
    },
  ],
});

Error Handling and Monitoring

Error Propagation

Errors from sub-schemas propagate through the gateway:

// Sub-schema error handling
const executor = async ({ document, variables, context }) => {
  try {
    const response = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query: print(document), variables }),
    });
 
    const result = await response.json();
 
    if (result.errors) {
      // Log errors for monitoring
      console.error("Sub-schema errors:", result.errors);
    }
 
    return result;
  } catch (error) {
    // Return partial result with error
    return {
      data: null,
      errors: [{ message: "Service unavailable", extensions: { code: "SERVICE_ERROR" } }],
    };
  }
};

Distributed Tracing

Add tracing to monitor cross-schema query performance:

import { ApolloServerPluginInlineTrace } from "@apollo/server";
 
const gateway = new ApolloServer({
  schema: gatewaySchema,
  plugins: [ApolloServerPluginInlineTrace()],
  formatError: (error) => {
    // Send to monitoring service
    errorTracker.captureException(error);
    return error;
  },
});

Testing Stitched Schemas

Unit Testing Individual Resolvers

describe("User orders resolver", () => {
  it("fetches orders for a user", async () => {
    const result = await execute({
      schema: gatewaySchema,
      document: gql`
        query {
          user(id: "1") {
            name
            orders {
              id
              total
            }
          }
        }
      `,
    });
 
    expect(result.data.user.orders).toBeDefined();
    expect(result.data.user.orders.length).toBeGreaterThan(0);
  });
});

Integration Testing with Mock Services

describe("Gateway integration", () => {
  let mockUsersServer: MockServer;
  let mockOrdersServer: MockServer;
 
  beforeAll(async () => {
    mockUsersServer = await createMockServer(usersTypeDefs, usersResolvers);
    mockOrdersServer = await createMockServer(ordersTypeDefs, ordersResolvers);
  });
 
  it("stitches data from multiple services", async () => {
    const gateway = await createGateway({
      usersUrl: mockUsersServer.url,
      ordersUrl: mockOrdersServer.url,
    });
 
    const result = await gateway.execute({
      query: `{ user(id: "1") { name orders { total } } }`,
    });
 
    expect(result.errors).toBeUndefined();
    expect(result.data.user.name).toBeDefined();
    expect(result.data.user.orders).toBeDefined();
  });
});

Future Outlook

Schema stitching is being superseded by Apollo Federation for new projects, but it remains relevant for integrating existing schemas and third-party APIs. GraphQL Mesh builds on stitching concepts with support for REST, gRPC, and other source types. The @graphql-tools/stitch library continues to be maintained and improved.

Conclusion

Schema stitching enables you to combine multiple GraphQL schemas into a unified API, making it ideal for integrating existing services and third-party APIs. The key takeaways are:

  1. Use schema stitching when you need to combine schemas you don't control
  2. Always implement DataLoader for batching to avoid N+1 query problems
  3. Use type merging configuration to handle shared types across schemas
  4. Forward authentication context through executors to sub-schemas
  5. Consider Apollo Federation for new microservices projects

Start by identifying which schemas need to be combined, implement the gateway with proper batching and error handling, and evolve your stitching configuration as your architecture matures.