Cerramos el módulo con la pregunta que dejó abierta la lección anterior. La pantalla de un evento en la aplicación de Escena Viva necesita el evento, sus sesiones y los datos de la sala. Con la API REST, eso son tres llamadas encadenadas, o una respuesta con ?incluir=sesiones,sala que devuelve mucho más de lo que la pantalla usa. Ninguna de las dos opciones es mala; simplemente, REST administra ese dilema en lugar de eliminarlo. GraphQL lo elimina, y cobra por ello. Esta lección explica qué resuelve, qué precio tiene, cómo se monta sobre la aplicación Express que ya existe, y —lo más importante— cuándo no deberías usarlo.

Contenido

  1. El problema: sobrecarga e infracarga de datos
  2. REST frente a GraphQL: comparación honesta
  3. El esquema como contrato
  4. Resolvers: cómo se resuelve un árbol
  5. Montar el servidor sobre Express
  6. Contexto y autorización
  7. El problema N+1 y DataLoader
  8. Límites obligatorios: la API sin límites es un DoS
  9. Errores en GraphQL
  10. Suscripciones, y cuándo elegir cada cosa

  1. El problema: sobrecarga e infracarga de datos

Dos síntomas con nombre propio. La infracarga (under-fetching): la respuesta no trae todo lo necesario y hay que encadenar GET /api/v1/eventos/evt-003, luego /sesiones, luego /salas/org-ribera. Tres viajes de ida y vuelta que, en una conexión móvil de 120 ms de latencia, son 360 ms solo de red antes de que el servidor haga nada; y el segundo no puede empezar hasta que termina el primero, porque necesita el identificador. La sobrecarga (over-fetching): la respuesta trae de sobra. El listado del catálogo devuelve por cada evento el título, la descripción larga, las políticas de devolución, las etiquetas, las imágenes en cuatro tamaños y los datos del organizador; la tarjeta de la pantalla de inicio usa tres campos. El resto son bytes que se serializan (CPU, lección 10-04), se transmiten y se descartan. Con GraphQL, el cliente escribe lo que quiere y recibe exactamente eso, en una sola petición y con la forma exacta de la consulta:

query PantallaDeEvento {
  evento(id: "evt-003") {
    titulo  fechaInicio
    sala { nombre ciudad }
    sesiones { id inicio aforoDisponible precioBaseCentimos }
  }
}

  1. REST frente a GraphQL: comparación honesta

Aspecto REST GraphQL
Forma de la respuesta y nº de peticiones La decide el servidor; una por recurso La decide el cliente; una por pantalla
Caché HTTP Nativa: Cache-Control, ETag, 304, proxies, CDN Casi nula: todo es POST /graphql
Complejidad del servidor Baja Alta: resolvers, DataLoader, límites de coste
Versionado Explícito (/v1, /v2) Evolutivo: se añaden campos y se marcan @deprecated
Códigos de estado Semánticos y ricos Siempre 200; los errores van en errors
Subida de archivos y curva de aprendizaje multipart/form-data; baja, es HTTP Especificación aparte; media-alta
Herramientas curl, Postman, cualquier proxy GraphiQL, Apollo Studio, tipado para el cliente
Monitorización y límites Por ruta y estado, gratis; paginación Por operación, a mano; profundidad y complejidad obligatorias
Encaja bien con APIs públicas, cachés, recursos claros Clientes variados (web, móvil, TV), grafos de datos

Dos casillas merecen énfasis. La caché HTTP es la pérdida más grave: en la lección 10-05 encadenamos tres capas (navegador, ETag/304, Redis) y las dos primeras desaparecen casi por completo, porque todas las consultas van por POST a la misma URL; se puede recuperar parte con consultas persistidas y GET, pero es trabajo extra. Y los códigos de estado: perder la semántica de 404, 409 o 429 tiene consecuencias en toda tu monitorización, como veremos en el apartado 9. El mensaje claro, y no es un tópico: GraphQL no sustituye a REST, coexiste con él. En Escena Viva mantenemos /api/v1/* para las integraciones de terceros, las descargas de PDF y los webhooks de la pasarela de pago, y añadimos /graphql para las pantallas de la aplicación, donde la flexibilidad compensa.

  1. El esquema como contrato

El esquema se escribe en SDL (Schema Definition Language) y es el contrato: define qué existe, qué tipo tiene y qué se puede pedir. Es tipado, obligatorio y verificable.

"Un evento cultural programado en una sala."
type Evento {
  id: ID!  titulo: String!  descripcion: String
  estado: EstadoEvento!  fechaInicio: FechaHora!  precioBaseCentimos: Int!
  sala: Sala!
  sesiones(soloDisponibles: Boolean = false): [Sesion!]!
}

type Sesion {
  id: ID!  evento: Evento!  inicio: FechaHora!
  aforoTotal: Int!  entradasVendidas: Int!  aforoDisponible: Int!  agotada: Boolean!
}
type Sala { id: ID!  nombre: String!  ciudad: String!  eventos: [Evento!]! }

La sintaxis de tipos es la parte que más confusión genera al principio:

Notación Significado
String / String! Cadena que puede ser null / que nunca lo es
[Sesion] Lista que puede ser null, con elementos que pueden ser null
[Sesion!]! Lista que nunca es null, con elementos que nunca son null

[Sesion!]! es lo que casi siempre quieres para una colección: si no hay sesiones, devuelve [], no null. Y una advertencia práctica sobre !: si un campo declarado String! resuelve a null, GraphQL propaga el error hacia arriba anulando el objeto entero, e incluso la consulta completa si la cadena de ! llega hasta la raíz. Marca ! solo donde la garantía sea real.

"Fecha y hora en ISO 8601 con zona horaria."
scalar FechaHora

enum EstadoEvento { BORRADOR PUBLICADO AGOTADO CANCELADO }
enum RolUsuario { ASISTENTE ORGANIZADOR ADMINISTRADOR }
enum EstadoPedido { PENDIENTE PAGADO ANULADO }

"Todo lo que puede aparecer en una busqueda global."
interface Resultado { id: ID!  titulo: String! }
type Usuario { id: ID!  nombre: String!  correo: String!  rol: RolUsuario!  pedidos: [Pedido!]! }
type Entrada { id: ID!  codigo: String!  butaca: String  precioCentimos: Int! }
type Pedido {
  id: ID!  usuario: Usuario!  sesion: Sesion!  estado: EstadoPedido!
  totalCentimos: Int!  creadoEn: FechaHora!  entradas: [Entrada!]!
}

Los escalares personalizados como FechaHora no son decoración: llevan funciones de serialización y validación, de modo que una fecha inválida se rechaza en el borde del esquema, igual que hacía zod en REST (M6). Y los tres tipos raíz son las tres puertas de entrada:

type Query {
  evento(id: ID!): Evento
  eventos(estado: EstadoEvento, limite: Int = 20, cursor: String): ConexionEventos!
  sesion(id: ID!): Sesion
  miUsuario: Usuario
  miPedido(id: ID!): Pedido
}
type Mutation {
  crearPedido(entrada: EntradaCrearPedido!): ResultadoPedido!
  anularPedido(pedidoId: ID!, motivo: String!): ResultadoPedido!
  publicarEvento(eventoId: ID!): Evento!
}

type Subscription { aforoActualizado(sesionId: ID!): Sesion! }
"Los argumentos complejos usan tipos de entrada, no tipos de objeto."
input EntradaCrearPedido { sesionId: ID!  cantidad: Int!  claveIdempotencia: String! }
type ResultadoPedido { pedido: Pedido  errorDeNegocio: ErrorDeNegocio }
type ErrorDeNegocio { codigo: String!  mensaje: String!  detalles: [String!]! }
type ConexionEventos { nodos: [Evento!]!  cursorFinal: String  hayMasPaginas: Boolean! }

Fíjate en ResultadoPedido: los errores de negocio previsibles (aforo insuficiente, sesión cancelada) se modelan como parte del esquema, no como excepciones; volveremos a ello en el apartado 9. Y en claveIdempotencia: la lección 10-05 no se queda fuera por cambiar de protocolo, solo cambia de sitio.

  1. Resolvers: cómo se resuelve un árbol

Un resolver es la función que produce el valor de un campo, con una firma de cuatro argumentos: padre (el valor devuelto por el resolver del nivel anterior), argumentos (los del campo), contexto (compartido por toda la petición: usuario, repositorios, cargadores) e info (metadatos de la consulta: qué campos se piden, ruta en el árbol). GraphQL resuelve en anchura, nivel a nivel: primero evento, luego todos sus campos, luego los de sala y sesiones, y así hacia abajo. Si no defines resolver para un campo, se aplica el resolver por defecto —buscar una propiedad con ese nombre en el padre—, y por eso titulo o fechaInicio no necesitan código.

'use strict';

const { GraphQLError } = require('graphql');

// Fabricas con dependencias inyectadas (misma disciplina del M9) que
// reutilizan los MISMOS repositorios del M7: la capa de datos no se duplica.
const exigirUsuario = (contexto) => {
  if (contexto.usuario) return;
  throw new GraphQLError('Autenticacion requerida',
    { extensions: { codigo: 'NO_AUTENTICADO', estado: 401 } });
};

const crearResolvers = ({ repositorios }) => ({
  Query: {
    evento: (padre, { id }, contexto) => contexto.cargadores.evento.load(id),
    eventos: (padre, { estado, limite, cursor }) => repositorios.eventos.obtenerCatalogo(
      { estado, cursor, limite: Math.min(limite, 50) }), // techo del servidor
    miUsuario: (padre, args, contexto) =>
      contexto.usuario ? repositorios.usuarios.obtenerPorId(contexto.usuario.id) : null,
    miPedido: async (padre, { id }, contexto) => {
      exigirUsuario(contexto);
      const pedido = await repositorios.pedidos.obtenerPorId(id);
      // Misma politica del M8, comprobada aqui y no en una ruta.
      return pedido && pedido.usuarioId === contexto.usuario.id ? pedido : null;
    },
  },
  // Resolvers de campo: 'padre' es el Evento ya resuelto.
  Evento: {
    sala: (evento, args, contexto) => contexto.cargadores.sala.load(evento.salaId),
    sesiones: (evento, { soloDisponibles }, contexto) =>
      contexto.cargadores.sesionesPorEvento.load(evento.id)
        .then((s) => (soloDisponibles ? s.filter((x) => x.aforoDisponible > 0) : s)),
  },
  Sesion: {
    // Campos calculados: no existen en la BD, se derivan del dominio.
    aforoDisponible: (sesion) => sesion.aforoTotal - sesion.entradasVendidas,
    agotada: (sesion) => sesion.entradasVendidas >= sesion.aforoTotal,
    evento: (sesion, args, contexto) => contexto.cargadores.evento.load(sesion.eventoId),
  },
  Mutation: {
    crearPedido: async (padre, { entrada }, contexto) => {
      exigirUsuario(contexto);
      try {
        // El MISMO repositorio transaccional del M7, con SELECT FOR UPDATE.
        const pedido = await repositorios.compras.comprarEntradas({
          usuarioId: contexto.usuario.id, sesionId: entrada.sesionId,
          cantidad: entrada.cantidad, claveIdempotencia: entrada.claveIdempotencia,
        });
        return { pedido, errorDeNegocio: null };
      } catch (error) {
        // Error esperado: forma parte del esquema, no es una excepcion.
        if (error.codigo !== 'AFORO_INSUFICIENTE') throw error;
        return { pedido: null, errorDeNegocio: { codigo: error.codigo,
          mensaje: error.message, detalles: error.detalles || [] } };
      }
    },
  },
});

module.exports = { crearResolvers };

Esta es la recompensa de haber aislado la capa de datos en el módulo 7: los repositorios son exactamente los mismos. La lógica de negocio, las transacciones con SELECT ... FOR UPDATE, las reglas de aforo, la autorización pura de src/autorizacion/politica.js: todo se reutiliza. GraphQL es una fachada distinta sobre el mismo núcleo, y si tu dominio estuviera enredado con Express este paso sería inviable.

  1. Montar el servidor sobre Express

Con npm install graphql @apollo/server @as-integrations/express5 dataloader graphql-depth-limit:

'use strict';

const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@as-integrations/express5');
const depthLimit = require('graphql-depth-limit');
const { tiposDefinidos } = require('./esquema.js');
const { crearResolvers } = require('./resolvers.js');
const { crearCargadores } = require('./cargadores.js');
const { crearContexto } = require('./contexto.js');
const { configuracion } = require('../config/index.js');

async function montarGraphql({ aplicacion, repositorios }) {
  const servidor = new ApolloServer({
    typeDefs: tiposDefinidos,
    resolvers: crearResolvers({ repositorios }),
    introspection: configuracion.entorno !== 'produccion', // ver apartado 8
    validationRules: [depthLimit(8)],
    formatError: (formateado, original) => { // nunca filtrar detalles (M8)
      if (formateado.extensions && formateado.extensions.codigo) return formateado;
      console.error('[graphql] error no controlado', original);
      return { message: 'Error interno', extensions: { codigo: 'ERROR_INTERNO', estado: 500 } };
    },
  });
  await servidor.start();

  // Convive con la API REST: /api/v1/* sigue exactamente igual.
  aplicacion.use('/graphql', expressMiddleware(servidor, {
    context: async ({ req }) => crearContexto({ peticion: req, repositorios, crearCargadores }),
  }));
  return servidor;
}

module.exports = { montarGraphql };

Se integra en crearAplicacion() (M6) como una ruta más, y los middlewares que ya teníamos —id-peticion.js, registro-http.js, cors.js, helmet, limites.js— siguen aplicándose porque están montados antes. El limitador es especialmente importante aquí, pero no basta: una sola consulta GraphQL puede ser tan cara como diez mil peticiones REST, así que limitar por número de peticiones es limitar la métrica equivocada. Lo arreglamos en el apartado 8. graphql-http es la alternativa minimalista si no quieres Apollo: implementa la especificación de transporte y poco más, mientras que Apollo aporta caché del plan de consulta, métricas, plugins y consultas persistidas.

  1. Contexto y autorización

El contexto se construye una vez por petición y es donde se inyecta todo lo que los resolvers necesitan:

'use strict';

const { verificarTokenDeAcceso } = require('../servicios/tokens.js');

async function crearContexto({ peticion, repositorios, crearCargadores }) {
  let usuario = null;
  const cabecera = peticion.get('authorization');
  if (cabecera && cabecera.startsWith('Bearer ')) {
    // Mismo servicio de tokens del M8 (JWT HS256, 15 min). Token
    // invalido: se trata como anonimo.
    try { usuario = await verificarTokenDeAcceso(cabecera.slice(7)); } catch { usuario = null; }
  }
  // Cargadores NUEVOS en cada peticion: su cache no debe sobrevivir ni
  // cruzarse entre usuarios. Es correccion y seguridad, no optimizacion.
  return {
    usuario, repositorios, idPeticion: peticion.idPeticion,
    cargadores: crearCargadores({ repositorios }),
  };
}

module.exports = { crearContexto };

Y aquí llega lo delicado. En REST la autorización se monta en la ruta (router.post('/eventos', autenticar, exigirRol('organizador'), ...)), y si olvidas el middleware se nota, porque la ruta es una unidad visible. En GraphQL no hay rutas: hay un único endpoint y un grafo por el que el cliente navega libremente, así que un campo desprotegido en cualquier rincón del esquema es accesible desde cualquier consulta que llegue hasta él. Una consulta aparentemente inocente que pide eventos { sala { eventos { ... } } } puede acabar alcanzando Pedido.usuario y, si ese resolver no comprueba nada, leer el correo de Lucia. La autorización se comprueba en cada resolver que expone datos sensibles, no en la entrada.

'use strict';

const { GraphQLError } = require('graphql');
const { puede } = require('../autorizacion/permisos.js');

// Envoltorio que aplica una politica antes de resolver, reutilizando las
// funciones puras del M8: la politica es una sola para REST y GraphQL.
const conPermiso = (accion, resolver) => (padre, argumentos, contexto, info) => {
  if (!contexto.usuario || !puede(contexto.usuario, accion, { padre, argumentos })) {
    throw new GraphQLError('No tienes permiso para esta operacion',
      { extensions: { codigo: 'PROHIBIDO', estado: 403 } });
  }
  return resolver(padre, argumentos, contexto, info);
};

// Los campos sensibles se declaran protegidos de forma explicita.
const resolversUsuario = { Usuario: {
  correo: conPermiso('leer-correo-usuario', (usuario) => usuario.correo),
  pedidos: conPermiso('leer-pedidos-usuario', (usuario, args, contexto) =>
    contexto.repositorios.pedidos.listarPorUsuario(usuario.id)),
} };

module.exports = { conPermiso, resolversUsuario };

Regla defensiva: por defecto, denegar. Existen bibliotecas de directivas (@auth(requiere: ORGANIZADOR)) que permiten declararlo en el propio SDL, lo cual es más difícil de olvidar que un envoltorio en el resolver.

  1. El problema N+1 y DataLoader

El problema N+1 del módulo 7 aparece en REST, pero en GraphQL es estructuralmente peor, porque el cliente elige la forma de la consulta y puede provocarlo sin saberlo. Una consulta que pide eventos { nodos { titulo sala { nombre } sesiones { id } } } con 3 eventos son 7 consultas: 1 del catálogo, 3 de salas y 3 de sesiones. Con 100 eventos serían 201, y el cliente no ha hecho nada raro: ha pedido los datos que necesita. En REST podías optimizar el endpoint concreto; aquí no sabes de antemano qué combinación pedirán. DataLoader lo resuelve con dos mecanismos: lotes, acumulando todas las llamadas a .load(id) que ocurren en el mismo tick del bucle de eventos para hacer una sola llamada con todos los identificadores; y caché por petición, de modo que el mismo id no se consulta dos veces.

'use strict';

const DataLoader = require('dataloader');

// CRITICO: devolver un array del MISMO tamano y en el MISMO orden que los
// ids recibidos; si falta uno, se devuelve null en su sitio.
const porClave = (registros, ids) => {
  const indice = new Map(registros.map((r) => [r.id, r]));
  return ids.map((id) => indice.get(id) || null);
};

// Un juego de cargadores NUEVO por peticion (ver crearContexto). Cada
// uno hace una sola consulta con WHERE id IN (...).
const crearCargadores = ({ repositorios }) => ({
  evento: new DataLoader(async (ids) =>
    porClave(await repositorios.eventos.obtenerPorIds(ids), ids)),
  sala: new DataLoader(async (ids) =>
    porClave(await repositorios.salas.obtenerPorIds(ids), ids)),
  // Cargador de uno-a-muchos: devuelve un array por cada clave.
  sesionesPorEvento: new DataLoader(async (eventoIds) => {
    const sesiones = await repositorios.sesiones.listarPorEventos(eventoIds);
    const porEvento = new Map(eventoIds.map((id) => [id, []]));
    for (const sesion of sesiones) porEvento.get(sesion.eventoId).push(sesion);
    return eventoIds.map((id) => porEvento.get(id)); // array vacio si no hay
  }),
});

module.exports = { crearCargadores };

Medición sobre el catálogo de Escena Viva con la consulta anterior:

Métrica Sin DataLoader Con DataLoader
Consultas a PostgreSQL (3 eventos) 7 3
Latencia p50 / p99 (3 eventos) 84 ms / 240 ms 21 ms / 46 ms
Consultas / p99 con 100 eventos 201 / 3 100 ms 3 / 78 ms

La cifra de 201 a 3 con 100 eventos es la que hay que retener: DataLoader convierte un problema que crece linealmente con los datos en uno constante. En GraphQL no es una optimización opcional, es un requisito de arquitectura. Dos advertencias: los cargadores deben crearse por petición, porque si los creas al arrancar su caché sobrevive entre peticiones y usuarios distintos, sirviendo datos obsoletos y, peor, datos de otro usuario; y el orden y el tamaño del array devuelto deben coincidir exactamente con las claves recibidas, que es el error de implementación más frecuente y produce datos cruzados entre entidades de forma silenciosa.

  1. Límites obligatorios: la API sin límites es un DoS

Una API GraphQL sin límites es un ataque de denegación de servicio esperando a ocurrir, y no hace falta ser sofisticado: basta con anidar eventos { nodos { sala { eventos { nodos { sala { ... } } } } } } una decena de niveles. Cada nivel multiplica el trabajo, así que con relaciones circulares (Evento → Sala → Evento) una consulta de 200 bytes puede generar millones de resoluciones y agotar la memoria del proceso. Es un fallo de la categoría API4 (Consumo de recursos sin restricción) del OWASP API Security Top 10 que vimos en el módulo 8.

Defensa Cómo Valor razonable
Profundidad máxima graphql-depth-limit 7-10 niveles
Complejidad y paginación graphql-query-complexity; limite con techo 1 000 puntos; máximo 50-100
Tiempo límite y tamaño del cuerpo AbortSignal; express.json({ limit }) 5-10 s; 16 KB en /graphql
Consultas permitidas e introspección Lista blanca de persistidas; introspection: false Clientes propios; en producción
Limitador de peticiones limites.js con Redis (10-03) Por usuario, no solo por IP
'use strict';

const { GraphQLError } = require('graphql');
const { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } =
  require('graphql-query-complexity');

// El coste se calcula ANTES de ejecutar nada: si excede, se rechaza.
const reglaDeComplejidad = (maximo = 1000) => (contexto) => createComplexityRule({
  maximumComplexity: maximo,
  variables: contexto.request.variables,
  // fieldExtensionsEstimator lee el coste declarado en cada campo del
  // esquema; simpleEstimator asigna 1 punto a los que no lo declaran.
  estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
  createError: (permitido, actual) => new GraphQLError(
    `La consulta es demasiado compleja: ${actual} puntos (maximo ${permitido}).`,
    { extensions: { codigo: 'CONSULTA_DEMASIADO_COMPLEJA', estado: 400 } }),
});

module.exports = { reglaDeComplejidad };

Sobre la introspección: es la capacidad de preguntarle al servidor por su propio esquema, y es lo que hace funcionar a GraphiQL y a las herramientas de generación de tipos. En producción entrega a cualquiera el mapa completo de tu API, incluidos los campos que aún no has anunciado y las mutaciones de administración: desactívala y publica el esquema por los canales que tú controles. No es seguridad por oscuridad —los límites y la autorización siguen siendo tu defensa real— pero no hay razón para regalar el mapa. Y las consultas persistidas son la defensa definitiva cuando el único cliente es tuyo: el cliente envía un hash en lugar de la consulta y el servidor solo ejecuta las de su lista aprobada, lo que elimina de un plumazo las bombas de profundidad y complejidad.

  1. Errores en GraphQL

En GraphQL todo responde 200 OK (salvo errores de transporte o de sintaxis, que pueden dar 400). Los errores viajan en el cuerpo, y hay algo que en REST no existe: las respuestas parciales.

{
  "data": { "evento": { "titulo": "Festival de Jazz de Primavera", "sala": null } },
  "errors": [{ "message": "No tienes permiso para esta operacion",
    "path": ["evento", "sala"],
    "extensions": { "codigo": "PROHIBIDO", "estado": 403, "idPeticion": "f1c2..." } }]
}

data.evento.titulo es válido y data.evento.sala es null con su error asociado. Es potente y obliga al cliente a comprobar errors siempre, incluso cuando hay datos. Las consecuencias son concretas:

Consecuencia Qué implica
El cliente no puede confiar en el código HTTP Hay que inspeccionar errors en cada respuesta
Los reintentos automáticos no se activan Un 200 con error no dispara ninguna política de reintento
La monitorización miente Tu tasa de 5xx será del 0 % con el sistema ardiendo
Proxies y alertas por código de estado no sirven Pueden cachear un error; hay que instrumentar por extensions.codigo

La lección 10-04 puso la tasa de errores entre las cuatro señales de oro; con GraphQL hay que producir esa señal a mano, con un plugin de Apollo que en willSendResponse registre el nombre de la operación, su duración y los extensions.codigo de cada error. Sin eso, tu panel del módulo 11 mostrará 0 % de errores para siempre. Y por eso el esquema distinguía dos clases de error. Los de negocio previsibles (aforo insuficiente) viajan en ResultadoPedido.errorDeNegocio: son parte del contrato, están tipados y el cliente los maneja con el compilador de su lado. Los inesperados (base de datos caída) van en errors. Esta separación —a veces llamada "errores como datos"— es una de las mejores prácticas del ecosistema, y evita que el cliente tenga que adivinar leyendo cadenas de texto.

  1. Suscripciones, y cuándo elegir cada cosa

Subscription permite que el servidor empuje datos al cliente sobre una conexión persistente (WebSocket, normalmente con graphql-ws), y en Escena Viva el caso natural es el aforo durante el estreno del Festival de Jazz: con subscription { aforoActualizado(sesionId: "ses-003-1") { aforoDisponible agotada } }, el contador baja en vivo mientras la gente compra, alimentado por los eventos venta-registrada y aforo-bajo que el GestorDeVentas ya emite desde el módulo 2, publicados a través de Redis (10-03) para que lleguen a los siete trabajadores.

Lo dejamos anunciado: la comunicación en tiempo real, con WebSockets, salas y difusión de mensajes, se trabaja de verdad en el módulo 12 con Socket.IO y el proyecto de chat, donde los problemas —conexiones con estado, escalado entre procesos, reconexión— se resuelven en profundidad. El criterio final, que es lo que debe quedarte:

Situación Elige
API pública para terceros, contenido cacheable, archivos y webhooks REST: caché HTTP con ETag y CDN, códigos de estado, herramientas universales
Aplicación propia con muchas pantallas, clientes heterogéneos o datos en grafo GraphQL
Equipo pequeño, plazos cortos, dominio sencillo REST: menos piezas que puedan fallar
Producto maduro con clientes externos y aplicación propia Los dos, sobre el mismo dominio

Escena Viva se queda en la última fila, y puede permitírselo por la arquitectura construida a lo largo del curso: dominio puro, repositorios aislados, políticas de autorización como funciones puras. Sobre ese núcleo, REST y GraphQL son dos fachadas; si el negocio estuviera dentro de los controladores de Express, mantener las dos sería duplicar la lógica y garantizar que un día divergen.

Errores Comunes y Consejos

  • Creer que GraphQL sustituye a REST. Son herramientas con compromisos distintos; la mayoría de sistemas maduros usan ambas. Y olvidar DataLoader: sin él, cualquier consulta anidada es un N+1.
  • Cargadores compartidos entre peticiones, o que devuelven un array de tamaño u orden distinto: lo primero cruza datos entre usuarios, lo segundo cruza datos entre entidades. Ambos, en silencio.
  • Autorizar solo en la raíz. El grafo se navega desde cualquier sitio: se comprueba en cada resolver sensible.
  • Publicar sin límites de profundidad y complejidad, o dejar la introspección activa en producción: lo primero es un DoS de 200 bytes, lo segundo regala el mapa de tu API. Y monitorizar por código HTTP no sirve: con GraphQL siempre es 200, así que instrumenta por extensions.codigo.
  • Consejo: empieza con un esquema pequeño sobre tus repositorios existentes, y modela el dominio, no tus tablas: un esquema que refleja la base de datos una a una suele ser mal diseño.
  • Consejo: marca los campos que retiras con @deprecated(reason: "...") y mide su uso antes de eliminarlos; es el equivalente de la cabecera Sunset de la 10-05.

Ejercicios

Ejercicio 1: esquema y resolvers del catálogo

Define el esquema de Evento, Sesion y Sala con sus campos calculados (aforoDisponible, agotada) y monta /graphql sobre crearAplicacion() reutilizando los repositorios del M7, sin duplicar lógica. Comprueba que la API REST sigue funcionando y escribe una prueba de integración con supertest que compare el resultado de la consulta GraphQL con el de GET /api/v1/eventos/evt-003.

Ejercicio 2: medir y eliminar el N+1

Instrumenta el repositorio para contar consultas SQL por petición. Ejecuta la consulta que pide 3 eventos con su sala y sus sesiones, y anota el número de consultas sin DataLoader. Implementa los tres cargadores y vuelve a medir. Repite con 100 eventos sembrados.

Ejercicio 3: bomba de consulta y defensa

Escribe una consulta con 12 niveles de anidamiento aprovechando la relación circular Evento → Sala → Evento. Mide el tiempo de respuesta y el retraso del bucle de eventos (10-04) sin límites. Aplica depthLimit(8) y una regla de complejidad de 1 000 puntos, y verifica que la consulta se rechaza antes de ejecutarse.

Soluciones

Ejercicio 1. La clave está en no escribir lógica nueva: Sesion.aforoDisponible es sesion.aforoTotal - sesion.entradasVendidas, la misma fórmula que ya vive en src/dominio/sesion.js, y lo correcto es llamar a esa función, no reescribirla. La prueba compara campo a campo el resultado de peticion.get('/api/v1/eventos/evt-003') con el de peticion.post('/graphql').send({ query: '{ evento(id: "evt-003") { titulo sala { nombre } } }' }), comprobando además que body.errors es undefined. Que ambas coincidan demuestra lo que buscábamos: dos fachadas sobre un dominio. Si divergen, hay lógica duplicada en algún sitio y eso es deuda técnica desde el primer día.

Ejercicio 2. Con 3 eventos y sin cargadores, el contador da 7 consultas: 1 del catálogo, 3 de salas, 3 de sesiones. Con los cargadores baja a 3: catálogo, salas WHERE id IN (...) y sesiones WHERE eventoId IN (...). Con 100 eventos sembrados la diferencia se vuelve dramática —201 frente a 3, y el p99 pasa de ~3,1 s a ~78 ms—, pero lo importante no es el factor de mejora sino la forma de la curva: sin DataLoader el coste crece con el número de elementos y con él es constante. Detalle que sorprende a mucha gente: los cargadores pueden agrupar porque GraphQL resuelve en anchura, así que todas las llamadas a .load() de un mismo nivel ocurren en el mismo tick del bucle de eventos —exactamente el mecanismo de microtareas del módulo 2— y DataLoader las recoge todas antes de consultar.

Ejercicio 3. Sin límites, la consulta de 12 niveles con relación circular tarda entre 8 y 40 segundos según los datos sembrados, y el retraso del bucle de eventos supera los 3 000 ms: el proceso queda inservible para todos los demás usuarios, exactamente el síntoma de la lección 10-02 pero provocado desde fuera con una cadena de texto de 200 bytes. Con depthLimit(8) la consulta se rechaza durante la fase de validación, antes de ejecutar un solo resolver, con Query exceeds maximum operation depth of 8 y en menos de 2 ms. La regla de complejidad captura además el caso que la profundidad no ve: una consulta plana pero con limite: 10000 en varias listas. Ambas son necesarias, y ninguna sustituye a la autorización: los límites protegen la disponibilidad, no la confidencialidad.

Conclusión

Escena Viva llega al final del módulo 10 siendo un sistema distinto del que empezó. Usa todos los núcleos de la máquina con cluster, con supervisión, recarga sin cortes y la lección aprendida de que el estado en memoria no sobrevive a varios procesos. No bloquea el bucle de eventos: la generación de los QR y los PDF vive en un pool de hilos de trabajo, y el retraso del bucle bajó de 4 176 ms a 11 ms. Cachea el catálogo en Redis con el patrón cache-aside, claves versionadas e invalidación al vender, pasando de 31 050 consultas a PostgreSQL a 7. Encola el trabajo pesado con BullMQ, responde 202 Accepted y protege la idempotencia en el productor y en el consumidor. Está medida: con pruebas de carga honestas, perfiles de CPU, flamegraphs, instantáneas del montículo y el retraso del bucle como métrica de salud, y con un orden claro de palancas —algoritmo, consulta, caché, concurrencia, hardware—. Tiene una API bien diseñada, con recursos coherentes, métodos con sus garantías, claves de idempotencia, códigos de estado con criterio, paginación por cursor, versionado con política de deprecación, caché HTTP con ETag y If-Match, y documentación OpenAPI generada desde los esquemas. Y ahora tiene también una alternativa GraphQL que convive con REST sobre el mismo dominio, con DataLoader contra el N+1 y límites de profundidad y complejidad para no ser una denegación de servicio esperando a ocurrir.

Es un sistema que aguanta el estreno del Festival de Jazz de Primavera. Y sin embargo, todo esto sigue corriendo en tu portátil.

Los secretos están en un .env local que no puedes compartir con nadie sin enviarlo por un canal inseguro. Los registros se pierden cuando cierras la terminal, y con siete trabajadores escribiendo a la vez, ni siquiera se leen bien. No hay nadie que reinicie el proceso si muere a las cuatro de la mañana: el src/cluster.js que escribimos supervisa a sus trabajadores, pero nadie supervisa al primario. No hay un contenedor que garantice que la versión de Node, de PostgreSQL y de Redis sea la misma en tu máquina y en el servidor. Y no hay despliegue: subir una versión nueva es una secuencia de órdenes manuales que solo tú conoces y que un día harás mal a las dos de la madrugada.

En el Módulo 11, Despliegue y DevOps, cerramos esa distancia: configuración y variables de entorno gestionadas como es debido, registro y monitorización en producción para que las métricas que hemos aprendido a producir lleguen a algún sitio donde alguien las mire, PM2 para supervisar y ejecutar en modo cluster lo que aquí implementamos a mano, empaquetado con Docker para que el entorno viaje con la aplicación, despliegue en Heroku y otras PaaS, y una tubería de integración y despliegue continuos que convierta subir una versión en algo aburrido. Que es, al final, el mayor elogio que se le puede hacer a un despliegue.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados