StackPractices
intermediate Por Mathias Paulenko

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 maxBatchSize para mantener clausulas IN dentro de los limites de la base de datos
  • Manejar errores por clave: retornar un objeto Error para 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 cacheKeyFn para 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.