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. We may also want to integrate our own schema with remote schemas.
In these cases, we use
stitchSchemas to combine multiple GraphQL schemas together and produce a new 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.
In this example we'll stitch together two very simple schemas. 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.
Note the new
subschemas property with an array of subschema configuration objects. This syntax is a bit more verbose, but we shall see how it provides multiple benefits:
- transforms should be specified on the subschema config object, avoiding creation of a new schema with a new round of delegation in order to transform a schema prior to merging. This also makes it simple to include the necessary transforms when delegating, as you will pass the entire subschema configuration object to
delegateToSchemainstead of just the schema, with the required transforms included for free.
- remote subschema configuration options can be specified, also avoiding an additional round of schema proxying. That's three rounds of delegations reduce to one!
This gives us a new schema with the root fields on
Query from both schemas (along with the
We now have a single schema that supports asking for
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:
We can now merge these three schemas together:
Note the new
typeDefs option in parallel to the new
subschemas option, which better expresses that these typeDefs are defined only within the outer gateway schemas.
We won't be able to query
Chirp.author yet, however, because we still need to define resolvers for these new fields.
How should these resolvers be implemented? When we resolve
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
Resolvers can use the
delegateToSchema function to forward parts of queries (or even whole new queries) to one of the subschemas that was passed to
stitchSchemas (or any other schema).
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
selectionSet 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:
Passing arguments between resolvers
As a stitching implementation scales, we encounter situations where it's impractical to pass around exhaustive sets of records between services. For example, what if a User has tens of thousands of Chirps? We'll want to add scoping arguments into our gateway schema to address this:
This argument in the gateway schema won't do anything until passed through to the underlying subservice requests. How we pass this input through depends on which subservice manages the association data.
First, let's say that the Chirps service manages the association and implements a
since param for scoping the returned Chirp results. This simply requires passing the resolver argument through to
Alternatively, let's say that the Users service manages the association and implements a
User.chirpIds(since: DateTime):[Int] method to stitch from. In this configuration, resolver arguments will need to passthrough with the initial
selectionSet for User data. The
forwardArgsToSelectionSet helper handles this:
forwardArgsToSelectionSet will passthrough all arguments from the gateway field to all root fields in the selection set. For complex selections that request multiple fields, you may provide an additional mapping of selection names with their respective arguments:
Note that a dynamic
selectionSet is simply a function that recieves a GraphQL
FieldNode (the gateway field) and returns a
SelectionSetNode. This dynamic capability can support a wide range of custom stitching configurations.
Suppose there was an additional root field within the schema for chirps called
trendingChirps that returned a list of the current most popular chirps, as well as an additonal field on the
Chirp type called
chirpedAtUserId that described the target of an individual chirp. Imagine as well that we used the above stitching strategy to add an additional new field on the
Chirp type called
chirpedAtUser so that we could write the following query:
The implementation could be something like this:
The above query as written would cause the gateway to fire an additional query to our author schema for each trending chirp, with the exact same arguments and selection set!
Imagine, however, that the author schema had an additional root field
usersByIds besides just
userById. Because we know that for each member of a list, the arguments and selection set will always match, we can utilize batch delegation using the DataLoader pattern to combine the individual queries from the gateway into one batch to the
userByIds root field instead of
userById. The implementation would look very similar:
Batch delegation may be preferable over plain delegation whenever possible, as it reduces the number of requests significantly whenever the parent object type appears in a list!
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. (In earlier versions of graphql-tools, this required an additional round of delegation prior to merging, but transforms can now be specifying directly when merging using the new subschema configuration objects.)
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
We will now have a schema that has all fields and types prefixed with
Chirp_ and has only the
chirpById root field.
Now let's implement the resolvers:
Notice that when we call
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.
We are still stuck exposing the
authorId field within the
Chirp type even though we actually just want to include an
author. We can always use transforms to filter out the
authorId field, but it would be nice if this could be made more ergonomical. It also makes stitching somewhat more difficult when the authorId is not directly exposed -- for example, in a situation where we do not control the remote subschema in question.
What is the chirps subschema also had its own self-contained user concept, and we just wanted to combine the relevant fields from both subschemas?
This can now be accomplished by turning on type merging!
merge property on the
SubschemaConfig object determines how types are merged, and is a map of
Type merging simply merges types with the same name, but is smart enough to apply the passed subschema transforms prior to merging types, so the types have to be identical on the gateway, not the individual subschema.
All merged types returned by any subschema will delegate as necessary to subschemas also implementing the type, using the provided
resolve function of type
You can also use batch delegation instead of simple delegation by delegating to a root field returning a list and using the
mapResultsFn properties. See the batch delegation for more details.
The simplified magic above happens because if left unspecified, we provide a default type-merging resolver for you, which uses the other
MergedTypeConfig options (for simple delegation), as follows:
When providing your own type-merging resolver, note the very important
skipTypeMerging setting. Without this option, your gateway will keep busy merging types forever, as each result returned from each subschema will trigger another round of delegation to the other implementing subschemas!
Finally, you may wish to fine-tune which types are merged. Besides taking a boolean value, you can also specify an array of type names, or a function of type
MergeTypeFilter that takes the potential types and decides dynamically how to merge.
Working with remote schemas
In order to merge with a remote schema, we specify different options within the subschema configuration object that describe how to connect to the remote schema. For example:
The remote schema may be obtained either via introspection or any other source. An executor is a generic method of connecting to a schema.
Specifying the remote schema options within the
stitchSchemas call itself allows for skipping an additional round of delegation. The old method of using makeRemoteExecutableSchema to create a local proxy for the remote schema would still work, and the same arguments are supported. See the remote schema docs for further description of the options available. Subschema configuration allows for specifying an executor method for query and mutation operations, and a subscriber function for subscription operations.
This is the main function that implements schema stitching. Note that in addition to the above arguments, the function also takes all the same arguments as
makeExecutableSchema. Read below for a description of each option.
subschemas is an array of
SubschemaConfig objects. These subschemas are wrapped with proxying resolvers in the final schema.
Additional types to add to the final type map, most useful for custom scalars or enums.
Strings or parsed documents that can contain additional types or type extensions. Note that type extensions are always applied last, while types are defined in the order in which they are provided.
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
selectionSet for a resolver. The
selectionSet must be a GraphQL selection set definition string, specifying which fields from the parent schema are required for the resolver to function properly.
As described in the documentation above,
delegateToSchema allows delegating to any
SubschemaConfig object. Transforms do not have to be re-specified when passing a
SubschemaConfig object, which is the preserved workflow. Additional transforms can also be passed as needed. See Schema Delegation and the Using with transforms section of this document.
onTypeConflict option to
stitchSchemas allows customization of type resolving logic.
The default behavior of
stitchSchemas is to take the last encountered type of all the types with the same name, with a warning that type conflicts have been encountered. If specified,
onTypeConflict enables explicit selection of the winning type.
For example, here's how we could select the first type among multiple types with the same name:
And here's how we might select the type whose schema has the latest
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
stitchSchemas produce more desirable results.
inheritResolversFromInterfaces option is simply passed through to
addResolversToSchema, which is called when adding resolvers to the schema under the covers. See
addResolversToSchema for more info.