Tanquem el mòdul amb la pregunta que va deixar oberta la lliçó anterior. La pantalla d'un esdeveniment a l'aplicació d'Escena Viva necessita l'esdeveniment, les seves sessions i les dades de la sala. Amb l'API REST, això són tres crides encadenades, o una resposta amb ?incloure=sessions,sala que retorna molt més del que la pantalla fa servir. Cap de les dues opcions no és dolenta; simplement, REST administra aquest dilema en comptes d'eliminar-lo. GraphQL l'elimina, i en cobra un preu. Aquesta lliçó explica què resol, quin preu té, com es munta sobre l'aplicació Express que ja existeix, i —el més important— quan no l'hauries de fer servir.

Contingut

  1. El problema: sobrecàrrega i infracàrrega de dades
  2. REST davant de GraphQL: comparació honesta
  3. L'esquema com a contracte
  4. Resolutors: com es resol un arbre
  5. Muntar el servidor sobre Express
  6. Context i autorització
  7. El problema N+1 i DataLoader
  8. Límits obligatoris: l'API sense límits és un DoS
  9. Errors a GraphQL
  10. Subscripcions, i quan triar cada cosa

  1. El problema: sobrecàrrega i infracàrrega de dades

Dos símptomes amb nom propi. La infracàrrega (under-fetching): la resposta no porta tot el que cal i s'han d'encadenar GET /api/v1/esdeveniments/evt-003, després /sessions, després /sales/org-ribera. Tres viatges d'anada i tornada que, en una connexió mòbil de 120 ms de latència, són 360 ms només de xarxa abans que el servidor faci res; i el segon no pot començar fins que acaba el primer, perquè necessita l'identificador. La sobrecàrrega (over-fetching): la resposta porta de sobres. El llistat del catàleg retorna per cada esdeveniment el títol, la descripció llarga, les polítiques de devolució, les etiquetes, les imatges en quatre mides i les dades de l'organitzador; la targeta de la pantalla d'inici fa servir tres camps. La resta són bytes que se serialitzen (CPU, lliçó 10-04), es transmeten i es descarten. Amb GraphQL, el client escriu el que vol i rep exactament això, en una sola petició i amb la forma exacta de la consulta:

query PantallaDEsdeveniment {
  esdeveniment(id: "evt-003") {
    titol  dataInici
    sala { nom ciutat }
    sessions { id inici aforamentDisponible preuBaseCentims }
  }
}

  1. REST davant de GraphQL: comparació honesta

Aspecte REST GraphQL
Forma de la resposta i nre. de peticions La decideix el servidor; una per recurs La decideix el client; una per pantalla
Memòria cau HTTP Nativa: Cache-Control, ETag, 304, proxies, CDN Gairebé nul·la: tot és POST /graphql
Complexitat del servidor Baixa Alta: resolutors, DataLoader, límits de cost
Versionat Explícit (/v1, /v2) Evolutiu: s'afegeixen camps i es marquen @deprecated
Codis d'estat Semàntics i rics Sempre 200; els errors van a errors
Pujada de fitxers i corba d'aprenentatge multipart/form-data; baixa, és HTTP Especificació a part; mitjana-alta
Eines curl, Postman, qualsevol proxy GraphiQL, Apollo Studio, tipatge per al client
Monitoratge i límits Per ruta i estat, de franc; paginació Per operació, a mà; profunditat i complexitat obligatòries
Encaixa bé amb APIs públiques, memòries cau, recursos clars Clients variats (web, mòbil, TV), grafs de dades

Dues caselles mereixen èmfasi. La memòria cau HTTP és la pèrdua més greu: a la lliçó 10-05 vam encadenar tres capes (navegador, ETag/304, Redis) i les dues primeres desapareixen gairebé del tot, perquè totes les consultes van per POST a la mateixa URL; se'n pot recuperar part amb consultes persistides i GET, però és feina extra. I els codis d'estat: perdre la semàntica de 404, 409 o 429 té conseqüències a tot el teu monitoratge, com veurem a l'apartat 9. El missatge clar, i no és un tòpic: GraphQL no substitueix REST, hi coexisteix. A Escena Viva mantenim /api/v1/* per a les integracions de tercers, les descàrregues de PDF i els webhooks de la passarel·la de pagament, i afegim /graphql per a les pantalles de l'aplicació, on la flexibilitat compensa.

  1. L'esquema com a contracte

L'esquema s'escriu en SDL (Schema Definition Language) i és el contracte: defineix què existeix, quin tipus té i què es pot demanar. És tipat, obligatori i verificable.

"Un esdeveniment cultural programat en una sala."
type Esdeveniment {
  id: ID!  titol: String!  descripcio: String
  estat: EstatEsdeveniment!  dataInici: DataHora!  preuBaseCentims: Int!
  sala: Sala!
  sessions(nomesDisponibles: Boolean = false): [Sessio!]!
}

type Sessio {
  id: ID!  esdeveniment: Esdeveniment!  inici: DataHora!
  aforamentTotal: Int!  entradesVenudes: Int!  aforamentDisponible: Int!  exhaurida: Boolean!
}
type Sala { id: ID!  nom: String!  ciutat: String!  esdeveniments: [Esdeveniment!]! }

La sintaxi de tipus és la part que més confusió genera al principi:

Notació Significat
String / String! Cadena que pot ser null / que mai no ho és
[Sessio] Llista que pot ser null, amb elements que poden ser null
[Sessio!]! Llista que mai no és null, amb elements que mai no són null

[Sessio!]! és el que gairebé sempre vols per a una col·lecció: si no hi ha sessions, retorna [], no null. I un advertiment pràctic sobre !: si un camp declarat String! resol a null, GraphQL propaga l'error cap amunt anul·lant l'objecte sencer, i fins i tot la consulta completa si la cadena de ! arriba fins a l'arrel. Marca ! només on la garantia sigui real.

"Data i hora en ISO 8601 amb zona horaria."
scalar DataHora

enum EstatEsdeveniment { ESBORRANY PUBLICAT EXHAURIT CANCELLAT }
enum RolUsuari { ASSISTENT ORGANITZADOR ADMINISTRADOR }
enum EstatComanda { PENDENT PAGAT ANULLAT }

"Tot el que pot apareixer en una cerca global."
interface Resultat { id: ID!  titol: String! }
type Usuari { id: ID!  nom: String!  correu: String!  rol: RolUsuari!  comandes: [Comanda!]! }
type Entrada { id: ID!  codi: String!  butaca: String  preuCentims: Int! }
type Comanda {
  id: ID!  usuari: Usuari!  sessio: Sessio!  estat: EstatComanda!
  totalCentims: Int!  creatEl: DataHora!  entrades: [Entrada!]!
}

Els escalars personalitzats com DataHora no són decoració: porten funcions de serialització i validació, de manera que una data invàlida es rebutja a la vora de l'esquema, igual que feia zod a REST (M6). I els tres tipus arrel són les tres portes d'entrada:

type Query {
  esdeveniment(id: ID!): Esdeveniment
  esdeveniments(estat: EstatEsdeveniment, limit: Int = 20, cursor: String): ConnexioEsdeveniments!
  sessio(id: ID!): Sessio
  elMeuUsuari: Usuari
  laMevaComanda(id: ID!): Comanda
}
type Mutation {
  crearComanda(entrada: EntradaCrearComanda!): ResultatComanda!
  anullarComanda(comandaId: ID!, motiu: String!): ResultatComanda!
  publicarEsdeveniment(esdevenimentId: ID!): Esdeveniment!
}

type Subscription { aforamentActualitzat(sessioId: ID!): Sessio! }
"Els arguments complexos fan servir tipus d'entrada, no tipus d'objecte."
input EntradaCrearComanda { sessioId: ID!  quantitat: Int!  clauIdempotencia: String! }
type ResultatComanda { comanda: Comanda  errorDeNegoci: ErrorDeNegoci }
type ErrorDeNegoci { codi: String!  missatge: String!  detalls: [String!]! }
type ConnexioEsdeveniments { nodes: [Esdeveniment!]!  cursorFinal: String  hiHaMesPagines: Boolean! }

Fixa't en ResultatComanda: els errors de negoci previsibles (aforament insuficient, sessió cancel·lada) es modelen com a part de l'esquema, no com a excepcions; hi tornarem a l'apartat 9. I a clauIdempotencia: la lliçó 10-05 no es queda fora per canviar de protocol, només canvia de lloc.

  1. Resolutors: com es resol un arbre

Un resolutor és la funció que produeix el valor d'un camp, amb una signatura de quatre arguments: pare (el valor retornat pel resolutor del nivell anterior), parametres (els del camp), context (compartit per tota la petició: usuari, repositoris, carregadors) i info (metadades de la consulta: quins camps es demanen, ruta a l'arbre). GraphQL resol en amplada, nivell a nivell: primer esdeveniment, després tots els seus camps, després els de sala i sessions, i així cap avall. Si no defineixes un resolutor per a un camp, s'aplica el resolutor per defecte —cercar una propietat amb aquell nom al pare—, i per això titol o dataInici no necessiten codi.

'use strict';

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

// Factories amb dependencies injectades (mateixa disciplina del M9) que
// reutilitzen els MATEIXOS repositoris del M7: la capa de dades no es duplica.
const exigirUsuari = (context) => {
  if (context.usuari) return;
  throw new GraphQLError('Autenticacio requerida',
    { extensions: { codi: 'NO_AUTENTICAT', estat: 401 } });
};

const crearResolutors = ({ repositoris }) => ({
  Query: {
    esdeveniment: (pare, { id }, context) => context.carregadors.esdeveniment.load(id),
    esdeveniments: (pare, { estat, limit, cursor }) => repositoris.esdeveniments.obtenirCataleg(
      { estat, cursor, limit: Math.min(limit, 50) }), // sostre del servidor
    elMeuUsuari: (pare, parametres, context) =>
      context.usuari ? repositoris.usuaris.obtenirPerId(context.usuari.id) : null,
    laMevaComanda: async (pare, { id }, context) => {
      exigirUsuari(context);
      const comanda = await repositoris.comandes.obtenirPerId(id);
      // Mateixa politica del M8, comprovada aqui i no en una ruta.
      return comanda && comanda.usuariId === context.usuari.id ? comanda : null;
    },
  },
  // Resolutors de camp: 'pare' es l'Esdeveniment ja resolt.
  Esdeveniment: {
    sala: (esdeveniment, parametres, context) => context.carregadors.sala.load(esdeveniment.salaId),
    sessions: (esdeveniment, { nomesDisponibles }, context) =>
      context.carregadors.sessionsPerEsdeveniment.load(esdeveniment.id)
        .then((s) => (nomesDisponibles ? s.filter((x) => x.aforamentDisponible > 0) : s)),
  },
  Sessio: {
    // Camps calculats: no existeixen a la BD, es deriven del domini.
    aforamentDisponible: (sessio) => sessio.aforamentTotal - sessio.entradesVenudes,
    exhaurida: (sessio) => sessio.entradesVenudes >= sessio.aforamentTotal,
    esdeveniment: (sessio, parametres, context) => context.carregadors.esdeveniment.load(sessio.esdevenimentId),
  },
  Mutation: {
    crearComanda: async (pare, { entrada }, context) => {
      exigirUsuari(context);
      try {
        // El MATEIX repositori transaccional del M7, amb SELECT FOR UPDATE.
        const comanda = await repositoris.compres.comprarEntrades({
          usuariId: context.usuari.id, sessioId: entrada.sessioId,
          quantitat: entrada.quantitat, clauIdempotencia: entrada.clauIdempotencia,
        });
        return { comanda, errorDeNegoci: null };
      } catch (error) {
        // Error esperat: forma part de l'esquema, no es una excepcio.
        if (error.codi !== 'AFORAMENT_INSUFICIENT') throw error;
        return { comanda: null, errorDeNegoci: { codi: error.codi,
          missatge: error.message, detalls: error.detalls || [] } };
      }
    },
  },
});

module.exports = { crearResolutors };

Aquesta és la recompensa d'haver aïllat la capa de dades al mòdul 7: els repositoris són exactament els mateixos. La lògica de negoci, les transaccions amb SELECT ... FOR UPDATE, les regles d'aforament, l'autorització pura de src/autoritzacio/politica.js: tot es reutilitza. GraphQL és una façana diferent sobre el mateix nucli, i si el teu domini estigués enredat amb Express aquest pas seria inviable.

  1. Muntar el servidor sobre Express

Amb 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 { tipusDefinits } = require('./esquema.js');
const { crearResolutors } = require('./resolutors.js');
const { crearCarregadors } = require('./carregadors.js');
const { crearContext } = require('./context.js');
const { configuracio } = require('../config/index.js');

async function muntarGraphql({ aplicacio, repositoris }) {
  const servidor = new ApolloServer({
    typeDefs: tipusDefinits,
    resolvers: crearResolutors({ repositoris }),
    introspection: configuracio.entorn !== 'produccio', // veure apartat 8
    validationRules: [depthLimit(8)],
    formatError: (formatat, original) => { // mai filtrar detalls (M8)
      if (formatat.extensions && formatat.extensions.codi) return formatat;
      console.error('[graphql] error no controlat', original);
      return { message: 'Error intern', extensions: { codi: 'ERROR_INTERN', estat: 500 } };
    },
  });
  await servidor.start();

  // Conviu amb l'API REST: /api/v1/* continua exactament igual.
  aplicacio.use('/graphql', expressMiddleware(servidor, {
    context: async ({ req }) => crearContext({ peticio: req, repositoris, crearCarregadors }),
  }));
  return servidor;
}

module.exports = { muntarGraphql };

S'integra a crearAplicacio() (M6) com una ruta més, i els middlewares que ja teníem —id-peticio.js, registre-http.js, cors.js, helmet, limits.js— continuen aplicant-se perquè estan muntats abans. El limitador és especialment important aquí, però no n'hi ha prou: una sola consulta GraphQL pot ser tan cara com deu mil peticions REST, així que limitar per nombre de peticions és limitar la mètrica equivocada. Ho arreglem a l'apartat 8. graphql-http és l'alternativa minimalista si no vols Apollo: implementa l'especificació de transport i poca cosa més, mentre que Apollo aporta memòria cau del pla de consulta, mètriques, connectors i consultes persistides.

  1. Context i autorització

El context es construeix una vegada per petició i és on s'injecta tot el que els resolutors necessiten:

'use strict';

const { verificarTokenDAcces } = require('../serveis/tokens.js');

async function crearContext({ peticio, repositoris, crearCarregadors }) {
  let usuari = null;
  const capcalera = peticio.get('authorization');
  if (capcalera && capcalera.startsWith('Bearer ')) {
    // Mateix servei de tokens del M8 (JWT HS256, 15 min). Token
    // invalid: es tracta com a anonim.
    try { usuari = await verificarTokenDAcces(capcalera.slice(7)); } catch { usuari = null; }
  }
  // Carregadors NOUS a cada peticio: la seva cache no ha de sobreviure ni
  // creuar-se entre usuaris. Es correccio i seguretat, no optimitzacio.
  return {
    usuari, repositoris, idPeticio: peticio.idPeticio,
    carregadors: crearCarregadors({ repositoris }),
  };
}

module.exports = { crearContext };

I aquí arriba el que és delicat. A REST l'autorització es munta a la ruta (router.post('/esdeveniments', autenticar, exigirRol('organitzador'), ...)), i si oblides el middleware es nota, perquè la ruta és una unitat visible. A GraphQL no hi ha rutes: hi ha un únic punt d'entrada i un graf pel qual el client navega lliurement, així que un camp desprotegit en qualsevol racó de l'esquema és accessible des de qualsevol consulta que hi arribi. Una consulta aparentment innocent que demana esdeveniments { sala { esdeveniments { ... } } } pot acabar arribant a Comanda.usuari i, si aquell resolutor no comprova res, llegir el correu de Lucía. L'autorització es comprova a cada resolutor que exposa dades sensibles, no a l'entrada.

'use strict';

const { GraphQLError } = require('graphql');
const { pot } = require('../autoritzacio/permisos.js');

// Embolcall que aplica una politica abans de resoldre, reutilitzant les
// funcions pures del M8: la politica es una de sola per a REST i GraphQL.
const ambPermis = (accio, resolutor) => (pare, parametres, context, info) => {
  if (!context.usuari || !pot(context.usuari, accio, { pare, parametres })) {
    throw new GraphQLError('No tens permis per a aquesta operacio',
      { extensions: { codi: 'PROHIBIT', estat: 403 } });
  }
  return resolutor(pare, parametres, context, info);
};

// Els camps sensibles es declaren protegits de manera explicita.
const resolutorsUsuari = { Usuari: {
  correu: ambPermis('llegir-correu-usuari', (usuari) => usuari.correu),
  comandes: ambPermis('llegir-comandes-usuari', (usuari, parametres, context) =>
    context.repositoris.comandes.llistarPerUsuari(usuari.id)),
} };

module.exports = { ambPermis, resolutorsUsuari };

Regla defensiva: per defecte, denegar. Existeixen biblioteques de directives (@auth(requereix: ORGANITZADOR)) que permeten declarar-ho al mateix SDL, cosa que és més difícil d'oblidar que un embolcall al resolutor.

  1. El problema N+1 i DataLoader

El problema N+1 del mòdul 7 apareix a REST, però a GraphQL és estructuralment pitjor, perquè el client tria la forma de la consulta i el pot provocar sense saber-ho. Una consulta que demana esdeveniments { nodes { titol sala { nom } sessions { id } } } amb 3 esdeveniments són 7 consultes: 1 del catàleg, 3 de sales i 3 de sessions. Amb 100 esdeveniments serien 201, i el client no ha fet res estrany: ha demanat les dades que necessita. A REST podies optimitzar el punt d'entrada concret; aquí no saps per endavant quina combinació demanaran. DataLoader ho resol amb dos mecanismes: lots, acumulant totes les crides a .load(id) que passen al mateix tick del bucle d'esdeveniments per fer una sola crida amb tots els identificadors; i memòria cau per petició, de manera que el mateix id no es consulta dues vegades.

'use strict';

const DataLoader = require('dataloader');

// CRITIC: retornar un array de la MATEIXA mida i en el MATEIX ordre que els
// ids rebuts; si en falta un, es retorna null al seu lloc.
const perClau = (registres, ids) => {
  const index = new Map(registres.map((r) => [r.id, r]));
  return ids.map((id) => index.get(id) || null);
};

// Un joc de carregadors NOU per peticio (veure crearContext). Cadascun
// fa una sola consulta amb WHERE id IN (...).
const crearCarregadors = ({ repositoris }) => ({
  esdeveniment: new DataLoader(async (ids) =>
    perClau(await repositoris.esdeveniments.obtenirPerIds(ids), ids)),
  sala: new DataLoader(async (ids) =>
    perClau(await repositoris.sales.obtenirPerIds(ids), ids)),
  // Carregador d'un-a-molts: retorna un array per cada clau.
  sessionsPerEsdeveniment: new DataLoader(async (esdevenimentIds) => {
    const sessions = await repositoris.sessions.llistarPerEsdeveniments(esdevenimentIds);
    const perEsdeveniment = new Map(esdevenimentIds.map((id) => [id, []]));
    for (const sessio of sessions) perEsdeveniment.get(sessio.esdevenimentId).push(sessio);
    return esdevenimentIds.map((id) => perEsdeveniment.get(id)); // array buit si no n'hi ha
  }),
});

module.exports = { crearCarregadors };

Mesura sobre el catàleg d'Escena Viva amb la consulta anterior:

Mètrica Sense DataLoader Amb DataLoader
Consultes a PostgreSQL (3 esdeveniments) 7 3
Latència p50 / p99 (3 esdeveniments) 84 ms / 240 ms 21 ms / 46 ms
Consultes / p99 amb 100 esdeveniments 201 / 3 100 ms 3 / 78 ms

La xifra de 201 a 3 amb 100 esdeveniments és la que cal retenir: DataLoader converteix un problema que creix linealment amb les dades en un de constant. A GraphQL no és una optimització opcional, és un requisit d'arquitectura. Dos advertiments: els carregadors s'han de crear per petició, perquè si els crees a l'arrencada la seva memòria cau sobreviu entre peticions i usuaris diferents, servint dades obsoletes i, pitjor, dades d'un altre usuari; i l'ordre i la mida de l'array retornat han de coincidir exactament amb les claus rebudes, que és l'error d'implementació més freqüent i produeix dades creuades entre entitats de manera silenciosa.

  1. Límits obligatoris: l'API sense límits és un DoS

Una API GraphQL sense límits és un atac de denegació de servei esperant a passar, i no cal ser sofisticat: n'hi ha prou d'imbricar esdeveniments { nodes { sala { esdeveniments { nodes { sala { ... } } } } } } una desena de nivells. Cada nivell multiplica la feina, així que amb relacions circulars (Esdeveniment → Sala → Esdeveniment) una consulta de 200 bytes pot generar milions de resolucions i exhaurir la memòria del procés. És una fallada de la categoria API4 (Consum de recursos sense restricció) de l'OWASP API Security Top 10 que vam veure al mòdul 8.

Defensa Com Valor raonable
Profunditat màxima graphql-depth-limit 7-10 nivells
Complexitat i paginació graphql-query-complexity; limit amb sostre 1 000 punts; màxim 50-100
Temps límit i mida del cos AbortSignal; express.json({ limit }) 5-10 s; 16 KB a /graphql
Consultes permeses i introspecció Llista blanca de persistides; introspection: false Clients propis; en producció
Limitador de peticions limits.js amb Redis (10-03) Per usuari, no només per IP
'use strict';

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

// El cost es calcula ABANS d'executar res: si l'excedeix, es rebutja.
const reglaDeComplexitat = (maxim = 1000) => (context) => createComplexityRule({
  maximumComplexity: maxim,
  variables: context.request.variables,
  // fieldExtensionsEstimator llegeix el cost declarat a cada camp de
  // l'esquema; simpleEstimator assigna 1 punt als que no el declaren.
  estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
  createError: (permes, actual) => new GraphQLError(
    `La consulta es massa complexa: ${actual} punts (maxim ${permes}).`,
    { extensions: { codi: 'CONSULTA_MASSA_COMPLEXA', estat: 400 } }),
});

module.exports = { reglaDeComplexitat };

Sobre la introspecció: és la capacitat de preguntar al servidor pel seu propi esquema, i és el que fa funcionar GraphiQL i les eines de generació de tipus. En producció lliura a qualsevol el mapa complet de la teva API, inclosos els camps que encara no has anunciat i les mutacions d'administració: desactiva-la i publica l'esquema pels canals que tu controlis. No és seguretat per obscuritat —els límits i l'autorització continuen sent la teva defensa real— però no hi ha cap raó per regalar el mapa. I les consultes persistides són la defensa definitiva quan l'únic client és teu: el client envia un hash en comptes de la consulta i el servidor només executa les de la seva llista aprovada, cosa que elimina d'un cop les bombes de profunditat i complexitat.

  1. Errors a GraphQL

A GraphQL tot respon 200 OK (llevat d'errors de transport o de sintaxi, que poden donar 400). Els errors viatgen al cos, i hi ha una cosa que a REST no existeix: les respostes parcials.

{
  "data": { "esdeveniment": { "titol": "Festival de Jazz de Primavera", "sala": null } },
  "errors": [{ "message": "No tens permis per a aquesta operacio",
    "path": ["esdeveniment", "sala"],
    "extensions": { "codi": "PROHIBIT", "estat": 403, "idPeticio": "f1c2..." } }]
}

data.esdeveniment.titol és vàlid i data.esdeveniment.sala és null amb el seu error associat. És potent i obliga el client a comprovar errors sempre, fins i tot quan hi ha dades. Les conseqüències són concretes:

Conseqüència Què implica
El client no pot confiar en el codi HTTP Cal inspeccionar errors a cada resposta
Els reintents automàtics no s'activen Un 200 amb error no dispara cap política de reintent
El monitoratge menteix La teva taxa de 5xx serà del 0 % amb el sistema cremant
Proxies i alertes per codi d'estat no serveixen Poden desar un error a la memòria cau; cal instrumentar per extensions.codi

La lliçó 10-04 va posar la taxa d'errors entre les quatre senyals d'or; amb GraphQL cal produir aquesta senyal a mà, amb un connector d'Apollo que a willSendResponse registri el nom de l'operació, la seva durada i els extensions.codi de cada error. Sense això, el teu panell del mòdul 11 mostrarà 0 % d'errors per sempre. I per això l'esquema distingia dues classes d'error. Els de negoci previsibles (aforament insuficient) viatgen a ResultatComanda.errorDeNegoci: són part del contracte, estan tipats i el client els gestiona amb el compilador del seu costat. Els inesperats (base de dades caiguda) van a errors. Aquesta separació —de vegades anomenada "errors com a dades"— és una de les millors pràctiques de l'ecosistema, i evita que el client hagi d'endevinar llegint cadenes de text.

  1. Subscripcions, i quan triar cada cosa

Subscription permet que el servidor empenyi dades cap al client sobre una connexió persistent (WebSocket, normalment amb graphql-ws), i a Escena Viva el cas natural és l'aforament durant l'estrena del Festival de Jazz: amb subscription { aforamentActualitzat(sessioId: "ses-003-1") { aforamentDisponible exhaurida } }, el comptador baixa en viu mentre la gent compra, alimentat pels esdeveniments venda-registrada i aforament-baix que el GestorDeVendes ja emet des del mòdul 2, publicats a través de Redis (10-03) perquè arribin als set treballadors.

Ho deixem anunciat: la comunicació en temps real, amb WebSockets, sales i difusió de missatges, es treballa de debò al mòdul 12 amb Socket.IO i el projecte de xat, on els problemes —connexions amb estat, escalat entre processos, reconnexió— es resolen en profunditat. El criteri final, que és el que t'ha de quedar:

Situació Tria
API pública per a tercers, contingut cacheable, fitxers i webhooks REST: memòria cau HTTP amb ETag i CDN, codis d'estat, eines universals
Aplicació pròpia amb moltes pantalles, clients heterogenis o dades en graf GraphQL
Equip petit, terminis curts, domini senzill REST: menys peces que puguin fallar
Producte madur amb clients externs i aplicació pròpia Tots dos, sobre el mateix domini

Escena Viva es queda a l'última fila, i s'ho pot permetre per l'arquitectura construïda al llarg del curs: domini pur, repositoris aïllats, polítiques d'autorització com a funcions pures. Sobre aquest nucli, REST i GraphQL són dues façanes; si el negoci fos dins dels controladors d'Express, mantenir-les totes dues seria duplicar la lògica i garantir que un dia divergeixen.

Errors Comuns i Consells

  • Creure que GraphQL substitueix REST. Són eines amb compromisos diferents; la majoria de sistemes madurs fan servir totes dues. I oblidar DataLoader: sense ell, qualsevol consulta imbricada és un N+1.
  • Carregadors compartits entre peticions, o que retornen un array de mida o ordre diferent: el primer creua dades entre usuaris, el segon creua dades entre entitats. Tots dos, en silenci.
  • Autoritzar només a l'arrel. El graf es navega des de qualsevol lloc: es comprova a cada resolutor sensible.
  • Publicar sense límits de profunditat i complexitat, o deixar la introspecció activa en producció: el primer és un DoS de 200 bytes, el segon regala el mapa de la teva API. I monitorar per codi HTTP no serveix: amb GraphQL sempre és 200, així que instrumenta per extensions.codi.
  • Consell: comença amb un esquema petit sobre els teus repositoris existents, i modela el domini, no les teves taules: un esquema que reflecteix la base de dades una a una sol ser mal disseny.
  • Consell: marca els camps que retires amb @deprecated(reason: "...") i mesura'n l'ús abans d'eliminar-los; és l'equivalent de la capçalera Sunset de la 10-05.

Exercicis

Exercici 1: esquema i resolutors del catàleg

Defineix l'esquema d'Esdeveniment, Sessio i Sala amb els seus camps calculats (aforamentDisponible, exhaurida) i munta /graphql sobre crearAplicacio() reutilitzant els repositoris del M7, sense duplicar lògica. Comprova que l'API REST continua funcionant i escriu una prova d'integració amb supertest que compari el resultat de la consulta GraphQL amb el de GET /api/v1/esdeveniments/evt-003.

Exercici 2: mesurar i eliminar el N+1

Instrumenta el repositori per comptar consultes SQL per petició. Executa la consulta que demana 3 esdeveniments amb la seva sala i les seves sessions, i anota el nombre de consultes sense DataLoader. Implementa els tres carregadors i torna a mesurar. Repeteix-ho amb 100 esdeveniments sembrats.

Exercici 3: bomba de consulta i defensa

Escriu una consulta amb 12 nivells d'imbricació aprofitant la relació circular Esdeveniment → Sala → Esdeveniment. Mesura el temps de resposta i el retard del bucle d'esdeveniments (10-04) sense límits. Aplica depthLimit(8) i una regla de complexitat de 1 000 punts, i verifica que la consulta es rebutja abans d'executar-se.

Solucions

Exercici 1. La clau és no escriure lògica nova: Sessio.aforamentDisponible és sessio.aforamentTotal - sessio.entradesVenudes, la mateixa fórmula que ja viu a src/domini/sessio.js, i el correcte és cridar aquella funció, no reescriure-la. La prova compara camp a camp el resultat de peticio.get('/api/v1/esdeveniments/evt-003') amb el de peticio.post('/graphql').send({ query: '{ esdeveniment(id: "evt-003") { titol sala { nom } } }' }), comprovant a més que body.errors és undefined. Que tots dos coincideixin demostra el que buscàvem: dues façanes sobre un domini. Si divergeixen, hi ha lògica duplicada en algun lloc i això és deute tècnic des del primer dia.

Exercici 2. Amb 3 esdeveniments i sense carregadors, el comptador dona 7 consultes: 1 del catàleg, 3 de sales, 3 de sessions. Amb els carregadors baixa a 3: catàleg, sales WHERE id IN (...) i sessions WHERE esdevenimentId IN (...). Amb 100 esdeveniments sembrats la diferència es torna dramàtica —201 davant de 3, i el p99 passa de ~3,1 s a ~78 ms—, però l'important no és el factor de millora sinó la forma de la corba: sense DataLoader el cost creix amb el nombre d'elements i amb ell és constant. Detall que sorprèn molta gent: els carregadors poden agrupar perquè GraphQL resol en amplada, així que totes les crides a .load() d'un mateix nivell passen al mateix tick del bucle d'esdeveniments —exactament el mecanisme de microtasques del mòdul 2— i DataLoader les recull totes abans de consultar.

Exercici 3. Sense límits, la consulta de 12 nivells amb relació circular triga entre 8 i 40 segons segons les dades sembrades, i el retard del bucle d'esdeveniments supera els 3 000 ms: el procés queda inservible per a tots els altres usuaris, exactament el símptoma de la lliçó 10-02 però provocat des de fora amb una cadena de text de 200 bytes. Amb depthLimit(8) la consulta es rebutja durant la fase de validació, abans d'executar un sol resolutor, amb Query exceeds maximum operation depth of 8 i en menys de 2 ms. La regla de complexitat captura a més el cas que la profunditat no veu: una consulta plana però amb limit: 10000 en diverses llistes. Totes dues són necessàries, i cap no substitueix l'autorització: els límits protegeixen la disponibilitat, no la confidencialitat.

Conclusió

Escena Viva arriba al final del mòdul 10 sent un sistema diferent del que va començar. Fa servir tots els nuclis de la màquina amb cluster, amb supervisió, recàrrega sense talls i la lliçó apresa que l'estat en memòria no sobreviu a diversos processos. No bloqueja el bucle d'esdeveniments: la generació dels QR i els PDF viu en un pool de fils de treball, i el retard del bucle va baixar de 4 176 ms a 11 ms. Desa a la memòria cau el catàleg a Redis amb el patró cache-aside, claus versionades i invalidació en vendre, passant de 31 050 consultes a PostgreSQL a 7. Encua la feina pesada amb BullMQ, respon 202 Accepted i protegeix la idempotència al productor i al consumidor. Està mesurada: amb proves de càrrega honestes, perfils de CPU, gràfics de flama, instantànies del munt i el retard del bucle com a mètrica de salut, i amb un ordre clar de palanques —algorisme, consulta, memòria cau, concurrència, maquinari—. Té una API ben dissenyada, amb recursos coherents, mètodes amb les seves garanties, claus d'idempotència, codis d'estat amb criteri, paginació per cursor, versionat amb política de deprecació, memòria cau HTTP amb ETag i If-Match, i documentació OpenAPI generada des dels esquemes. I ara té també una alternativa GraphQL que conviu amb REST sobre el mateix domini, amb DataLoader contra el N+1 i límits de profunditat i complexitat per no ser una denegació de servei esperant a passar.

És un sistema que aguanta l'estrena del Festival de Jazz de Primavera. I tanmateix, tot això continua corrent al teu portàtil.

Els secrets són en un .env local que no pots compartir amb ningú sense enviar-lo per un canal insegur. Els registres es perden quan tanques la terminal, i amb set treballadors escrivint alhora, ni tan sols es llegeixen bé. No hi ha ningú que reiniciï el procés si mor a les quatre de la matinada: el src/cluster.js que vam escriure supervisa els seus treballadors, però ningú no supervisa el primari. No hi ha un contenidor que garanteixi que la versió de Node, de PostgreSQL i de Redis sigui la mateixa a la teva màquina i al servidor. I no hi ha desplegament: pujar una versió nova és una seqüència d'ordres manuals que només tu coneixes i que un dia faràs malament a les dues de la matinada.

Al Mòdul 11, Desplegament i DevOps, tanquem aquesta distància: configuració i variables d'entorn gestionades com cal, registre i monitoratge en producció perquè les mètriques que hem après a produir arribin a algun lloc on algú se les miri, PM2 per supervisar i executar en mode cluster el que aquí vam implementar a mà, empaquetatge amb Docker perquè l'entorn viatgi amb l'aplicació, desplegament a Heroku i altres PaaS, i una canonada d'integració i desplegament continus que converteixi pujar una versió en una cosa avorrida. Que és, al capdavall, el major elogi que es pot fer a un desplegament.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats