Patron de Resolver Batch en GraphQL
Resuelve campos anidados de GraphQL en una sola peticion batch para eliminar consultas N+1 y reducir la carga de la base de datos.
Descripcion general
Cuando una query GraphQL pide campos anidados a traves de una lista, cada item dispara una llamada separada a la base de datos. Consultar 50 posts y sus autores produce 50 sentencias SELECT extra — el clasico problema N+1. Los resolvers batch recolectan todas las claves de la lista, hacen una sola peticion batch, y distribuyen los resultados de vuelta a cada item.
El patron se combina naturalmente con DataLoader, que maneja batching, caching y deduplicacion por peticion.
Cuando Usar
-
For alternatives, see GraphQL DataLoader Pattern.
-
Resolvers que obtienen datos relacionados por item padre (posts → autor, ordenes → producto)
-
Listas de items donde cada item tiene una relacion anidada
-
Cualquier campo GraphQL que dispara una query de base de datos o llamada API por padre
-
Capas de gateway o stitching que delegan a servicios backend
Cuando No Usar
- Queries de un solo item donde N=1 (sin beneficio de batching)
- Campos resueltos desde datos ya cargados en el objeto padre (usar acceso directo a propiedad)
- Subscripciones en tiempo real donde las ventanas de batch anaden latencia inaceptable
Solucion
1. El Problema N+1
Sin batching, cada post dispara una busqueda de autor separada:
const resolvers = {
Post: {
author: (post, _args, { db }) => {
// Llamado una vez por post — 50 posts = 50 queries
return db.user.findById(post.authorId);
},
},
};
Consultar 50 posts genera 50 queries SQL:
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 1; -- duplicado
SELECT * FROM users WHERE id = 3;
-- ... 46 mas
2. DataLoader: Batch y Cache
DataLoader recolecta todos los valores de authorId dentro de un mismo tick del event loop, los deduplica, y envia una sola peticion batch.
import DataLoader from 'dataloader';
async function batchLoadUsers(userIds: readonly string[], { db }) {
// Una query: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
const users = await db.user.findMany({ where: { id: { in: [...userIds] } } });
// DataLoader espera resultados en el mismo orden que las claves de entrada
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
}
// Crear un nuevo DataLoader por peticion para evitar caching entre peticiones
function createLoaders(context) {
return {
userLoader: new DataLoader(
(ids) => batchLoadUsers(ids, context),
{ cacheKeyFn: (key) => key.toString() }
),
};
}
3. Usar el Loader en Resolvers
const resolvers = {
Post: {
author: (post, _args, { loaders }) => {
// DataLoader agrupa todas las llamadas dentro del mismo tick
return loaders.userLoader.load(post.authorId);
},
},
Query: {
posts: async (_parent, _args, { db }) => {
return db.post.findMany({ take: 50 });
},
},
};
Ahora 50 posts producen una sola query SQL:
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, ...);
4. Inicializacion de Loaders por Peticion
Crear loaders frescos para cada peticion para evitar filtrar datos cacheados entre usuarios:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const db = getDatabase();
const loaders = createLoaders({ db });
return { db, loaders, user: req.headers.authorization };
},
listen: { port: 4000 },
});
5. Batching Entre Multiples Campos
DataLoader agrupa tambien entre diferentes campos de resolver. Si una query pide post.author y comment.author, ambos resuelven a traves del mismo userLoader:
const resolvers = {
Post: {
author: (post, _args, { loaders }) => loaders.userLoader.load(post.authorId),
},
Comment: {
author: (comment, _args, { loaders }) => loaders.userLoader.load(comment.authorId),
},
};
Una query como { posts { author { name } } comments { author { name } } } sigue produciendo una sola llamada batch para todos los IDs de autor.
Explicacion
- Ventana de batch: DataLoader recolecta todas las llamadas
.load()dentro de un tick del event loop de Node.js, luego dispara la funcion batch una vez - Deduplicacion: Si dos posts tienen el mismo
authorId, DataLoader pide ese usuario una vez y devuelve el resultado cacheado para ambos - Cache por peticion: El cache vive solo durante una operacion GraphQL, previniendo datos stale entre peticiones
- Preservacion de orden: La funcion batch debe retornar resultados en el mismo orden que las claves de entrada — DataLoader empareja resultados por posicion
Variantes
Funciones Batch Personalizadas para APIs REST
Batchear contra un endpoint REST que acepta multiples IDs:
const productLoader = new DataLoader(async (productIds: readonly string[]) => {
const response = await fetch(
`https://api.example.com/products?ids=${productIds.join(',')}`
);
const products = await response.json();
const productMap = new Map(products.map((p) => [p.id, p]));
return productIds.map((id) => productMap.get(id));
});
Batch con Tamano Maximo
Limitar el tamano del batch para evitar clausulas IN demasiado grandes:
const userLoader = new DataLoader(batchLoadUsers, {
maxBatchSize: 100,
cache: true,
});
Batch con Priming
Pre-poblar el cache cuando ya tienes los datos:
Query: {
user: async (_parent, { id }, { db, loaders }) => {
const user = await db.user.findById(id);
// Primear el cache para que los resolvers anidados no re-fetchen
loaders.userLoader.prime(id, user);
return user;
},
},
Batch con Cache Key Personalizado
Para claves compuestas (ej. tenant + user ID):
const loader = new DataLoader(batchLoad, {
cacheKeyFn: (key) => `${key.tenantId}:${key.userId}`,
});
Mejores Practicas
- Crear una instancia de DataLoader por peticion, nunca compartida entre peticiones
- Retornar siempre resultados en el mismo orden que las claves de entrada en funciones batch
- Usar
prime()cuando ya tienes datos para evitar fetches redundantes - Configurar
maxBatchSizepara mantener clausulasINdentro de los limites de la base de datos - Manejar errores por clave: retornar un objeto
Errorpara items fallidos, no lanzar una excepcion - Monitorear tamanos de batch en produccion para detectar patrones de query inesperados
Errores Comunes
- Compartir DataLoader entre peticiones: Causa fugas de datos entre usuarios y cache hits stale
- Retornar resultados desordenados: DataLoader empareja por posicion, resultados desalineados silenciosamente devuelven datos incorrectos
- Lanzar en funcion batch: Una clave fallida rechaza todo el batch. Retornar
new Error()por item en su lugar - No usar
cacheKeyFnpara claves no-string: Objetos como claves causan cache misses porque{}!=={}por referencia - Olvidar limpiar el cache en mutaciones: Despues de actualizar un usuario, llamar
loader.clear(id)para prevenir lecturas stale
Temas Avanzados
Escenario: Batched Resolver para N+1 en GraphQL
// DataLoader: batchear requests para evitar N+1
import DataLoader from "dataloader";
// Resolver sin batching: N+1 queries
// query { users { orders { id } } } -> 1 query users + N queries orders
// Resolver con DataLoader: 1 query batch
const orderLoader = new DataLoader<string, Order[]>(async (userIds) => {
// 1 query para todos los users
const res = await db.query(
"SELECT * FROM orders WHERE user_id = ANY($1)",
[userIds]
);
// Agrupar por user_id
const ordersByUser = new Map<string, Order[]>();
for (const order of res.rows) {
const list = ordersByUser.get(order.user_id) || [];
list.push(order);
ordersByUser.set(order.user_id, list);
}
// Retornar en el mismo orden que userIds
return userIds.map(id => ordersByUser.get(id) || []);
});
// GraphQL resolver
const resolvers = {
User: {
orders: (parent: User) => orderLoader.load(parent.id),
},
Query: {
users: async () => {
const res = await db.query("SELECT * FROM users LIMIT 100");
return res.rows;
},
},
};
// Antes: 100 users -> 100 queries de orders (N+1)
// Despues: 100 users -> 1 query de orders (batched)
// DataLoader agrupa todas las llamadas .load() en un tick
Lecciones:
- DataLoader agrupa llamadas .load() en un mismo tick de event loop
- 1 query batch en lugar de N queries individuales
- DataLoader cachea por request: no compartir entre requests
- El orden del resultado debe coincidir con el orden de keys
- En Apollo Server, crear DataLoader por request en context
- Usar para cualquier relacion 1:N o N:1 en GraphQL
### Como configuro DataLoader en Apollo Server?
Crea una instancia de DataLoader por request en el context function. No compartas DataLoader entre requests: la cache de un request puede contaminar otro. En el context: () => ({ orderLoader: new DataLoader(batchFn) }). El DataLoader se crea por request y se descarta al terminar. Para cache entre requests, usar Redis con cacheKeyFn. Para batching manual sin DataLoader: agrupar ids en un Set y hacer una sola query con IN clause. Preguntas frecuentes
¿Es este patrón adecuado para proyectos pequeños?
Para proyectos pequeños con pocos componentes, este patrón puede añadir complejidad innecesaria. Empieza simple e introduce el patrón cuando sientas el problema que resuelve.
¿Cómo se compara este patrón con alternativas?
Cada patrón hace diferentes trade-offs. Revisa la tabla de variantes arriba y considera tus restricciones específicas: tamaño del equipo, requisitos de rendimiento y planes de escalado.
¿Puedo aplicar este patrón parcialmente?
Sí. Muchos equipos adoptan patrones incrementalmente. Empieza con la idea central y añade sofisticación según sea necesario. El patrón es una guía, no un blueprint estricto.
Recursos Relacionados
Patron DataLoader en GraphQL
Consolida llamadas de carga individuales en llamadas batch con cache por peticion para prevenir queries N+1 y fetches redundantes.
GuideGuía Completa de GraphQL Federation
Construye APIs GraphQL unificadas con Apollo Federation. Cubre subgraphs, supergraph, resolución de entidades y despliegue de gateway.
GuideGuía Completa de GraphQL Federation
Construye APIs GraphQL unificadas con Apollo Federation. Cubre subgraphs, supergraph, resolución de entidades y despliegue de gateway.
RecipeDetectar y corregir consultas N+1 en resolvers GraphQL
Identifica problemas de consultas N+1 en resolvers GraphQL usando logging, DataLoader y herramientas de analisis antes de que lleguen a produccion
RecipePaginacion por cursores con GraphQL Relay Connections
Implementa paginacion estilo Relay con cursores en GraphQL usando edges, nodes y pageInfo para recorrido eficiente hacia adelante y atras
PatternPatron GraphQL Connection Pagination
Implementa paginacion basada en cursores estilo Relay con edges, nodes y pageInfo para queries de listas estables en GraphQL.