Schema Extensions
Schema extensions add gateway-level type definitions and resolvers into a combined API, which is useful for establishing connections between types that exist in separate subschemas.
When considering these capabilities, be sure to compare them with the newer automated features available through type merging. While type merging frequently eliminates the need for schema extensions, it does not preclude their use.
This approach uses Schema Delegation approach to delegate queries to subschemas.
Schema Delegation
Schema delegation is a way to automatically forward a query (or a part of a query) from a parent schema to another schema (called a subschema) that can execute the query. Delegation is useful when the parent schema shares a significant part of its data model with the subschema. For example:
- A GraphQL gateway that connects multiple existing endpoints, each with its schema, could be implemented as a parent schema that delegates portions of queries to the relevant subschemas.
- Any local schema can directly wrap remote schemas and optionally extend them with additional fields. As long as schema delegation is unidirectional, no gateway is necessary. Simple examples are schemas that wrap other autogenerated schemas (e.g. Postgraphile, Hasura, Prisma) to add custom functionality.
Delegation is performed by one function, delegateToSchema
, called from within a resolver function
of the parent schema. The delegateToSchema
function sends the query subtree received by the parent
resolver to the subschema that knows how to execute it. Fields for the merged types use the
defaultMergedResolver
resolver to extract the correct data from the query response.
Motivational Example
Let’s consider two schemas, a subschema and a parent schema that reuses parts of a subschema. While the parent schema reuses the definitions of the subschema, we want to keep the implementations separate, so that the subschema can be tested independently, or even used as a remote service.
# Subschema
type Repository {
id: ID!
url: String
issues: [Issue]
userId: ID!
}
type Issue {
id: ID!
text: String!
repository: Repository!
}
type Query {
repositoryById(id: ID!): Repository
repositoriesByUserId(id: ID!): [Repository]
}
# Parent schema
type Repository {
id: ID!
url: String
issues: [Issue]
userId: ID!
user: User
}
type Issue {
id: ID!
text: String!
repository: Repository!
}
type User {
id: ID!
username: String
repositories: [Repository]
}
type Query {
userById(id: ID!): User
}
Suppose we want the parent schema to delegate retrieval of repositories to the subschema, in order to execute queries such as this one:
query {
userById(id: "1") {
id
username
repositories {
id
url
user {
username
id
}
issues {
text
}
}
}
}
The resolver function for the repositories
field of the User
type would be responsible for the
delegation, in this case. While it’s possible to call a remote GraphQL endpoint or resolve the data
manually, this would require us to transform the query manually or always fetch all possible fields,
which could lead to overfetching. Delegation automatically extracts the appropriate query to send to
the subschema:
# To the subschema
query ($id: ID!) {
repositoriesByUserId(id: $id) {
id
url
issues {
text
}
}
}
The delegation also removes the fields that don’t exist on the subschema, such as user
. This field
would be retrieved from the parent schema using normal GraphQL resolvers.
Each field on the Repository
and Issue
types should use the defaultMergedResolver
to properly
extract data from the delegated response. Although in the simplest case, the default resolver can be
used for the merged types, defaultMergedResolver
resolves aliases, converts custom scalars and
enums to their internal representations, and maps errors.
Basic Example
Going back to the posts and users service example:
import { addMocksToSchema } from '@graphql-tools/mock'
import { makeExecutableSchema } from '@graphql-tools/schema'
let postSchema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Post {
id: ID!
text: String
userId: ID!
}
type Query {
postById(id: ID!): Post
postsByUserId(userId: ID!): [Post!]!
}
`
})
let userSchema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type User {
id: ID!
email: String
}
type Query {
userById(id: ID!): User
}
`
})
// just mock the schemas for now to make them return dummy data
postSchema = addMocksToSchema({ schema: postSchema })
userSchema = addMocksToSchema({ schema: userSchema })
// setup subschema config objects
export const postsSubschema = { schema: postSchema }
export const usersSubschema = { schema: userSchema }
postsSubschema
andusersSubschema
are subschema config objects described in Configuring Subschemas. Typically local subschemas are already executable (via embedded resolvers) and thus do not need to supply an executor.
We may want to navigate from a particular user to their posts, or from a post to its user. This is possible within our service architecture by connecting an existing key of each object to a corresponding root query:
Post.userId -> userById(id)
gets a Post’s user.User.id -> postsByUserId(userId)
gets a User’s posts.
To formalize this navigation within our gateway schema, we can extend each type with a new field that will translate its respective key into an actual object association:
import { stitchSchemas } from '@graphql-tools/stitch'
export const schema = stitchSchemas({
subschemas: [postsSubschema, usersSubschema],
typeDefs: /* GraphQL */ `
extend type Post {
user: User!
}
extend type User {
posts: [Post!]!
}
`
})
The typeDefs
option provides type extensions (using the extend
keyword) that add additional
fields into the combined gateway schema and therefore may cross-reference types from any
subschema.
However, these extensions alone won’t do anything until they have corresponding resolvers. A complete example would look like this:
import { delegateToSchema } from '@graphql-tools/delegate'
import { stitchSchemas } from '@graphql-tools/stitch'
export const schema = stitchSchemas({
subschemas: [postsSubschema, usersSubschema],
typeDefs: /* GraphQL */ `
extend type Post {
user: User!
}
extend type User {
posts: [Post!]!
}
`,
resolvers: {
User: {
posts: {
selectionSet: `{ id }`,
resolve(user, args, context, info) {
return delegateToSchema({
schema: postsSubschema,
operation: 'query',
fieldName: 'postsByUserId',
args: { userId: user.id },
context,
info
})
}
}
},
Post: {
user: {
selectionSet: `{ userId }`,
resolve(post, args, context, info) {
return delegateToSchema({
schema: usersSubschema,
operation: 'query',
fieldName: 'userById',
args: { id: post.userId },
context,
info
})
}
}
}
}
})
When resolving User.posts
and Post.user
, we delegate each key reference to its corresponding
root query. Note that the structure of stitching resolvers has a selectionSet
property and a
resolve
method.
selectionSet
Post: {
user: {
selectionSet: `{ userId }`,
// ... resolve
}
}
The selectionSet
specifies the key field(s) needed from an object to query for its associations.
For example, Post.user
will require that a Post provide its userId
. Rather than relying on
incoming queries to manually request this key for the association, the selection set will
automatically be included in subschema requests to guarantee that these fields are fetched. Dynamic
selection sets are also possible by providing a function that receives a GraphQL FieldNode
(the
gateway field) and returns a SelectionSetNode
.
Note: As of version 7 of GraphQL-Tools, fragment
hints are removed in favor of
selectionSet
hints, read more in the migration guide.
resolve
Post: {
user: {
// ... selectionSet
resolve(post, args, context, info) {
return delegateToSchema({
schema: usersSubschema,
operation: 'query',
fieldName: 'userById',
args: { id: post.userId },
context,
info
})
}
}
}
Resolvers use the delegateToSchema
function to forward parts of queries (or even whole new
queries) to any other schema—inside or outside of the stitched schema. When delegating to a
stitched subschema, always provide the complete
subschema config object as the
schema
option.
By default, delegateToSchema
assumes that the delegated operation will return the same GraphQL
type as the resolved field (ex: a User
field would delegate to a User
query). If this is not the
case, then you should manually provide a returnType
option citing the expected GraphQL return
type, and transform the result accordingly in the resolver.
Batch Delegation (Array Batching)
The drawback of performing individual delegateToSchema
calls is that they can be fairly
inefficient. Say we request Post.user
from an array of ten posts—that would delegate ten
individual userById
queries while resolving each user! To improve this, we can instead delegate in
batches, where many instances of a field resolver are consolidated into one delegation.
To setup batching, the first thing we’ll need is a new query in the users’ service that allows fetching many users at once:
usersByIds(ids: [ID!]!): [User]!
With this many-users query available, we can now delegate the Post.user
field in batches across
many records:
import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'
import { stitchSchemas } from '@graphql-tools/stitch'
const schema = stitchSchemas({
subschemas: [postsSubschema, usersSubschema],
typeDefs: /* GraphQL */ `
extend type Post {
user: User!
}
`,
resolvers: {
Post: {
user: {
selectionSet: `{ userId }`,
resolve(post, _args, context, info) {
return batchDelegateToSchema({
schema: usersSubschema,
operation: 'query',
fieldName: 'usersByIds',
key: post.userId,
argsFromKeys: ids => ({ ids }),
context,
info
})
}
}
}
}
})
Internally, batchDelegateToSchema
wraps a single delegateToSchema
call in a
DataLoader scoped by context, field, arguments, and query
selection. It assumes that the delegated operation will return an array of objects matching the
gateway field’s named GraphQL type (ex: a User
field delegates to a [User]
query). If this is
not the case, then you should manually provide a returnType
option citing the expected GraphQL
return type. Since it is a thin wrapper around DataLoader
, it also makes the following assumptions
on the results:
-
The Array of values must be the same length as the Array of keys.
-
Each index in the Array of values must correspond to the same index in the Array of keys.
If the query you’re delegating to don’t conform to these expectations, you can provide a custom valuesFromResults function to transform it appropriately.
Batch delegation is generally preferable over plain delegation because it eliminates the redundancy
of requesting the same field across an array of parent objects. Even so, delegation costs can add up
because there is still one subschema request made per batched field—for remote services,
this may create many network requests sent to the same service. Consider enabling an additional
layer of batching by enabling batch execution with batch: true
in subschema configuration;
const someSubschema = {
schema: someNonExecutableSchema,
executor: someExecutor,
batch: true
}
Passing Gateway Arguments
Exhaustive accessors like User.posts
do not scale well (…what happens when a user has tens of
thousands of posts?), so the gateway should probably accept scoping arguments and pass them through
to the underlying subschemas. Let’s add a pageNumber
argument to the User.posts
schema
extension:
extend type User {
posts(pageNumber: Int = 1): [Post]!
}
This argument only exists in the gateway schema and won’t do anything until passed through to subschemas. How we pass this input through depends on which subservice owns the association data…
Via Delegation
First, let’s say that the Posts service defines this association. The first thing we’ll need is a corresponding argument in the posts query; and while we’re at it, let’s also support batching:
postPagesByUserIds(userIds: [ID!]!, pageNumber: Int=1): [[Post!]!]!
This postPagesByUserIds
query is a very primitive example of pagination, and simply returns an
array of posts for each user ID. Now we just need to pass the resolver’s page number argument
through to batchDelegateToSchema
, and manually specify a returnType
that matches the pagination
format:
import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'
import { GraphQLList } from 'graphql'
/// ...
User: {
posts: {
selectionSet: `{ id }`,
resolve(user, args, context, info) {
return batchDelegateToSchema({
schema: postsSubschema,
operation: 'query',
fieldName: 'postPagesByUserIds',
key: user.id,
argsFromKeys: userIds => ({ userIds, pageNumber: args.pageNumber }),
returnType: new GraphQLList(new GraphQLList(postsSubschema.schema.getType('Post'))),
context,
info
})
}
}
}
Via selectionSet
Alternatively, let’s say that users and posts have a many-to-many relationship and the users service
owns the association data. That might give us a User.postIds
field to stitch from:
User.postIds(pageNumber: Int=1): [ID]!
In this configuration, resolver arguments will need to pass through with the initial selectionSet
.
The forwardArgsToSelectionSet
helper handles this:
import { forwardArgsToSelectionSet } from '@graphql-tools/stitch'
import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'
//...
User: {
posts: {
selectionSet: forwardArgsToSelectionSet('{ postIds }'),
resolve(user, args, context, info) {
return batchDelegateToSchema({
schema: postsSubschema,
operation: 'query',
fieldName: 'postsByIds',
key: user.postIds,
argsFromKeys: ids => ({ ids }),
context,
info
})
}
}
}
By default, forwardArgsToSelectionSet
will pass through 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:
forwardArgsToSelectionSet('{ id postIds }', { postIds: ['pageNumber'] })
Extending Transformed Schemas
Transformed schemas are nuanced because they involve two versions of the same schema: the original schema, and the transformed gateway schema. When extending a transformed schema, we extend the gateway schema but delegate to the original schema. For example:
import { delegateToSchema } from '@graphql-tools/delegate'
import { addMocksToSchema } from '@graphql-tools/mock'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { stitchSchemas } from '@graphql-tools/stitch'
import { FilterRootFields, RenameTypes } from '@graphql-tools/wrap'
const postSchema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Post {
id: ID!
text: String
userId: ID!
}
type Query {
postById(id: ID!): Post
postsByUserId(userId: ID!): [Post]!
}
`
})
const postsSubschema = {
schema: addMocksToSchema({ schema: postSchema }),
transforms: [
// remove the "postsByUserId" root field
new FilterRootFields((op, field) => field !== 'postsByUserId'),
// prefix all type names with "Post_"
new RenameTypes(name => `Post_${name}`)
]
}
const userSchema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type User {
id: ID!
email: String
}
type Query {
userById(id: ID!): User
}
`
})
const usersSubschema = {
schema: addMocksToSchema({ schema: userSchema })
}
const stitchedSchema = stitchSchemas({
subschemas: [postsSubschema, usersSubschema],
typeDefs: /* GraphQL */ `
extend type User {
posts: [Post_Post!]!
}
extend type Post_Post {
user: User!
}
`,
resolvers: {
User: {
posts: {
selectionSet: `{ id }`,
resolve(user, args, context, info) {
return delegateToSchema({
schema: postsSubschema,
operation: 'query',
fieldName: 'postsByUserId',
args: { userId: user.id },
context,
info
})
}
}
},
Post_Post: {
user: {
selectionSet: `{ userId }`,
resolve(post, args, context, info) {
return delegateToSchema({
schema: usersSubschema,
operation: 'query',
fieldName: 'userById',
args: { id: post.userId },
context,
info
})
}
}
}
}
})
A few key points to note here:
-
All schema extensions and their resolvers exist in the gateway schema and therefore refer to the transformed type name
Post_Post
. -
Delegations refer to the original subschema and therefore may reference fields such as
postsByUserId
that have been removed from the gateway schema.
API
delegateToSchema
The delegateToSchema
method should be called with the following named options:
delegateToSchema(options: {
schema: GraphQLSchema
operation: 'query' | 'mutation' | 'subscription'
fieldName: string
args?: Record<string, any>
context: Record<string, any>
info: GraphQLResolveInfo
transforms?: Array<Transform>
}): Promise<any>
schema: GraphQLSchema
A subschema to delegate to.
operation: 'query' | 'mutation' | 'subscription'
The operation type to use during the delegation.
fieldName: string
A root field in a subschema from which the query should start.
args: Record<string, any>
Additional arguments to be passed to the field. Arguments passed to the field that is being resolved will be preserved if the subschema expects them, so you don’t have to pass existing arguments explicitly, though you could use the additional arguments to override the existing ones. For example:
# Subschema
type Booking {
id: ID!
}
type Query {
bookingsByUser(userId: ID!, limit: Int): [Booking]
}
# Schema
type User {
id: ID!
bookings(limit: Int): [Booking]
}
type Booking {
id: ID!
}
If we delegate User.bookings
to Query.bookingsByUser
, we want to preserve the limit
argument
and add a userId
argument by using the User.id
. So the resolver would look like the following:
import { delegateToSchema } from '@graphql-tools/delegate'
const resolvers = {
User: {
bookings(parent, args, context, info) {
return delegateToSchema({
schema: subschema,
operation: 'query',
fieldName: 'bookingsByUser',
args: {
userId: parent.id
},
context,
info
})
}
// ...
}
// ...
}
context: Record<string, any>
GraphQL’s context that is going to be passed to the subschema execution or subscription call.
info: GraphQLResolveInfo
GraphQL resolves info of the current resolver. Provides access to the subquery that starts at the current resolver.
transforms: Transform[]
Any additional operation transforms to apply to the query and results.
Transforms are specified similarly to the transforms used in conjunction within the
subschemas, but only the operational components of
transforms will be used by delegateToSchema
, i.e. any specified transformRequest
and
transformResult
functions. The following transforms are automatically applied during schema
delegation to translate between source and target types and fields: