https://medium.com/paypal-tech/graphql-resolvers-best-practices-cd36fdbcef55
- Field has resolvers
- 4 steps to queries execution
- Abstract syntax tree. Runtime walks from parent down
Context
for storing models/fetchers for API and databases- Passing data from parent to child should be avoided
- Beware of overfetching
- Use field level fetching and libraries to dedupe fetches
Every field on every type is backed by a function called resolvers
Resolvers can return primitives or objects. Resolvers can be asynchronous too
Queries are parsed
, validated
, and executed
- Parse - into an abstract syntax tree (AST)
- Validate - AST validated against the schema
- Execute - Runtime walks through AST from root of tree down, invoking resolvers, collecting results, emitting JSON
This query ...
query {
user {
name
email
}
album {
title
}
}
... is parsed into the below abstract syntax tree:
Two root fields user
and album
resolvers are executed in parallel.
These fields are executed breadth-first before children (ie name
, email
, and title
) are resolved
Resolvers take 4 arguments root
, args
, context
, and info
.
root
- from parent typeargs
- arguments to the fieldcontext
- mutable object provided to all resolversinfo
- field specific information relevant to query
Example
export default {
Event: {
title: (root, args, context, info) => {
// Do stuff
},
},
};
Context
is a mutable object provided to resolvers. They are created and destroyed between each request.
Context
is a great place to store common Auth data, models/fetchers for API and databases
It is not recommended to use context
as a general purpose cache
Example schema
type Query {
event(id: ID!): Event
}
type Event {
title: String
photoUrl: String
}
Example resolver
export default {
Event: {
title: async ({ id }, args, context) => {
const event = await getEvent(id);
// Store event in context for later
context.event = event;
return event.title;
},
photoUrl: async (root, args, context) => {
// Not deterministic
// `context.event` might not exist
return context.event.photoUrl;
},
},
};
When title
is invoked, event result is stored in context
.
When photoUrl
is invoked, event is pulled out of context
but it is not guaranteed title
will be executed before photoUrl
It is best to avoid mutating context inside resolvers
root
argument is used for passing data from parent to child
For this example, we might want to fetch the data once on the top level as all fields depend on the same data
type Event {
title: String
photoUrl: String
}
Fetching using the top level event
resolver and provides results to title
and photoUrl
:
export default {
Query: {
event: async (root, { id }) => await getEvent(id),
},
Event: {
title: ({ title }) => title,
photoUrl: ({ photoUrl }) => photoUrl,
},
};
We dont even need to specify bottom two resolvers, by using default resolvers as getEvent()
has title
and photoUrl
property
export default {
Query: {
event: async (root, { id }) => await getEvent(id),
},
};
However, this can lead to overfetching
Example: if we require to display attendees
type Event {
title: String
photoUrl: String
attendees: [Person]
}
Here we have two options: fetch data at event
resolver; or fetch data at attendees
resolver
- Fetching with
event
resolver
export default {
Query: {
event: async (root, { id }) => {
const event = await getEvent(id);
const attendees = await getAttendeesFromEvent(id);
return {
...event,
attendees,
};
},
},
};
If client queries title
and photoUrl
and not attendees
then this is inefficient
- Fetching with
attendees
resolver
export default {
Query: {
event: async (root, { id }) => await getEvent(id),
},
Event: {
attendees: async (root, { id }) => await getAttendeesFromEvent(id),
},
};
If client queries only attendees
then this is inefficient
Because data is fetch at field-level, we risk overfetching.
Example with events
field that returns all events:
type Query {
event(id: ID): Event
events: [Event]
}
type Event {
title: String
photoUrl: String
attendees: [Person]
}
type Person {
id: ID
name: String
}
Example of query for all events
with title and attendees
query {
events {
title
attendees {
name
}
}
}
If client queries all events
and attendees
, we risk overfetching because attendees
can attend more than one event. We may make duplicate requests
To solve this we need batch and de-dupe requests. Popular options in JavaScripts are dataloader and Apollo data sources
What if child fields are responsible for fetchign their own data
export default {
Query: {
event: async (root, { id }) => ({ id }),
},
Event: {
title: async ({ id }) => {
const { title } = await getEvent(id);
return title;
},
photoUrl: async ({ id }) => {
const { photoUrl } = await getEvent(id);
return photoUrl;
},
attendees: async ({ id }) => {
await getAttendeesFromEvent(id);
},
},
};
Fields are responsible for their own data fetching!
Benefits
- Code is easy to reason and debug
- Code is testable in smaller units
- Repeated
getEvent()
might be code smell but it is worth the simplicity
However, if client queries title
and photoUrl
, there are still 2 getEvent
calls as per N+1 problem. This can be managed with libraries like dataLoader and Apollo data sources
-
Fetching and passing data from parent to child should be used sparingly
-
Use libraries to de-dupe downstream requests
-
Be aware of any pressure causing on data sources
-
Don't mutate
context
to ensure deterministic code -
Write resolvers that are readable, maintainable, testable. Don't be clever
-
Make resolvers as thin as possible. Extract out data fetching logic to reusable async functions