StackPractices
intermediate Por Mathias Paulenko

Construye una API GraphQL con Apollo Server y TypeScript

Cómo construir una API GraphQL lista para producción usando Apollo Server, TypeScript y DataLoader para resolver el problema de consultas N+1

Temas: api

Descripción General

GraphQL permite a los clientes pedir exactamente los datos que necesitan en una sola consulta, y Apollo Server sigue siendo la forma más común de servir un esquema desde Node. Esta receta construye una API pequeña pero real: definiciones schema-first, resolvers conectados a una base de datos, batching con DataLoader bien hecho (por petición, no por proceso), autenticación por token a través del contexto y una directiva @auth que de verdad compila.

Cuándo Usar Esto

  • Los clientes necesitan consultas flexibles sobre un modelo de dominio complejo
  • Quieres reducir el over-fetching y under-fetching habitual en APIs REST
  • Las actualizaciones en tiempo real vía suscripciones son un requisito

Requisitos Previos

  • Node.js 18+
  • Sintaxis básica de esquemas GraphQL

Solución

1. Instalar Dependencias

npm install @apollo/server graphql graphql-tag dataloader @graphql-tools/utils
npm install -D @types/node typescript

2. Definir el Esquema

// schema.ts
import { gql } from 'graphql-tag';

export const typeDefs = gql`
  directive @auth on FIELD_DEFINITION

  type User {
    id: ID!
    email: String!
    name: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Query {
    user(id: ID!): User
    users(limit: Int = 10): [User!]!
    posts: [Post!]!
  }

  type Mutation {
    createPost(title: String!, content: String!): Post! @auth
  }
`;

3. Implementar Resolvers con DataLoader por Petición

La trampa aquí es crear el loader una sola vez y reutilizarlo entre peticiones. DataLoader guarda una caché interna, así que una instancia compartida puede entregar un User obsoleto a otra petición. Crea el loader dentro de context para que cada petición reciba uno nuevo.

// resolvers.ts
import type { GraphQLContext } from './context';

export const resolvers = {
  Query: {
    user: (_: unknown, { id }: { id: string }, ctx: GraphQLContext) =>
      ctx.db.users.findById(id),
    users: (_: unknown, { limit }: { limit: number }, ctx: GraphQLContext) =>
      ctx.db.users.findMany({ take: limit }),
    posts: (_: unknown, __: unknown, ctx: GraphQLContext) => ctx.db.posts.findMany(),
  },

  Mutation: {
    createPost: (
      _: unknown,
      args: { title: string; content: string },
      ctx: GraphQLContext,
    ) => ctx.db.posts.create({ ...args, authorId: ctx.user!.id }),
  },

  Post: {
    // Agrupado: N posts siguen produciendo una sola consulta por petición
    author: (post: { authorId: string }, _: unknown, ctx: GraphQLContext) =>
      ctx.loaders.users.load(post.authorId),
  },

  User: {
    posts: (user: { id: string }, _: unknown, ctx: GraphQLContext) =>
      ctx.db.posts.findMany({ where: { authorId: user.id } }),
  },
};

4. Crear el Contexto y el Servidor

// context.ts
import DataLoader from 'dataloader';
import { db, verifyToken } from './db';

export const createContext = async (authHeader?: string) => {
  const token = authHeader?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;

  return {
    db,
    user,
    loaders: {
      users: new DataLoader<string, User | undefined>(async (ids) => {
        const rows = await db.users.findMany({ where: { id: { in: [...ids] } } });
        return ids.map((id) => rows.find((u) => u.id === id));
      }),
    },
  };
};

export type GraphQLContext = Awaited<ReturnType<typeof createContext>>;
// server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
import { authDirective } from './auth';

// schemaTransform conecta @auth al esquema ejecutable
const schema = authDirective(typeDefs, resolvers);

const server = new ApolloServer({ schema });

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => createContext(req.headers.authorization),
});

console.log(`Server ready at ${url}`);

5. Autenticación Mediante una Directiva de Esquema

// auth.ts
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
import { makeExecutableSchema } from '@graphql-tools/schema';

export const authDirective = (typeDefs: any, resolvers: any): GraphQLSchema =>
  mapSchema(makeExecutableSchema({ typeDefs, resolvers }), {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const usesAuth = getDirective(
        makeExecutableSchema({ typeDefs, resolvers }),
        fieldConfig,
        'auth',
      )?.[0];
      if (!usesAuth) return fieldConfig;

      const { resolve = defaultFieldResolver } = fieldConfig;
      fieldConfig.resolve = (source, args, context, info) => {
        if (!context.user) throw new Error('Unauthorized');
        return resolve(source, args, context, info);
      };
      return fieldConfig;
    },
  });

Cómo Funciona

flowchart diagram: Consulta del cliente

La fábrica de contexto se ejecuta una vez por petición HTTP, que es exactamente donde pertenece DataLoader. Dentro de una misma petición, el loader agrupa todas las llamadas users.load(id) en una sola consulta a la base de datos y memoriza el resultado, así que Post.author sobre cincuenta posts sigue costando un solo viaje en lugar de cincuenta.

Variantes

  • Middleware de Express: cambia startStandaloneServer por expressMiddleware de @apollo/server/express4 cuando la API comparte proceso con rutas REST o necesita middleware propio.
  • GraphQL Yoga: un servidor más ligero con el mismo modelo de resolvers; buena opción si no necesitas el ecosistema de plugins de Apollo.
  • Apollo Federation: divide el esquema entre servicios detrás de un gateway cuando tengas más de un servicio GraphQL que mantener. Mira la receta de gateway de federación.

Mejores Prácticas

  • Diseña schema-first. Escribe el contrato antes que los resolvers; te obliga a pensar en lo que los clientes realmente necesitan en vez de exponer tus tablas.
  • Desactiva Sandbox en producción. Expone la introspección y un constructor de consultas. Pasa introspection: false (o nodeEnv: 'production') en las opciones del servidor.
  • Mete el estado de petición en el contexto. Autenticación, loaders, fuentes de datos: todo lo que pertenece a una petición va por context, nunca en singletons a nivel de módulo.
  • Anota los campos cacheables con @cacheControl (maxAge, scope) para que Apollo Server y los CDN aguas abajo puedan cachear lecturas.
  • Compara el esquema en CI con graphql-inspector y bloquea PRs que eliminen campos o cambien tipos sin un periodo de deprecation.

Errores Comunes

  • Un DataLoader a nivel de proceso. Su caché es un Map que vive tanto como la instancia; compártela entre peticiones y servirás los datos de un usuario a otro. Crea siempre los loaders dentro de la fábrica de contexto.
  • Playground encendido en producción. Los valores por defecto de desarrollo exponen tu esquema y dejan ejecutar consultas arbitrarias a cualquiera. Desactiva la introspección y Sandbox antes de desplegar.
  • Resolvers que devuelven filas completas. Si el cliente pide dos campos, traer la fila entera desperdicia ancho de banda y CPU: selecciona solo lo que necesita el selection set.
  • Sin límite de profundidad ni de coste. Un cliente malicioso o con bugs puede anidar consultas hasta tumbar el servidor. Limita la profundidad (7-10 niveles) y considera persisted queries para clientes propios.
  • Saltarse el registro del esquema. Sin un registry no hay historial de cambios breaking ni uso por cliente. El plan gratuito de Apollo Studio lo cubre.

Checklist de Producción

  • Introspección y Sandbox desactivados en producción
  • Loaders creados por petición en la fábrica de contexto
  • Profundidad de consultas limitada (máx. 7-10 niveles)
  • Persisted queries o safelisting activos para clientes propios
  • @cacheControl en los campos cacheables
  • El formato de errores oculta detalles internos (formatError o includeStacktraceInErrorResponses: false)
  • Health check en /.well-known/apollo/server-health
  • Esquema registrado en Apollo Studio para seguimiento de cambios

Consideraciones de Escalado

  • Fan-out de resolvers: los resolvers de campos hermanos pueden correr en paralelo, pero una cadena de resolvers anidados se serializa. Una consulta con cincuenta campos a 5 ms por resolver cuesta ~250 ms; aplana la forma o agrupa con loaders cuando los datos lo permitan.
  • Result sets grandes: GraphQL construye el objeto de respuesta completo en memoria antes de serializarlo. Pagina pasados unos miles de elementos; @stream/@defer permiten entrega incremental para hojas lentas.
  • Varios nodos a la vez: el servidor no guarda estado de sesión, así que escalar significa añadir procesos. Las suscripciones rompen ese esquema: necesitan un backend pub/sub compartido (Redis o NATS) para que un evento en un nodo llegue a los suscriptores de los demás.
  • Cold starts en serverless: la construcción y validación del esquema hacen que el cold start en Lambda sea notablemente más lento que un proceso Node en caliente: pre-construye el esquema y mantén el bundle pequeño si vas por esa vía.

Estimación de Costos

Cifras mensuales aproximadas para una API de tamaño medio, a finales de 2026 — verifica los precios vigentes antes de presupuestar:

ComponenteCosteNotas
Apollo Server (self-hosted)$0Open source, corre en cualquier host Node
Apollo GraphOSPlan gratuitoSchema registry y métricas básicas; los planes de pago añaden tracing y SSO
Redis (suscripciones + caché)~$10-75Según tamaño de instancia y proveedor
Una VM tipo t3.small~$10-15Aguanta unos miles de peticiones por segundo con esta forma

El self-hosting mantiene el coste base cerca del precio de la VM. Las piezas gestionadas se pagan cuando necesitas gobernanza del esquema, analítica de uso o routing entre subgraphs.

Cuándo No Usar Este Enfoque

  • CRUD plano sobre un modelo simple: cinco endpoints REST y un documento OpenAPI ganan a un servidor GraphQL con una décima parte de piezas móviles.
  • Runtimes edge: Apollo Server espera Node.js. Para Cloudflare Workers o Vercel Edge, mira Yoga o un handler más ligero.
  • Payloads binarios pesados: las subidas y descargas pertenecen a un endpoint REST o a object storage; la spec multipart de GraphQL funciona, pero rara vez es la herramienta correcta.

Estrategia de Testing

  • Inyección de contexto: crea una instancia del servidor con un contexto mock (auth, loaders, db falsa) y verifica que los resolvers ven la forma correcta y fallan limpiamente cuando falta algo.
  • Validez del esquema: ejecuta assertValidSchema y graphql-inspector diff en CI para que un cambio breaking no se cuele en un merge.
  • Cabeceras de caché: envía una consulta anotada con @cacheControl y comprueba la cabecera Cache-Control a la salida.
  • Limpieza de suscripciones: abre una suscripción por WebSocket, ciérrala y verifica que el servidor la retira del registro pub/sub en pocos segundos.

Troubleshooting

  • Errores 5xx bajo carga: revisa límites de rate, pools de conexión y timeouts aguas abajo.
  • Errores CORS en el navegador: confirma orígenes, métodos y cabeceras permitidos. Las peticiones preflight deben devolver las cabeceras correctas antes de la petición real.
  • 404 inesperados: verifica definiciones de rutas, parámetros de path y base paths. Ojo con las barras finales y las diferencias de codificación de URL.
  • Fallos de autenticación: valida la expiración del token, los algoritmos de firma y el desfase de reloj. Registra los tokens rechazados sin exponer secretos.
  • Respuestas lentas: perfila la latencia por resolver; normalmente una llamada de loader sin agrupar explica la cola.

Ver También

Preguntas frecuentes

¿Debo usar Apollo Server o GraphQL Yoga?

Apollo Server tiene el mayor ecosistema de plugins y la integración con Apollo Studio. Yoga es más ligero y arranca más rápido, lo que importa en runtimes edge y serverless. Ambos son aptos para producción; elige Yoga cuando el tiempo de arranque o el tamaño del bundle es la restricción, Apollo cuando quieras las herramientas que lo rodean.

¿DataLoader va en el contexto o a nivel de módulo?

Por petición, en el contexto. El loader cachea resultados, y una caché compartida puede filtrar los datos de un usuario a otra petición. La fábrica de contexto es el ámbito correcto: una instancia por petición HTTP.

¿Cómo manejo las subidas de archivos en GraphQL?

Usa graphql-upload-minimal con peticiones multipart, o prefiere un endpoint REST separado para archivos grandes — suele ser más simple y más barato.

¿Las suscripciones funcionan directamente con varios nodos?

Cada nodo mantiene su propio pub/sub en memoria, así que un evento publicado en uno nunca llega a los suscriptores conectados a otro. Apunta todos los nodos a un backend compartido como Redis o NATS.

¿Necesito Apollo Studio para ejecutar Apollo Server?

No. El servidor es totalmente autocontenido. Studio añade schema registry, detección de cambios breaking y analítica de uso por cliente: vale la pena en producción, opcional mientras prototipas.

¿Cuál es la diferencia entre persisted queries y safelisting?

Las persisted queries permiten al cliente enviar un hash en lugar del texto completo de la consulta, ahorrando ancho de banda y tiempo de parseo. El safelisting va más allá: solo se ejecutan consultas pre-registradas, lo que bloquea consultas caras arbitrarias de clientes desconocidos.