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
- El problema: sobrecarga e infracarga de datos
- REST frente a GraphQL: comparación honesta
- El esquema como contrato
- Resolvers: cómo se resuelve un árbol
- Montar el servidor sobre Express
- Contexto y autorización
- El problema N+1 y DataLoader
- Límites obligatorios: la API sin límites es un DoS
- Errores en GraphQL
- Suscripciones, y cuándo elegir cada cosa
- 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 }
}
}
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 cabeceraSunsetde 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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
