GraphQL Schema Stitching and Type Merging: A Practical Guide

A practical guide to GraphQL schema stitching and type merging with patterns, code, performance tips, and pitfalls for building a reliable gateway.

ASOasis
8 min read
GraphQL Schema Stitching and Type Merging: A Practical Guide

Image used for representation purposes only.

Overview

GraphQL schema stitching and type merging let you present multiple GraphQL services as a single, coherent API. Instead of forcing teams to centralize on one monolith, you can keep services independent and compose them at the gateway. The result: one endpoint, one graph, many backends.

This guide explains what stitching and merging are, when to use them over alternatives like federation, and how to implement them with patterns that scale in production.

Stitching vs. Merging vs. Federation

  • Schema stitching: Compose several executable schemas into a single gateway schema. The gateway delegates incoming operations to the right subschema(s) and can resolve cross-service fields.
  • Type merging: A stitching capability that unifies the same conceptual type exposed by multiple services (for example, a User type from Accounts and a User type from Billing) into one gateway type.
  • SDL merging (build-time merging): Combine SDL and resolver maps locally (e.g., merging many modules inside a single server). This does not delegate to remote schemas.
  • Federation: A composition model based on directives and entity references. Federation and stitching solve similar problems with different ergonomics. Stitching is library-driven and flexible; federation is spec-driven and ecosystem-aligned. Many organizations successfully run either approach.

Use stitching and type merging when:

  • You have heterogeneous GraphQL services (different stacks, ownership, or release cadences).
  • You want a flexible gateway that can transform, rename, and enrich fields.
  • You prefer library-driven composition without adopting a directive-based spec.

Core Concepts

  • Subschema: An independent executable GraphQL schema (local or remote).
  • Gateway schema: The stitched result that clients query.
  • Delegation: A gateway resolver forwards part of a query to a subschema and returns the result.
  • Type merging: Rules that describe how the gateway should select and hydrate a unified type from multiple subschemas.
  • Selection set: The minimal fields the gateway must fetch to identify an object (commonly an id) for later hydration.

Minimal End-to-End Example

Suppose we have two services:

  • Accounts service (http://accounts:4001/graphql): User core profile
  • Inventory service (http://inventory:4002/graphql): Products and ownership

Accounts SDL (simplified):

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

type Query { userById(id: ID!): User usersByIds(ids: [ID!]!): [User!]! }

Inventory SDL (simplified):

# inventory
type Product { id: ID! sku: String! title: String! }

extend type User { ownedProducts: [Product!]! }

type Query {
  productById(id: ID!): Product
  productsByIds(ids: [ID!]!): [Product!]!
  userById(id: ID!): User # returns a lightweight user shell with id only
}

Gateway code (Node.js) showing remote executors, stitching, type merging, and delegation with batching. The exact import names may vary slightly by package version but the pattern is consistent:

import { print } from 'graphql';
import fetch from 'node-fetch';
import { stitchSchemas } from '@graphql-tools/stitch';
import { wrapSchema, introspectSchema } from '@graphql-tools/wrap';
import { batchDelegateToSchema, delegateToSchema } from '@graphql-tools/delegate';

function httpExecutor(url: string) {
  return async ({ document, variables }: any) => {
    const query = print(document);
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ query, variables })
    });
    return res.json();
  };
}

async function makeRemoteSchema(url: string) {
  const executor = httpExecutor(url);
  const schema = wrapSchema({
    schema: await introspectSchema(executor),
    executor
  });
  return schema;
}

export async function makeGatewaySchema() {
  const accountsSchema = await makeRemoteSchema('http://accounts:4001/graphql');
  const inventorySchema = await makeRemoteSchema('http://inventory:4002/graphql');

  const gatewaySchema = stitchSchemas({
    subschemas: [
      {
        schema: accountsSchema,
        merge: {
          User: {
            # use id to uniquely identify users from accounts
            selectionSet: '{ id }',
            fieldName: 'userById',
            args: originalObject => ({ id: originalObject.id })
          }
        }
      },
      {
        schema: inventorySchema,
        merge: {
          User: {
            # inventory can also return a user shell; we can hydrate across services
            selectionSet: '{ id }',
            fieldName: 'userById',
            args: originalObject => ({ id: originalObject.id })
          }
        }
      }
    ],

    # Example of adding cross-service fields via delegation
    typeDefs: /* GraphQL */ `
      extend type User { products: [Product!]! }
    `,
    resolvers: {
      User: {
        products: {
          selectionSet: '{ id }',
          resolve(user, _args, context, info) {
            // Batch to avoid N+1 calls
            return batchDelegateToSchema({
              schema: inventorySchema,
              operation: 'query',
              fieldName: 'productsByIds',
              key: user.ownedProducts?.map((p: any) => p.id) ?? [],
              argsFromKeys: (ids: string[]) => ({ ids }),
              context,
              info
            });
          }
        }
      },
      Query: {
        productById: (_root, args, context, info) =>
          delegateToSchema({
            schema: inventorySchema,
            operation: 'query',
            fieldName: 'productById',
            args,
            context,
            info
          })
      }
    }
  });

  return gatewaySchema;
}

Notes:

  • subschemas[].merge config tells the gateway how to identify and hydrate a unified User across services.
  • selectionSet ensures the gateway fetches id, which is then used to refetch or extend the object in another subschema.
  • batchDelegateToSchema reduces N+1 queries when resolving lists.

Build-Time SDL Merging (Single Server)

If you are not delegating to remote services but simply organizing a modular schema inside one server, merge SDL and resolvers at build time:

import { loadFiles } from '@graphql-tools/load-files';
import { mergeTypeDefs, mergeResolvers } from '@graphql-tools/merge';
import { makeExecutableSchema } from '@graphql-tools/schema';

const typeSources = await loadFiles('src/**/*.graphql');
const resolverMaps = await loadFiles('src/**/resolvers.*');

const typeDefs = mergeTypeDefs(typeSources);
const resolvers = mergeResolvers(resolverMaps);

export const schema = makeExecutableSchema({ typeDefs, resolvers });

This is schema merging in the “one server, many modules” sense. It differs from stitching, which composes multiple executable schemas at runtime.

Designing for Type Merging

  • Choose stable keys: Favor globally unique IDs for merge selection sets (e.g., id). If you must use a compound key, include every component in selectionSet.
  • Keep types canonical: Decide which service is the source of truth for a type’s identity. Other services can extend the type with non-canonical fields.
  • Use explicit extensions: In non-canonical services, use extend type consistently to signal augmentation.
  • Plan for partial objects: Non-canonical services may return lightweight shells (id only). The gateway will fully hydrate via merge rules.

Renaming and Conflict Resolution

When services disagree on naming, use schema transforms to normalize at the gateway. Examples:

  • Rename Types: normalize ProductItem to Product across services.
  • Rename Fields: map upc to sku.
  • Wrap/Filter: remove internal fields or wrap resolvers to enrich results.

A common pattern is to apply transforms at subschema level before stitching so the gateway exposes clean, consistent names. Keep a mapping registry in code to document transformations.

Cross-Service Fields with Delegation

Cross-service fields are where stitching shines. For example, Order.customer might live in Orders but resolve from Accounts:

Order: {
  customer: {
    selectionSet: '{ customerId }',
    resolve(order, _args, ctx, info) {
      return delegateToSchema({
        schema: accountsSchema,
        operation: 'query',
        fieldName: 'userById',
        args: { id: order.customerId },
        context: ctx,
        info
      });
    }
  }
}

Batch where possible to avoid N+1, especially for list fields and nested relations.

Performance Patterns

  • Batching: Use batchDelegateToSchema or a batching executor to collate identical field requests across a query.
  • Caching: Layer a per-request cache keyed by document/variables and object identifiers. Consider response caching at the gateway for idempotent queries.
  • Dataloaders at the edge: Some subschemas already use DataLoader; avoid double-batching by tuning batch sizes and key strategies.
  • Cost controls: Limit query depth and complexity. Set timeouts and circuit breakers for slow subschemas.
  • Persisted queries: Reduce parse/validation overhead and improve CDN cacheability.

Error Handling and Nullability

  • Preserve error paths: When delegating, forward errors with original path information to keep stack traces meaningful.
  • Partial data: GraphQL nullability lets unrelated fields succeed even if one subschema fails. Design nullability intentionally; prefer non-null only when you can guarantee availability.
  • Extensions: Use error.extensions to tag which subschema failed and include remediation codes.

Security and Context Propagation

  • Authentication: Pass auth headers or user claims from the gateway to subschemas. Normalize context so each subschema receives only what it needs.
  • Authorization: Centralize cross-cutting checks at the gateway where appropriate, but keep domain-specific checks in the subschema that owns the data.
  • Input hardening: Validate inputs at the edge. Consider allowlists for delegated field names.
  • Introspection and query limits: Disable introspection in production if needed, or scope it to trusted tooling. Enforce max depth/complexity and timeouts.

Testing and CI/CD

  • Contract tests: For each subschema, pin critical queries and verify snapshots of shape/fields. Fail fast on breaking changes.
  • Gateway smoke tests: Run end-to-end queries against a local or ephemeral gateway using mocked subschemas.
  • Schema checks: Use schema diffing in CI to catch destructive changes (field removals, nullability tightening).
  • Resilience tests: Simulate latency, errors, and timeouts from subschemas and verify partial-data behavior.

Observability

  • Tracing: Add spans for gateway parsing, validation, planning, and each delegation call. Propagate trace IDs to subschemas.
  • Metrics: Record per-subschema latency, error rate, throughput, and hit/miss ratios for batch/caches.
  • Logs: Log operation names, variables shape (redacted), and subschema targets for each delegated call.

Common Pitfalls and How to Avoid Them

  • ID mismatch: If two services use different identifiers (e.g., userId vs. accountId), add a consistent canonical ID and map at the gateway.
  • Over-merging: Not every shared name implies the same concept. Consider namespacing or explicit renames to avoid accidental merges.
  • N+1 explosions: Always add batching for list fields and nested relations.
  • Hidden breaking changes: A subschema can add a non-null field that makes a merged type unsatisfiable. Rely on schema checks and contract tests.
  • Ambiguous selection sets: Make selectionSet minimal but sufficient. If the key changes across services, hydration breaks.

Migration Playbook (Monolith → Stitched Gateway)

  1. Extract a feature slice into a new service that exposes a minimal schema.
  2. Stitch the new subschema into the gateway and migrate a small set of client queries.
  3. Add type merging to unify overlapping types gradually.
  4. Introduce transforms to normalize naming and enums.
  5. Scale out: add batching, caches, and observability. Then repeat for the next slice.

When to Prefer Federation Instead

  • Many teams and services want a common, directive-based approach with dedicated tooling.
  • You want a standardized entity reference model with built-in composition contracts.

Both models can coexist. Some organizations stitch legacy/third-party GraphQL or REST-wrapped graphs while federating new, greenfield services.

Operational Checklist

  • Keys chosen for every merged type (selectionSet documented).
  • Batching enabled for cross-service list fields.
  • Depth/complexity limits and circuit breakers configured.
  • Contract tests and schema checks in CI.
  • Auth context propagation finalized and reviewed with security.
  • Renames/transforms documented in a registry.

Summary

Schema stitching with type merging is a practical, flexible way to present a single graph over many services. Start by identifying canonical ownership for types, define clear merge keys, and rely on delegation with batching to connect data across boundaries. Layer in security, testing, and observability early to keep the composed graph reliable as it grows.

Related Posts