Dataloader plugin
Batch object and relation loads through a dataloader to kill N+1 queries, using loadableObject and the loadable field methods.
A GraphQL query that walks a list and then a field on each item fans out into one database round-trip per item, the N+1 problem. The dataloader plugin folds those calls into one. You define a type with builder.loadableObject, resolvers return ids instead of objects, and the plugin routes each id into a DataLoader, which coalesces every id loaded in a tick into a single load call.
Install
npm install --save dataloader @pothos/plugin-dataloaderloadableObject, the loadable* field methods, and the helpers below all come from the plugin once it's on the builder.
Loadable objects
builder.loadableObject defines an object type whose backing rows are fetched through a dataloader. load receives the ids collected across the whole query; fields resolving to that type return an id and the plugin does the fetching.
import DataloaderPlugin from '@pothos/plugin-dataloader';
const builder = new SchemaBuilder({
plugins: [DataloaderPlugin],
});
const Player = builder.loadableObject('Player', {
// Called once per request with every id the query touched. Return one result
// per id, in the same order — an Error in a slot fails just that player.
load: async (ids: string[]) => {
console.log('loading players', ids);
return ids.map((id) => players.get(id) ?? new Error(`No player ${id}`));
},
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
number: t.exposeInt('number'),
}),
});
builder.queryType({
fields: (t) => ({
// Return the id; the loader turns it into a Player.
player: t.field({
type: Player,
args: { id: t.arg.string({ required: true }) },
resolve: (_root, args) => args.id,
}),
// A list of ids batches into a single load call.
roster: t.field({
type: [Player],
args: { ids: t.arg.stringList({ required: true }) },
resolve: (_root, args) => args.ids,
}),
}),
});Annotate the ids parameter (and context, if you use it). Pothos reads the load function's types to constrain what resolvers for this type may return. A resolver can return a string, number, or bigint key, and the loader fetches it. When you already have the record in hand, return the full object instead; Pothos detects that it isn't a key and skips the loader. Lists may mix the two: [...ids, alreadyLoadedPlayer] resolves the keys through the loader and passes the object straight through.
load must return results in the same order as the ids it was given, because the plugin maps results back to ids positionally, so a shuffled result array hands clients the wrong records. Fetching from a database rarely preserves order; the sort option below does the reordering for you. See the dataloader batch-function docs for the contract in full.
Batching relations
A one-to-many like a team's roster is a loadableGroup field: load runs once with every parent id in the query and returns a flat list, and group sorts each row back to the parent it belongs to.
const Team = builder.loadableObject('Team', {
load: async (ids: string[]) => {
console.log('loading teams', ids);
return ids.map((id) => teams.get(id) ?? new Error(`No team ${id}`));
},
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
// One load call fetches the roster for every team in the query at once.
// `load` returns a flat list; `group` sorts each player back to its team.
players: t.loadableGroup({
type: Player,
load: async (teamIds: string[]) => {
console.log('loading rosters', teamIds);
return players.filter((player) => teamIds.includes(player.teamId));
},
group: (player: IPlayer) => player.teamId,
resolve: (team: ITeam) => team.id,
}),
}),
});Use loadableGroup when your query returns a flat list, since group-by is cheaper than nesting. If your data source already returns a list per parent (a Player[][], one array per id, in id order), use loadableList instead:
builder.objectField(Team, 'players', (t) =>
t.loadableList({
type: Player,
// called with every team id; returns Player[][], one array per id, in order
load: (teamIds: string[], context) => context.rostersByTeamId(teamIds),
resolve: (team) => team.id,
}),
);For a plain many-to-one (or a field with its own dataloader) use t.loadable. Its type may be a single type or a list; load is called with the keys your resolve returns:
builder.objectField(Player, 'team', (t) =>
t.loadable({
type: Team,
load: (ids: string[], context) => context.loadTeams(ids),
resolve: (player) => player.teamId,
}),
);loadableInterface and loadableUnion mirror loadableObject for interface and union types, and take the same load/sort options.
Field arguments
A field's load normally has no access to its arguments, because the dataloader aggregates calls across selections and aliases that may not share arguments. Pass byPath: true to aggregate only calls at the same query path, which do share arguments, and load gains a third args parameter:
builder.objectField(Team, 'topScorers', (t) =>
t.loadable({
type: [Player],
byPath: true,
args: { limit: t.arg.int({ required: true }) },
load: (ids: string[], context, args) => context.loadTopScorers(ids, args.limit),
resolve: (team) => team.id,
}),
);Dataloader options
Pass options straight through to the underlying dataloader with loaderOptions, on a loadable type or any loadable field. See the dataloader API for the full set.
const Player = builder.loadableObject('Player', {
loaderOptions: { maxBatchSize: 20 },
load: (ids: string[], context) => context.loadPlayers(ids),
fields: (t) => ({ id: t.exposeID('id') }),
});Sorting load results
Rather than hand-sorting load results into id order, give any loadable type or field a sort function that returns a row's key. The plugin builds the id-to-result map for you:
const Player = builder.loadableObject('Player', {
load: (ids: string[], context) => context.loadPlayers(ids),
sort: (player) => player.id,
fields: (t) => ({ id: t.exposeID('id') }),
});sort throws if a result is an Error, because an error has no key to sort by. Don't use sort on a loader whose results may include per-item errors; return them in id order yourself instead.
Caching resolved values
When a resolver returns a full object it skips the loader, so that object never enters the loader's cache and a later selection re-fetches it. cacheResolved primes the cache with anything a resolver returns; pass a function that maps the object to its key:
const Player = builder.loadableObject('Player', {
load: (ids: string[], context) => context.loadPlayers(ids),
cacheResolved: (player) => player.id,
fields: (t) => ({ id: t.exposeID('id') }),
});When you need both cacheResolved and sort, defining the key extractor twice is redundant. Provide a single toKey and set the other two to true:
const Player = builder.loadableObject('Player', {
load: (ids: string[], context) => context.loadPlayers(ids),
toKey: (player) => player.id,
cacheResolved: true,
sort: true,
fields: (t) => ({ id: t.exposeID('id') }),
});Using the loader directly
Every loadable type exposes its dataloader through getDataloader(context). Loaders live on the context, so they aren't shared across requests, so pass the current context to get the right one:
builder.queryField('player', (t) =>
t.field({
type: Player,
resolve: (_root, _args, context) => Player.getDataloader(context).load('1'),
}),
);loadMany resolves to (Player | Error)[]. GraphQL has no special handling for Error objects in a list, so wrap the call in rejectErrors to turn each error into a rejected promise the normal resolver flow can surface:
import { rejectErrors } from '@pothos/plugin-dataloader';
builder.queryField('players', (t) =>
t.field({
type: [Player],
resolve: (_root, _args, context) =>
rejectErrors(Player.getDataloader(context).loadMany(['1', '2'])),
}),
);Your own load function may return the same (Player | Error)[] shape when it has partial failures; the plugin maps each Error to a rejected promise so it errors only that item.
Loaders on the context
To reach loaders straight from context rather than a type ref, add helpers to your context type and factory. initContextCache keeps the loaders consistent if your server copies the context before resolving:
import { LoadableRef } from '@pothos/plugin-dataloader';
export interface ContextType {
playerLoader: DataLoader<string, { id: string }>;
getLoader: <K, V>(ref: LoadableRef<K, V, ContextType>) => DataLoader<K, V>;
load: <K, V>(ref: LoadableRef<K, V, ContextType>, id: K) => Promise<V>;
loadMany: <K, V>(ref: LoadableRef<K, V, ContextType>, ids: K[]) => Promise<(Error | V)[]>;
}import { initContextCache } from '@pothos/core';
import { LoadableRef, rejectErrors } from '@pothos/plugin-dataloader';
export const createContext = (req, res): ContextType => ({
// Prevents duplicate loaders if the server extends the context object.
...initContextCache(),
// Getters let each helper read the live context through `this`.
get playerLoader() {
return Player.getDataloader(this);
},
get getLoader() {
return <K, V>(ref: LoadableRef<K, V, ContextType>) => ref.getDataloader(this);
},
get load() {
return <K, V>(ref: LoadableRef<K, V, ContextType>, id: K) => ref.getDataloader(this).load(id);
},
get loadMany() {
return <K, V>(ref: LoadableRef<K, V, ContextType>, ids: K[]) =>
rejectErrors(ref.getDataloader(this).loadMany(ids));
},
});Resolvers then load from the context directly: context.playerLoader.load('1'), context.getLoader(Player).load('2'), context.load(Player, '3'), or context.loadMany(Player, ['1', '2']).
Relay nodes
With the Relay plugin installed, builder.loadableNode creates a Node that loads through a dataloader like any other loadable object:
const PlayerNode = builder.loadableNode('PlayerNode', {
id: { resolve: (player) => player.id },
load: (ids: string[], context) => context.loadPlayers(ids),
fields: (t) => ({ name: t.exposeString('name') }),
});To data-load a Relay connection, combine builder.connectionObject for the edge and connection types, a byPath loadable field so it can read the connection arguments, and t.arg.connectionArgs:
const TeammatesConnection = builder.connectionObject({
type: Player,
name: 'TeammatesConnection',
});
builder.objectFields(Player, (t) => ({
teammates: t.loadable({
type: TeammatesConnection,
byPath: true,
args: { ...t.arg.connectionArgs() },
load: async (ids: string[], context, args) => {
const teammatesById = await context.loadTeammates(ids);
return ids.map((id) => resolveArrayConnection({ args }, teammatesById[id]));
},
resolve: (player) => player.id,
}),
}));Splitting refs
Two loadable objects that reference each other in their definitions can trip circular-type errors. As with builder.objectRef, you can split the declaration from the implementation. loadableObjectRef (and loadableNodeRef for Relay) take the plugin options up front so the ref can be implemented later:
const Player = builder.loadableObjectRef('Player', {
load: (ids: string[], context) => context.loadPlayers(ids),
});
Player.implement({
fields: (t) => ({ id: t.exposeID('id') }),
});Because the ref carries the load behavior, it also works with builder.objectType(Player, { ... }) and any other method that implements a ref, letting you layer additional behavior on the same loadable type. See Circular references for the broader pattern.
Subscriptions
Under a subscription, loaders live on the subscription's context, so values stay cached for its whole lifetime. Clear them between events with clearAllDataLoaders:
import { clearAllDataLoaders } from '@pothos/plugin-dataloader';
clearAllDataLoaders(context);