Anatomia de um resolver
Resolver recebe quatro argumentos: parent (valor resolvido do pai), args (argumentos do campo), context (per-request — user, loaders, db) e info (AST da query, útil para projeção seletiva). O valor retornado pode ser sync, Promise ou async iterator (subscriptions).
const resolvers = {
Post: {
author: (post, _args, ctx) => ctx.loaders.userById.load(post.authorId),
comments: (post, { first = 10 }, ctx) => ctx.loaders.commentsByPostId.load(post.id),
},
};O problema N+1 em carne viva
Query lista 100 posts. Cada post resolve author. Sem DataLoader: 1 SELECT de posts + 100 SELECTs de users. Com DataLoader: 1 + 1. Mesma query, latência 100x menor. Isso é a diferença entre GraphQL usável e GraphQL que derruba o banco.
Métrica obrigatória em produção: query count por request. Sem isso, N+1 aparece só quando o banco cai. Logue ctx.db.queryCount ao fim de cada request, alerte acima de um threshold por complexidade.
DataLoader na prática
import DataLoader from 'dataloader';
export function createLoaders(db) {
return {
userById: new DataLoader(async (ids: readonly string[]) => {
const rows = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[ids as string[]],
);
const byId = new Map(rows.map(r => [r.id, r]));
return ids.map(id => byId.get(id) ?? null);
}),
};
}
// Apollo Server context
const server = new ApolloServer({ schema });
await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await authenticate(req),
loaders: createLoaders(db),
db,
}),
});O array retornado precisa ter o mesmo length e ordem dos ids. Se um id não existe, devolva null naquela posição — nunca filter. Senão DataLoader liga ids errados a resultados errados.
Quando DataLoader não resolve
Top-N por parent (últimos 5 posts de cada user) exige janela SQL ou lateral join. Agregações exigem contador materializado. Filtros dinâmicos por parent exigem query batcher custom (encode filter + id no key, rehydrate no batch). Se o resolver precisa de contexto além do id, considere resolver no pai.