Schema stitching

Combining multiple GraphQL APIs into one

Schema stitching is the process of creating a single GraphQL schema from multiple underlying GraphQL APIs.

One of the main benefits of GraphQL is that we can query all of our data as part of one schema, and get everything we need in one request. But as the schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. We may also want to decompose your schema into separate microservices, which can be developed and deployed independently.

In both cases, we use mergeSchemas to combine multiple GraphQL schemas together and produce a merged schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing us to connect to external data and create mashups.

Working with remote schemas

In order to merge with a remote schema, we first call makeRemoteExecutableSchema to create a local proxy for the schema that knows how to call the remote endpoint. We then merge that local proxy schema the same way we would merge any other locally implemented schema.

Basic example

In this example we’ll stitch together two very simple schemas. It doesn’t matter whether these are local or proxies created with makeRemoteExecutableSchema, because the merging itself would be the same.

In this case, we’re dealing with two schemas that implement a system with users and “chirps”—small snippets of text that users can post.

import {
  makeExecutableSchema,
  addMockFunctionsToSchema,
  mergeSchemas,
} from 'graphql-tools';

// Mocked chirp schema
// We don't worry about the schema implementation right now since we're just
// demonstrating schema stitching.
const chirpSchema = makeExecutableSchema({
  typeDefs: `
    type Chirp {
      id: ID!
      text: String
      authorId: ID!
    }

    type Query {
      chirpById(id: ID!): Chirp
      chirpsByAuthorId(authorId: ID!): [Chirp]
    }
  `
});

addMockFunctionsToSchema({ schema: chirpSchema });

// Mocked author schema
const authorSchema = makeExecutableSchema({
  typeDefs: `
    type User {
      id: ID!
      email: String
    }

    type Query {
      userById(id: ID!): User
    }
  `
});

addMockFunctionsToSchema({ schema: authorSchema });

export const schema = mergeSchemas({
  schemas: [
    chirpSchema,
    authorSchema,
  ],
});

Run the above example on Launchpad.

This gives us a new schema with the root fields on Query from both schemas (along with the User and Chirp types):

type Query {
  chirpById(id: ID!): Chirp
  chirpsByAuthorId(authorId: ID!): [Chirp]
  userById(id: ID!): User
}

We now have a single schema that supports asking for userById and chirpsByAuthorId in the same query!

Adding resolvers between schemas

Combining existing root fields is a great start, but in practice we will often want to introduce additional fields for working with the relationships between types that came from different subschemas. For example, we might want to go from a particular user to their chirps, or from a chirp to its author. Or we might want to query a latestChirps field and then get the author of each of those chirps. If the only way to obtain a chirp’s author is to call the userById(id) root query field with the authorId of a given chirp, and we don’t know the chirp’s authorId until we receive the GraphQL response, then we won’t be able to obtain the authors as part of the same query.

To add this ability to navigate between types, we need to extend existing types with new fields that translate between the types:

const linkTypeDefs = `
  extend type User {
    chirps: [Chirp]
  }

  extend type Chirp {
    author: User
  }
`;

We can now merge these three schemas together:

mergeSchemas({
  schemas: [
    chirpSchema,
    authorSchema,
    linkTypeDefs,
  ],
});

We won’t be able to query User.chirps or Chirp.author yet, however, because we still need to define resolvers for these new fields.

How should these resolvers be implemented? When we resolve User.chirps or Chirp.author, we want to delegate to the relevant root fields. To get from a user to the user’s chirps, for example, we’ll want to use the id of the user to call Query.chirpsByAuthorId. And to get from a chirp to its author, we can use the chirp’s authorId field to call the existing Query.userById field.

Resolvers for fields in schemas created by mergeSchema have access to a handy delegateToSchema function (exposed via info.mergeInfo.delegateToSchema) that allows forwarding parts of queries (or even whole new queries) to one of the subschemas that was passed to mergeSchemas.

In order to delegate to these root fields, we’ll need to make sure we’ve actually requested the id of the user or the authorId of the chirp. To avoid forcing users to add these fields to their queries manually, resolvers on a merged schema can define a fragment property that specifies the required fields, and they will be added to the query automatically.

A complete implementation of schema stitching for these schemas might look like this:

const mergedSchema = mergeSchemas({
  schemas: [
    chirpSchema,
    authorSchema,
    linkTypeDefs,
  ],
  resolvers: {
    User: {
      chirps: {
        fragment: `... on User { id }`,
        resolve(user, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: chirpSchema,
            operation: 'query',
            fieldName: 'chirpsByAuthorId',
            args: {
              authorId: user.id,
            },
            context,
            info,
          });
        },
      },
    },
    Chirp: {
      author: {
        fragment: `... on Chirp { authorId }`,
        resolve(chirp, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: authorSchema,
            operation: 'query',
            fieldName: 'userById',
            args: {
              id: chirp.authorId,
            },
            context,
            info,
          });
        },
      },
    },
  },
});

Run the above example on Launchpad.

Using with Transforms

Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using transforms with schema stitching, we can easily tweak the subschemas before merging them together.

Before, when we were simply merging schemas without first transforming them, we would typically delegate directly to one of the merged schemas. Once we add transforms to the mix, there are times when we want to delegate to fields of the new, transformed schemas, and other times when we want to delegate to the original, untransformed schemas.

For example, suppose we transform the chirpSchema by removing the chirpsByAuthorId field and add a Chirp_ prefix to all types and field names, in order to make it very clear which types and fields came from chirpSchema:

import {
  makeExecutableSchema,
  addMockFunctionsToSchema,
  mergeSchemas,
  transformSchema,
  FilterRootFields,
  RenameTypes,
  RenameRootFields,
} from 'graphql-tools';

// Mocked chirp schema; we don't want to worry about the schema
// implementation right now since we're just demonstrating
// schema stitching
const chirpSchema = makeExecutableSchema({
  typeDefs: `
    type Chirp {
      id: ID!
      text: String
      authorId: ID!
    }

    type Query {
      chirpById(id: ID!): Chirp
      chirpsByAuthorId(authorId: ID!): [Chirp]
    }
  `
});

addMockFunctionsToSchema({ schema: chirpSchema });

// create transform schema

const transformedChirpSchema = transformSchema(chirpSchema, [
  new FilterRootFields(
    (operation: string, rootField: string) => rootField !== 'chirpsByAuthorId'
  ),
  new RenameTypes((name: string) => `Chirp_${name}`),
  new RenameRootFields((operation: 'Query' | 'Mutation' | 'Subscription', name: string) => `Chirp_${name}`),
]);

Now we have a schema that has all fields and types prefixed with Chirp_ and has only the chirpById root field. Note that the original schema has not been modified, and remains fully functional. We’ve simply created a new, slightly different schema, which hopefully will be more convenient for merging with our other subschemas.

Now let’s implement the resolvers:

const mergedSchema = mergeSchemas({
  schemas: [
    transformedChirpSchema,
    authorSchema,
    linkTypeDefs,
  ],
  resolvers: {
    User: {
      chirps: {
        fragment: `... on User { id }`,
        resolve(user, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: chirpSchema,
            operation: 'query',
            fieldName: 'chirpsByAuthorId',
            args: {
              authorId: user.id,
            },
            context,
            info,
            transforms: transformedChirpSchema.transforms,
          });
        },
      },
    },
    Chirp_Chirp: {
      author: {
        fragment: `... on Chirp { authorId }`,
        resolve(chirp, args, context, info) {
          return info.mergeInfo.delegateToSchema({
            schema: authorSchema,
            operation: 'query',
            fieldName: 'userById',
            args: {
              id: chirp.authorId,
            },
            context,
            info,
          });
        },
      },
    },
  },
});

Notice that resolvers.Chirp_Chirp has been renamed from just Chirp, but resolvers.Chirp_Chirp.author.fragment still refers to the original Chirp type and authorId field, rather than Chirp_Chirp and Chirp_authorId.

Also, when we call info.mergeInfo.delegateToSchema in the User.chirps resolvers, we can delegate to the original chirpsByAuthorId field, even though it has been filtered out of the final schema. That’s because we’re delegating to the original chirpSchema, which has not been modified by the transforms.

Complex example

For a more complicated example involving properties and bookings, with implementations of all of the resolvers, check out the Launchpad links below:

API

mergeSchemas

mergeSchemas({
  schemas: Array<string | GraphQLSchema | Array<GraphQLNamedType>>;
  resolvers?: Array<IResolvers> | IResolvers;
  onTypeConflict?: (
    left: GraphQLNamedType,
    right: GraphQLNamedType,
    info?: {
      left: {
        schema?: GraphQLSchema;
      };
      right: {
        schema?: GraphQLSchema;
      };
    },
  ) => GraphQLNamedType;
  inheritResolversFromInterfaces?: boolean;
})

This is the main function that implements schema stitching. Read below for a description of each option.

schemas

schemas is an array of GraphQLSchema objects, schema strings, or lists of GraphQLNamedTypes. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided.

resolvers

resolvers accepts resolvers in same format as makeExecutableSchema. It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a fragment for a resolver. The fragment must be a GraphQL fragment definition string, specifying which fields from the parent schema are required for the resolver to function properly.

resolvers: {
  Booking: {
    property: {
      fragment: '... on Booking { propertyId }',
      resolve(parent, args, context, info) {
        return info.mergeInfo.delegateToSchema({
          schema: bookingSchema,
          operation: 'query',
          fieldName: 'propertyById',
          args: {
            id: parent.propertyId,
          },
          context,
          info,
        });
      },
    },
  },
}

mergeInfo and delegateToSchema

The info.mergeInfo object provides the delegateToSchema method:

type MergeInfo = {
  delegateToSchema<TContext>(options: IDelegateToSchemaOptions<TContext>): any;
}

interface IDelegateToSchemaOptions<TContext = {
    [key: string]: any;
}> {
    schema: GraphQLSchema;
    operation: Operation;
    fieldName: string;
    args?: {
        [key: string]: any;
    };
    context: TContext;
    info: GraphQLResolveInfo;
    transforms?: Array<Transform>;
}

As described in the documentation above, info.mergeInfo.delegateToSchema allows delegating to any GraphQLSchema object, optionally applying transforms in the process. See Schema Delegation and the Using with transforms section of this document.

onTypeConflict

type OnTypeConflict = (
  left: GraphQLNamedType,
  right: GraphQLNamedType,
  info?: {
    left: {
      schema?: GraphQLSchema;
    };
    right: {
      schema?: GraphQLSchema;
    };
  },
) => GraphQLNamedType;

The onTypeConflict option to mergeSchemas allows customization of type resolving logic.

The default behavior of mergeSchemas is to take the first encountered type of all the types with the same name. If there are conflicts, onTypeConflict enables explicit selection of the winning type.

For example, here’s how we could select the last type among multiple types with the same name:

const onTypeConflict = (left, right) => right;

And here’s how we might select the type whose schema has the latest version:

const onTypeConflict = (left, right, info) => {
  if (info.left.schema.version >= info.right.schema.version) {
    return left;
  } else {
    return right;
  }
}

When using schema transforms, onTypeConflict is often unnecessary, since transforms can be used to prevent conflicts before merging schemas. However, if you’re not using schema transforms, onTypeConflict can be a quick way to make mergeSchemas produce more desirable results.

inheritResolversFromInterfaces

The inheritResolversFromInterfaces option is simply passed through to addResolveFunctionsToSchema, which is called when adding resolvers to the schema under the covers. See addResolveFunctionsToSchema for more info.

Edit on GitHub
// search box