A la lliçó anterior, 06-01, vam tancar la memòria tècnica de l'API de la Botiga Aroma amb una llista de certeses: paginació per desplaçament, comptadors exactes, autorització basada en la propietat del recurs, col·leccions embolcallades en dades amb el seu total, i temps real com a accessori del tauler intern. Totes aquestes decisions eren correctes per a aquell domini. Ara canviem de domini sense canviar d'estil arquitectònic: dissenyem CafeSocial, la xarxa de tastadors que la Botiga Aroma vol llançar, i una a una veurem caure aquestes certeses. No perquè fossin equivocades, sinó perquè estaven lligades a un context que ja no existeix. Aquest contrast —la mateixa disciplina REST produint dissenys oposats— és el veritable contingut de la lliçó.
Contingut
- L'escenari: CafeSocial, requisits i consumidors
- Modelatge del graf de relacions amb REST
- La línia de temps: fan-out, recurs derivat i cursor opac
- Volum i escala: comptadors aproximats, memòria cau i cues
- Contingut generat pels usuaris: pujada, moderació i denúncies
- Privadesa i autorització a nivell de recurs
- Notificacions i temps real
- Botiga Aroma davant de CafeSocial, decisió a decisió
- Per què aquí GraphQL sí que és defensable
- Errors comuns i consells
- Exercicis i solucions
- L'escenari: CafeSocial, requisits i consumidors
CafeSocial és una xarxa social vertical de tastadors. Un usuari publica un tast: una foto del cafè, la varietat, l'origen, el mètode de preparació i una nota del 0 al 100. Altres usuaris hi comenten, hi donen "m'agrada", segueixen els tastadors que els interessen i reben una línia de temps amb les publicacions de la gent que segueixen. Hi ha etiquetes (#geisha, #v60), notificacions i missatges directes.
Els identificadors mantenen la convenció del curs: usr_10, usr_77, pub_2100, cmt_310, not_55. El JSON continua sent camelCase, els errors segueixen el catàleg de {"error": {"codi","missatge","detalls":[]}} i la versió continua a la ruta (/v1). Canvia el domini, no les convencions: això és justament el que permet comparar.
Requisits que condicionen el disseny
| Requisit | Xifra objectiu (fictícia) | Conseqüència de disseny |
|---|---|---|
| Proporció lectura/escriptura | ~500:1 | Tot s'optimitza per a la lectura; l'escriptura pot ser més cara |
| Latència de la línia de temps | p95 < 150 ms | Precalcular, no calcular a la petició |
| Mida del graf | 2 M d'usuaris, 180 M d'arestes de seguiment | La relació és un recurs de primer nivell, no un camp |
| Distribució de seguidors | Cua llarga: el 0,01 % supera els 100.000 seguidors | Un únic algorisme de repartiment no serveix |
| Contingut d'usuaris | 40.000 publicacions amb foto al dia | Moderació i emmagatzematge fora de l'API |
| Consistència | Eventual acceptable a la línia de temps i als comptadors | Es pot desacoblar amb cues |
Consumidors
- App mòbil (iOS/Android): el consumidor principal, amb amplada de banda i bateria limitades. Li dol cada byte i cada anada i tornada.
- Web pública: perfils i publicacions indexables pels cercadors quan són públics.
- Tauler de moderació intern: pocs usuaris, permisos amplis, necessita cues de treball.
- Integració amb la Botiga Aroma: quan una publicació esmenta un cafè del catàleg, la fitxa enllaça a
/v1/cafes/caf_001de l'API de la botiga. Són dues APIs diferents que s'enllacen per hipermèdia, no una de sola.
A diferència de la botiga, aquí no hi ha diners a la petició. Res no exigeix l'exactitud transaccional que a 06-01 ens va obligar a resoldre la sobrevenda dins de l'
UPDATE. Aquesta llibertat és la que fa possible gairebé tot el que ve a continuació.
- Modelatge del graf de relacions amb REST
A la Botiga Aroma gairebé tot era una relació de contenció simple: una comanda pertany a un client, una línia pertany a una comanda. Aquí la relació és l'entitat interessant.
2.1 Les dues col·leccions del graf
Són dues vistes de la mateixa aresta, des dels dos extrems. Cap no és "la bona": l'app necessita totes dues i amb cardinalitats molt diferents (un usuari en segueix 300, però pot tenir-ne 300.000 de seguidors).
2.2 La relació com a recurs: PUT davant de POST /seguir
La temptació és crear un verb: POST /v1/seguir amb {"seguitId": "usr_77"}. Funciona, i és exactament el que a 02-02 anomenàvem convertir una acció en recurs sense necessitat. L'alternativa és tractar l'aresta com un recurs adreçable:
PUT /v1/usuaris/usr_10/seguint/usr_77 HTTP/1.1
Authorization: Bearer <token d'usr_10>
HTTP/1.1 204 No Content
Aroma-Graf-Estat: seguintEls avantatges, recuperant la taula de mètodes de 02-03:
| Aspecte | PUT /seguint/{id} |
POST /seguir |
|---|---|---|
| Idempotència | Sí: prémer "seguir" cinc vegades deixa el mateix estat | No garantida; cal desduplicar |
| Reintent després d'un timeout | Segur per definició | Requereix Idempotency-Key |
| Consulta de l'estat | GET de la mateixa URI (204/404) |
Cal inventar-se un altre endpoint |
| Desfer | DELETE de la mateixa URI |
Un altre verb: POST /deixar-de-seguir |
| Cacheable / enllaçable | Sí, té URI pròpia | No |
En una app mòbil amb xarxa inestable, la idempotència no és elegància teòrica: és la diferència entre un botó que es queda "a mitges" i un que no. El client pot reintentar el PUT sense pensar-hi.
GET /v1/usuaris/usr_10/seguint/usr_77 retorna 204 si la relació existeix i 404 si no, cosa que dona al client una comprovació barata per pintar el botó.
2.3 "M'agrada": usuari a la ruta o al token?
Les dues formes són legítimes i convé entendre el compromís:
PUT /v1/publicacions/pub_2100/magrada/usr_10 # A: subjecte explícit
PUT /v1/publicacions/pub_2100/magrada # B: subjecte implícit al token| Criteri | A (explícit) | B (implícit) |
|---|---|---|
| URI autodescriptiva | Sí | No: la mateixa URI significa coses diferents segons qui cridi |
| Risc de suplantació | Cal validar que la ruta coincideix amb el token | Impossible per construcció |
| Actuar en nom d'un altre (admin, importació) | Directe | Necessita una capçalera o un endpoint a part |
| Llistar qui ha donat "m'agrada" | GET /publicacions/pub_2100/magrada natural |
Igual de natural |
| Memòria cau | Cacheable per URI | Necessita Vary: Authorization |
A CafeSocial triem A per al graf de seguiment i per a "m'agrada", per coherència i perquè el tauler de moderació ha de poder retirar un "m'agrada" d'un altre usuari. La regla que apliquem: si algun consumidor legítim pot actuar sobre la relació d'un tercer, el subjecte va a la ruta. Quan la ruta i el token no coincideixen i qui crida no té l'àmbit adequat, responem 403 amb permisos_insuficients.
2.4 Quan la relació necessita cos propi
Una aresta amb atributs deixa de ser un simple "existeix o no". Seguir algú pot tenir preferències associades:
PUT /v1/usuaris/usr_10/seguint/usr_77 HTTP/1.1
Content-Type: application/json
{"notificacions": "silenciades", "veureRepublicacions": false}HTTP/1.1 200 OK
Content-Type: application/json
ETag: "w/rel-usr10-usr77-3"
{
"usuariId": "usr_10",
"seguitId": "usr_77",
"creatEl": "2026-03-04T10:22:11Z",
"notificacions": "silenciades",
"veureRepublicacions": false,
"_links": {
"self": {"href": "/v1/usuaris/usr_10/seguint/usr_77"},
"seguit": {"href": "/v1/usuaris/usr_77"}
}
}Regla pràctica: sense atributs, 204 i cos buit; amb atributs, 200 i representació completa amb ETag, i llavors les modificacions parcials (PATCH) i la concurrència optimista de 03-06 tornen a aplicar-se. No convé inventar atributs "per si de cas": una aresta amb cos costa una fila més ampla multiplicada per 180 milions.
- La línia de temps: fan-out, recurs derivat i cursor opac
3.1 El problema del fan-out
Quan usr_77 publica, qui paga el cost que aparegui a la línia de temps dels seus seguidors?
graph LR
A[usr_77 publica pub_2100] --> B{Estrategia}
B -->|Fan-out en escriptura| C[Cua de repartiment]
C --> D[Inserir en 300.000 busties]
D --> E[GET /liniatemps llegeix una bustia: rapid]
B -->|Fan-out en lectura| F[Escriptura barata: 1 fila]
F --> G[GET /liniatemps consulta 300 seguits i barreja]
G --> H[Latencia alta i variable]
| Criteri | Fan-out en escriptura (push) | Fan-out en lectura (pull) |
|---|---|---|
| Cost de publicar | Alt: O(seguidors) | Mínim: O(1) |
| Cost de llegir la línia | Mínim: lectura seqüencial d'una bústia | Alt: O(seguits), barreja ordenada |
| Latència p95 de lectura | Baixa i estable | Alta i dependent de l'usuari |
| Emmagatzematge | Enorme (duplicat per seguidor) | Mínim |
| Publicar amb 1 M de seguidors | Un milió d'escriptures per publicació | Sense cost extra |
| Esborrar una publicació | Cal netejar totes les bústies | Desapareix sola |
| Encaixa amb | La majoria d'usuaris normals | Comptes molt seguits |
Amb 500 lectures per cada escriptura, el fan-out en escriptura guanya gairebé sempre. El problema és la cua llarga: si usr_77 és un tastador famós amb un milió de seguidors, cada publicació seva dispara un milió d'insercions i la cua s'encalla durant minuts.
Solució híbrida, que és la que adopta CafeSocial:
- Els usuaris amb menys de 10.000 seguidors fan servir push: en publicar, una tasca asíncrona insereix la referència a la bústia de cada seguidor.
- Els usuaris per sobre d'aquest llindar es marquen com a comptes d'abast i fan servir pull: no reparteixen res.
GET /v1/liniatempsllegeix la bústia de l'usuari i barreja en el moment les publicacions recents dels pocs comptes d'abast que segueix (normalment menys de 20), ordenant per marca temporal.
És més codi i més complexitat, però és l'únic disseny que sobreviu a les dues puntes de la distribució.
3.2 /liniatemps no és una col·lecció
A la Botiga Aroma, /cafes era una col·lecció: es podia crear (POST), comptar (total), filtrar i saltar a la pàgina 7. La línia de temps no és res d'això: és un recurs derivat, una vista calculada que només existeix per a un usuari i en un instant.
| Propietat d'una col·lecció normal | A /liniatemps |
|---|---|
POST per crear un element |
No existeix: es publica a /publicacions, no a la línia |
total estable |
Impossible: no hi ha cap conjunt tancat per comptar |
| Saltar a la pàgina N | No té sentit: la pàgina 7 de fa 3 s ja no és la mateixa |
| Filtres arbitraris | Molt limitats (?nomesAmbFoto=true), no és un cercador |
DELETE d'un element |
No: s'amaga o es deixa de seguir l'autor |
Dit d'una altra manera: /liniatemps respon "què hi ha de nou per a mi?", no "quins elements conté aquest conjunt?". Igual que a 02-06 distingíem cerca de llistat, aquí distingim flux de col·lecció.
3.3 Per què la paginació per desplaçament no serveix
Recuperem el mecanisme de 02-06: ?limit=20&desplacament=20 es tradueix a LIMIT 20 OFFSET 20. Això pressuposa que el conjunt ordenat no canvia entre peticions. En una línia de temps ordenada per data descendent, canvia cada segon.
Exemple numèric. La línia d'usr_10 conté, ordenades de més nova a més antiga, les publicacions pub_2100 (posició 1) fins a pub_2001 (posició 100).
Cas de duplicats. El client demana la primera pàgina:
Mentre l'usuari llegeix, arriben 3 publicacions noves. Ara tot s'ha desplaçat 3 posicions. El client demana la segona pàgina:
GET /v1/liniatemps?limit=20&desplacament=20
→ posicions 21..40 del conjunt NOU = pub_2084 ... pub_2065pub_2084, pub_2083 i pub_2082 ja s'havien mostrat a la primera pàgina. L'usuari veu tres publicacions repetides.
Cas de salts. Amb el mateix estat inicial, entre la primera i la segona petició s'esborren 3 publicacions de les 20 primeres. En demanar desplacament=20, les posicions 21..40 del conjunt reduït corresponen al que abans eren les posicions 24..43: pub_2078 i les dues anteriors no es mostren mai. L'usuari no les veurà mai, i no hi ha cap error visible que ho delati.
Afegeix-hi que OFFSET 200000 obliga el motor a recórrer i descartar 200.000 files: el cost creix amb la profunditat. Duplicats, buits silenciosos i cost creixent: tres raons independents, cadascuna suficient.
3.4 El cursor opac
A la Botiga Aroma el cursor era una opció que vam aplicar a /comandes. Aquí és obligatori. El cursor implementa paginació per clau (keyset): en lloc de "salta't 20 files", diu "dona'm el que hi ha abans d'aquest punt exacte".
Contingut del cursor: marca temporal + identificador de desempat. La marca sola no n'hi ha prou perquè dues publicacions poden compartir mil·lisegon; l'identificador trenca l'empat i garanteix un ordre total.
// utilitats/cursor.js — codificació i descodificació del cursor opac
const CLAU_VERSIO = 'v1';
function codificarCursor({ creatEl, id }) {
const carrega = JSON.stringify({ v: CLAU_VERSIO, t: creatEl, i: id });
return Buffer.from(carrega, 'utf8').toString('base64url');
}
function descodificarCursor(cursor) {
let dades;
try {
dades = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw new ErrorApi(400, 'cursor_invalid', 'El cursor no és vàlid.');
}
if (dades.v !== CLAU_VERSIO || !dades.t || !dades.i) {
throw new ErrorApi(400, 'cursor_invalid', 'El cursor no és vàlid.');
}
// Límit d'antiguitat: un cursor vell apunta a una bústia ja retallada.
const antiguitatDies = (Date.now() - Date.parse(dades.t)) / 86_400_000;
if (antiguitatDies > 30) {
throw new ErrorApi(410, 'cursor_caducat', 'Torna a començar des del principi.');
}
return { creatEl: dades.t, id: dades.i };
}La consulta corresponent fa servir la comparació de tuples, que aprofita l'índex compost (usuari_id, creat_el DESC, publicacio_id DESC):
-- Demanem limit+1 files per saber si hi ha pagina seguent sense comptar el total
SELECT p.publicacio_id, p.autor_id, p.creat_el
FROM bustia b
JOIN publicacions p ON p.publicacio_id = b.publicacio_id
WHERE b.usuari_id = :usuariId
AND (p.creat_el, p.publicacio_id) < (:cursorData, :cursorId)
ORDER BY p.creat_el DESC, p.publicacio_id DESC
LIMIT :limit + 1;La resposta no porta total —no existeix— i exposa l'enllaç següent tant al cos com a la capçalera Link de la RFC 8288, igual que a la botiga:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=0, must-revalidate
Vary: Authorization
Link: </v1/liniatemps?limit=20&cursor=eyJ2IjoidjEiLCJ0IjoiMjAyNi0wOC0xNFQwOToxMjozMy40MTJaIiwiaSI6InB1Yl8yMDgxIn0>; rel="next"
{
"dades": [
{
"id": "pub_2100",
"autor": {"id": "usr_77", "alias": "tastadora_geisha"},
"nota": 92,
"metode": "v60",
"magrada": 1240,
"magradaAproximat": true,
"_links": {"self": {"href": "/v1/publicacions/pub_2100"}}
}
],
"paginacio": {
"seguent": "eyJ2IjoidjEiLCJ0IjoiMjAyNi0wOC0xNFQwOToxMjozMy40MTJaIiwiaSI6InB1Yl8yMDgxIn0",
"hiHaMes": true
}
}Per què opac. El cursor és base64url d'un JSON, no xifrat: qualsevol el pot llegir. L'opacitat és un contracte, no una mesura de seguretat: com que no en documentem l'interior, demà podem canviar de (data, id) a un identificador de posició en un índex distribuït sense trencar cap client. El que sí que garantim és la validació: si el client el manipula, la descodificació falla (400 cursor_invalid) o els valors no superen la comprovació de tipus. I com que el cursor no conté l'identificador de l'usuari —aquest surt sempre del token—, manipular-lo no permet llegir la bústia d'un altre. Aquest punt és essencial: no fiquis mai informació d'autorització al cursor.
Antiguitat màxima. Les bústies es retallen a les últimes 800 entrades i a 30 dies. Un cursor més antic apunta a un buit, així que responem 410 Gone amb cursor_caducat i el client torna al principi, en lloc de retornar una llista buida que l'app interpretaria com a "fi del contingut".
- Volum i escala
4.1 Comptadors aproximats a propòsit
A la Botiga Aroma, l'estoc havia de ser exacte: d'aquí va sortir la condició dins de l'UPDATE que resolia la sobrevenda. Aquí, SELECT COUNT(*) FROM magrada WHERE publicacio_id = 'pub_2100' a cada lectura de la línia de temps significa 20 consultes de recompte per pantalla, multiplicades per milions de pantalles.
La decisió: comptador desnormalitzat, actualitzat de manera asíncrona i declarat com a aproximat.
// serveis/magrada.js
async function donarMagrada(publicacioId, usuariId) {
const creat = await repoMagrada.inserirSiNoExisteix(publicacioId, usuariId);
if (creat) {
// El comptador no s'actualitza aquí: s'agrega per lots cada 5 segons.
await cua.publicar('comptadors.magrada', { publicacioId, delta: 1 });
}
return creat; // permet respondre 201 el primer cop i 204 als reintents
}Les conseqüències cal assumir-les de manera explícita, no amagar-les:
- La resposta marca
"magradaAproximat": truequan el comptador supera els 1.000. Per sota es recalcula en el moment i és exacte: els usuaris noten un desfasament en 12 però no en 12.480. - Una tasca nocturna reconcilia els comptadors amb la taula real.
- El mateix usuari sempre veu reflectit el seu propi "m'agrada" a l'instant (lectura de les teves pròpies escriptures), encara que el nombre global trigui: és el que evita que la interfície sembli trencada.
4.2 Memòria cau de perfils i el problema de "depèn de qui pregunta"
Reprenent 04-06, el perfil és el candidat ideal a la memòria cau: es llegeix constantment i canvia poc.
GET /v1/usuaris/usr_77 HTTP/1.1
If-None-Match: "perf-usr77-v18"
HTTP/1.1 304 Not Modified
ETag: "perf-usr77-v18"
Cache-Control: private, max-age=60
Vary: AuthorizationEl delicat és què es pot posar a la memòria cau compartida:
| Recurs | Directiva | Motiu |
|---|---|---|
| Perfil públic, sense sessió | public, max-age=300 |
Igual per a tothom |
| Perfil vist per un usuari autenticat | private, max-age=60 + Vary: Authorization |
Inclou "el segueixo?", "m'ha bloquejat?" |
| Línia de temps | private, max-age=0, must-revalidate |
Única per usuari i per instant |
| Imatge de publicació (CDN) | public, max-age=31536000, immutable |
URL amb empremta de contingut |
Vary: Authorization és correcte però, a la pràctica, destrueix la memòria cau compartida: cada token genera una entrada diferent. Per això l'estratègia real és partir la resposta en dues: les dades objectives del perfil (àlies, biografia, foto) se serveixen cacheables i públiques, i el que és relatiu a l'observador (seguintAquestUsuari, emBloqueja) es demana a part o es marca private. Posar la barreja a la memòria cau és l'error clàssic, i la seva versió més greu és un proxy retornant el perfil "vist per un altre usuari" a qui no toca.
4.3 Cues, 202 i recursos de tasca
El repartiment del fan-out i altres operacions llargues no caben al cicle de la petició. Igual que a 04-06 amb els informes de la botiga:
POST /v1/usuaris/usr_10/exportacio HTTP/1.1
HTTP/1.1 202 Accepted
Location: /v1/tasques/tas_9001
Retry-After: 10
{"id": "tas_9001", "estat": "en_curs",
"_links": {"self": {"href": "/v1/tasques/tas_9001"}}}Publicar és el mateix però a l'inrevés: POST /v1/publicacions respon 201 immediatament amb la publicació creada —l'autor ja la veu al seu perfil— mentre el repartiment a les bústies passa per darrere. És la consistència eventual feta contracte: un seguidor pot trigar uns segons a veure-la, i això està documentat.
4.4 El cost de la desnormalització
Desnormalitzar no és gratis. A CafeSocial paguem: emmagatzematge multiplicat (una publicació apareix a centenars de milers de bústies), un camí d'escriptura més fràgil (si la cua falla, hi ha bústies incompletes i cal un procés de reparació), i dues fonts de veritat que poden divergir. La regla és que la font canònica continua sent /publicacions/{id}; les bústies són memòria cau reconstruïble. Tot el que es pugui regenerar des de la font canònica és acceptable de desnormalitzar; el que no, no.
- Contingut generat pels usuaris
5.1 Pujada d'imatges amb URL presignada
L'API no rep els megabytes de la foto. S'emet una autorització de pujada directa a l'emmagatzematge d'objectes:
sequenceDiagram
participant App as App mobil
participant API as API CafeSocial
participant Alm as Emmagatzematge
App->>API: POST /v1/publicacions/pub_2100/imatges {tipus, mida}
API-->>App: 201 {urlPujada, camps, expiraEl, imatgeId}
App->>Alm: PUT urlPujada (bytes de la foto)
Alm-->>App: 200 OK
App->>API: POST /v1/publicacions/pub_2100/imatges/img_44/confirmacio
API->>Alm: Verificar tipus, mida i capcalera del fitxer
API-->>App: 200 {estat: "pendent_moderacio"}
POST /v1/publicacions/pub_2100/imatges HTTP/1.1
Content-Type: application/json
{"tipusContingut": "image/jpeg", "midaBytes": 2411520}HTTP/1.1 201 Created
Location: /v1/publicacions/pub_2100/imatges/img_44
{
"id": "img_44",
"urlPujada": "https://magatzem.cafesocial.example/pujades/img_44?signatura=...",
"metode": "PUT",
"expiraEl": "2026-08-14T09:27:00Z",
"midaMaximaBytes": 5242880,
"estat": "pendent_pujada"
}Les raons per no fer passar els bytes per l'API són concretes:
- Un procés Node ocupat 8 segons amb una pujada de 5 MB és un procés que no atén ningú més.
- L'escalat del trànsit de pujada es desacobla de l'escalat de la lògica de negoci.
- L'emmagatzematge i el CDN ja resolen represa, multipart i distribució geogràfica.
- Els temps d'espera de proxies i balancejadors deixen de ser un problema.
Validació. La URL presignada limita tipus i mida, però això no n'hi ha prou: el client declara image/jpeg i puja qualsevol cosa. Per això la confirmació és obligatòria i comprova al servidor els bytes reals (nombres màgics de la capçalera del fitxer, dimensions, mida), reprocessa la imatge a diverses mides i descarta les metadades EXIF —que inclouen coordenades GPS: publicar la ubicació exacta de la casa d'un usuari seria una fuita de dades personals. Una imatge sense confirmar en 15 minuts s'esborra i la publicació queda com a esborrany.
5.2 Moderació
L'estat replica el de les ressenyes de la Botiga Aroma: pendent_moderacio | publicada | rebutjada. El que canvia és l'escala, que obliga a dues etapes:
- Automàtica, en el moment: classificador d'imatge i text. Puntuació baixa, publicació directa; puntuació mitjana, cua humana; puntuació alta, rebuig immediat amb possibilitat de recurs.
- Humana, sobre una cua de treball:
GET /v1/moderacio/pendents?limit=50&cursor=... HTTP/1.1
Authorization: Bearer <token amb ambit moderacio:llegir>
POST /v1/moderacio/publicacions/pub_2100/aprovacio HTTP/1.1
POST /v1/moderacio/publicacions/pub_2100/rebuig HTTP/1.1
Content-Type: application/json
{"motiu": "contingut_no_relacionat", "notificarAutor": true}Detalls de disseny que importen:
- La cua de moderació viu sota
/moderacio/*, un espai de noms propi amb els seus àmbits (moderacio:llegir,moderacio:escriure). Barrejar-la amb/publicacionsobligaria que el mateix endpoint es comportés de manera radicalment diferent segons l'àmbit, que és justament el que fa que les proves siguin ingovernables. - Aprovació i rebuig són recursos-acció (
POSTa un subrecurs) perquè no són idempotents en els seus efectes: disparen notificacions i queden auditats. Cada decisió es desa amb moderador, marca temporal i motiu. - La cua es pagina per cursor: creix i es consumeix alhora.
5.3 Denúncies
POST /v1/publicacions/pub_2100/denuncies HTTP/1.1
{"motiu": "spam", "comentari": "Publica el mateix enllaç a tots els tasts"}Es respon 202 i no 201 amb el veredicte: la denúncia s'accepta, no es resol. El denunciant no ha de poder consultar l'estat detallat ni la identitat del moderador, i el denunciat no ha de conèixer el denunciant. Diverses denúncies sobre la mateixa publicació s'agreguen en un únic cas.
Advertiment imprescindible. Tot l'anterior és la part d'enginyeria, que és la fàcil. Un servei real amb contingut d'usuaris té obligacions legals que no són decisions tècniques: bases de licitud i drets RGPD, terminis de retirada de contingut il·lícit, protecció de menors i verificació d'edat, retenció i lliurament de dades a les autoritats, transparència de la moderació i vies de recurs. Canvien per país i amb el temps. Dissenya-ho amb assessoria legal i de compliment normatiu des del principi; un endpoint d'esborrat que no compleix el termini legal és un problema jurídic, no un
TODOdel backlog.
- Privadesa i autorització a nivell de recurs
A la Botiga Aroma la pregunta era "de qui és això?": la comanda com_5001 és de cli_842, així que només cli_842 i els administradors la veuen. Binari i senzill.
A CafeSocial la pregunta és "qui pregunta i què li deixem veure?", i la resposta ja no és sí o no, sinó una representació diferent:
Qui pregunta per usr_77 (perfil privat) |
Què rep |
|---|---|
El mateix usr_77 |
Tot, inclosos esborranys i correu |
| Un seguidor acceptat | Perfil complet i publicacions |
| Un usuari autenticat que no el segueix | Àlies, foto, biografia, comptadors; sense publicacions |
Un usuari bloquejat per usr_77 |
404 usuari_no_trobat |
| Sense autenticar | 404 si el perfil és privat; fitxa reduïda si és públic |
6.1 Projecció per visibilitat
El patró: la capa de servei retorna l'entitat completa i una projecció decideix quins camps sobreviuen segons la relació entre observador i observat.
// presentacio/projeccions/usuari.js
const CAMPS = {
propietari: ['id','alias','nom','bio','foto','correu','seguidors','seguint','privat'],
seguidor: ['id','alias','nom','bio','foto','seguidors','seguint','privat'],
public: ['id','alias','foto','bio','seguidors','privat'],
};
function projectarUsuari(usuari, context) {
const nivell = calcularNivellVisibilitat(usuari, context); // propietari|seguidor|public|ocult
if (nivell === 'ocult') throw new ErrorApi(404, 'usuari_no_trobat', 'No trobat.');
const sortida = {};
for (const camp of CAMPS[nivell]) sortida[camp] = usuari[camp];
sortida._links = enllacosSegonsNivell(usuari, nivell);
return sortida;
}Tres regles que eviten les errades habituals:
- Llista blanca, mai llista negra. Un camp nou a l'entitat no s'ha de filtrar només per haver-s'hi afegit.
- La projecció s'aplica també dins de les llistes.
GET /usuaris/usr_77/seguidorsretalla els usuaris bloquejats i els privats: la llista retornada pot ser més curta que el comptador que mostra el perfil, i això és correcte. - La visibilitat es decideix a la consulta, no després. Filtrar en memòria després de portar 50 files trenca la paginació per cursor: en demanaries 20 i en retornaries 14.
6.2 404 en lloc de 403
Si usr_77 bloqueja usr_10 i aquest demana GET /v1/usuaris/usr_77, un 403 permisos_insuficients seria tècnicament honest i filtraria informació: confirmaria que el compte existeix, i per diferència de respostes es podria enumerar qui ha bloquejat qui o descobrir quins àlies estan registrats. Quan la mera existència del recurs és informació sensible, es respon 404.
El criteri general: 403 quan el recurs és conegut per qui crida o la seva existència no revela res (el moderador que no té l'àmbit adequat); 404 quan revelar l'existència ja és una fuita. I cal ser coherent en els temps de resposta: un 404 que triga 5 ms quan el recurs no existeix i 40 ms quan existeix però està bloquejat torna a filtrar la informació per un canal lateral.
6.3 Efecte sobre la memòria cau i les proves
- Memòria cau: qualsevol resposta la forma de la qual depengui de l'observador és
privatei ambVary: Authorization. Un CDN compartit només pot servir el que és objectivament públic. Això redueix el rendiment i és un cost assumit conscientment. - Proves: la matriu creix de cop. Cada endpoint sensible necessita casos per a propietari, seguidor, no seguidor, bloquejat i anònim. A CafeSocial es resol amb una taula de casos parametritzada:
describe('GET /v1/usuaris/usr_77 segons observador', () => {
const casos = [
{ observador: 'propietari', estat: 200, inclou: ['correu'], exclou: [] },
{ observador: 'seguidor', estat: 200, inclou: ['nom'], exclou: ['correu'] },
{ observador: 'estrany', estat: 200, inclou: ['alias'], exclou: ['nom','correu'] },
{ observador: 'bloquejat', estat: 404, inclou: [], exclou: [] },
];
for (const c of casos) it(`observador ${c.observador} → ${c.estat}`, async () => { /* ... */ });
});
- Notificacions i temps real
A la Botiga Aroma, SSE era un extra del tauler intern. Aquí, la immediatesa és el producte: una xarxa social en què la notificació arriba dos minuts tard es percep com a trencada.
REST pur obligaria al sondeig: l'app pregunta GET /v1/notificacions cada 10 segons. Amb 500.000 usuaris actius són 50.000 peticions per segon, de les quals el 99 % retornen el mateix. Ni amb ETag i 304 (que estalvien amplada de banda, no peticions) surten els números.
El disseny combina tres peces:
GET /v1/notificacions?limit=30&cursor=... HTTP/1.1
{"dades": [
{"id": "not_55", "tipus": "magrada", "llegida": false,
"actor": {"id": "usr_77", "alias": "tastadora_geisha"},
"recurs": {"href": "/v1/publicacions/pub_2100"},
"creadaEl": "2026-08-14T09:12:33Z"}
],
"noLlegides": 4,
"paginacio": {"seguent": "eyJ2IjoidjEi...", "hiHaMes": true}}POST /v1/notificacions/lectura HTTP/1.1
{"fins": "not_55"} # marca com a llegides fins a un punt, idempotentL'històric es pagina per cursor (mateix mecanisme que la línia de temps) i el canal en directe només transporta avisos que hi ha alguna cosa nova, no el contingut complet: el client rep el senyal i recarrega per REST. Així el canal en temps real no es converteix en una segona API per mantenir en paral·lel.
| Mecanisme | Direcció | Quan triar-lo a CafeSocial | Cost |
|---|---|---|---|
Sondeig amb ETag/304 |
Client→servidor | Reserva quan falla tota la resta; dades poc urgents | Peticions constants |
| Sondeig llarg | Client→servidor | Compatibilitat amb xarxes hostils | Connexions retingudes |
| SSE | Servidor→client | Comptador de no llegides, publicacions noves: és el que fa servir la web | Unidireccional; reconnexió automàtica i Last-Event-ID de sèrie |
| WebSockets | Bidireccional | Missatges directes amb "escrivint…" i confirmació de lectura | Infraestructura pròpia, estat per connexió, més difícil d'escalar |
| Webhooks (01-07) | Servidor→servidor | Integracions: avisar la Botiga Aroma d'un tast amb nota alta | Reintents, signatura HMAC, lliurament "com a mínim una vegada" |
| Notificacions push (APNs/FCM) | Servidor→dispositiu | App tancada o en segon pla | Dependència de plataformes externes |
CafeSocial fa servir SSE per a la web i el comptador de notificacions, WebSockets només a la pantalla de missatges directes, push quan l'app no està activa i webhooks signats amb HMAC-SHA256 cap a la Botiga Aroma, reutilitzant exactament el mecanisme de 06-01.
- Botiga Aroma davant de CafeSocial, decisió a decisió
Aquesta és la taula que dona sentit a les dues lliçons juntes.
| Decisió | Botiga Aroma | CafeSocial | Què ho canvia |
|---|---|---|---|
| Proporció lectura/escriptura | ~10:1 | ~500:1 | Justifica precalcular i desnormalitzar |
| Paginació | limit/desplacament als cafès; cursor a les comandes |
Cursor obligatori a tot flux | El conjunt canvia entre peticions |
total a les col·leccions |
Sí, útil i barat | No existeix als fluxos | No hi ha conjunt tancat per comptar |
| Consistència | Forta i transaccional (estoc, pagaments) | Eventual i documentada | No hi ha diners a la petició |
| Comptadors | Exactes per definició | Aproximats per disseny | Cost de comptar davant del valor de l'exactitud |
| Autorització | "de qui és?" → 200/403 |
"qui pregunta?" → projeccions i 404 |
L'existència del recurs és informació |
| Forma de la resposta | Estable per a tothom | Variable segons l'observador | Privadesa i bloquejos |
| Memòria cau | public amb ETag al catàleg |
Gairebé tot private + Vary |
Depèn de l'observador |
| Escriptures | Síncrones, amb concurrència optimista | Asíncrones, cua i 202 |
El fan-out no cap a la petició |
| Cost del fan-out | Inexistent | Dominant: híbrid push/pull | La distribució de seguidors té cua llarga |
| Fitxers | Sense pujades rellevants | URL presignada, fora de l'API | Volum i temps d'ocupació |
| Temps real | SSE com a extra del tauler | SSE + WebSockets com a producte | La immediatesa és el valor percebut |
| Moderació | Ressenyes, volum baix, revisió manual | Dues etapes, cua dedicada, denúncies | 40.000 elements diaris |
| Relacions | Contenció simple (comanda→client) | Graf: l'aresta és recurs amb PUT/DELETE |
180 M d'arestes amb semàntica pròpia |
| Idempotència | Idempotency-Key als pagaments |
PUT idempotent per disseny al graf |
Reintents en xarxes mòbils |
La conclusió no és que un disseny sigui millor. És que no existeix un disseny REST universal: existeixen decisions dependents del domini, i la competència professional consisteix a saber quina s'aplica i poder justificar-la. Si algú et proposa "la manera correcta de paginar" sense preguntar pel domini, t'està venent una resposta abans d'haver escoltat la pregunta.
- Per què aquí GraphQL sí que és defensable
A 01-07 vam concloure que per a la Botiga Aroma GraphQL hauria estat complexitat sense retorn: pocs tipus de pantalla, alt valor de la memòria cau HTTP al catàleg, un consumidor principal molt estable. A CafeSocial els arguments canvien de signe:
- Pantalles amb dades heterogènies. El detall d'una publicació necessita publicació, autor, si el segueixo, els primers cinc comentaris amb els seus autors, comptador de "m'agrada", si jo hi he donat "m'agrada", etiquetes i cafè enllaçat del catàleg. En REST són 5-7 peticions, o un endpoint compost fet a mida que envelleix malament.
- Clients mòbils amb amplada de banda limitada. La llista demana 6 camps per publicació; el detall, 30. Amb REST s'acaba inventant
?camps=o?vista=resum, que és GraphQL mal fet. - Evolució ràpida del client. Un redisseny de la línia de temps cada trimestre implica, en REST, negociar canvis de contracte amb el backend cada trimestre.
- Un graf es consulta com un graf. "Els últims comentaris de la gent que segueixo en publicacions que també van agradar als meus seguidors" és una consulta natural en GraphQL i un endpoint retorçat en REST.
I el que es perd, que cal posar a la mateixa balança:
| Es guanya | Es perd |
|---|---|
| Una petició per pantalla | La memòria cau HTTP intermèdia: tot és POST /graphql |
| El client tria els camps | ETag/304 i CDN deixen de servir |
| Evolució sense versionar rutes | Codis d'estat: gairebé tot és 200 amb errors |
| Esquema tipat i autodocumentat | El client pot construir consultes caríssimes: calen límits de profunditat, complexitat i consultes persistides |
| Un únic punt d'entrada | Observabilitat i rate limiting per endpoint deixen de funcionar tal com són |
| — | Problema N+1 als resolutors: obliga a DataLoader des del primer dia |
La decisió realista de CafeSocial —i la que veureu a moltes empreses— és híbrida: GraphQL per a les pantalles de l'app mòbil, REST per al que és públic i indexable (perfils i publicacions cacheables al CDN), per a les integracions amb tercers i per als webhooks. Triar GraphQL no és abandonar el que has après: els recursos, els estats, la idempotència, la paginació per cursor i l'autorització per observador continuen sent exactament els mateixos problemes, només canvia la capa de transport.
Errors Comuns i Consells
- Fer servir
POST /seguirperquè "és una acció". Perds idempotència, consulta i esborrat gratuïts. Si l'acció crea o destrueix una relació entre dues entitats identificables, aquesta relació té URI:PUT/DELETE(02-03). - Ficar informació d'autorització al cursor. Un cursor amb
usuariIda dins és una escalada de privilegis esperant a passar. El subjecte surt sempre del token. - Confondre opac amb segur. Base64url no xifra res. L'opacitat és llibertat per canviar el format, no protecció: valida sempre el contingut descodificat.
- Retornar
totalen un flux. Obliga a unCOUNTcar sobre un conjunt que canvia, i el número resultant és fals tan bon punt s'envia. Fes servirhiHaMes. - Filtrar per visibilitat després de paginar. En demanes 20, n'amagues 6 i en retornes 14: el client creu que està arribant al final. La visibilitat va a la consulta.
- Llista negra de camps a les projeccions. El dia que afegeixis
correuRecuperacioa l'entitat, es publicarà tot sol. Llista blanca sempre. 403on l'existència ja filtra informació. I compte també amb els temps de resposta i els missatges d'error, que filtren per canals laterals.- Posar a la memòria cau com a
publicuna resposta que depèn de l'observador. És la via més ràpida perquè un proxy serveixi el perfil privat d'un usuari a un altre. Davant del dubte,private. - Pujar fitxers grans a través de l'API. Ocupa processos, xoca amb els temps d'espera dels proxies i no escala. URL presignada i confirmació posterior.
- Fiar-te del
Content-Typedeclarat pel client. Verifica els bytes reals, reprocessa la imatge i elimina les metadades EXIF abans de publicar-la. - Dissenyar el fan-out amb un sol algorisme. El push pur mor amb els comptes molt seguits; el pull pur mor amb la latència. L'híbrid és lleig i és el que funciona.
- Consell de procés: escriu la taula comparativa de l'apartat 8 abans de programar. Obliga a justificar cada decisió davant d'una alternativa concreta i és la millor defensa contra copiar el disseny de l'últim projecte per inèrcia.
Exercicis
Exercici 1 — El graf com a recurs. Dissenya els endpoints per "silenciar" un usuari que segueixes (deixes de veure les seves publicacions a la teva línia de temps, però continues sent-ne seguidor). Indica mètode, URI, codis d'estat i si necessita cos. Justifica per què no ho modeles com a POST /v1/silenciar.
Exercici 2 — Cursor a prova de manipulació. Un client envia ?cursor=eyJ2IjoidjEiLCJ0IjoiMjAzMC0wMS0wMVQwMDowMDowMFoiLCJpIjoicHViXzk5OTk5In0 (una data futura). Explica què retorna el sistema, per què això no és una fallada de seguretat, i afegeix a descodificarCursor la validació que hi falta.
Exercici 3 — Projecció i paginació juntes. GET /v1/usuaris/usr_77/seguidors?limit=20 ha d'amagar els usuaris que han bloquejat l'observador. Explica per què filtrar en memòria trenca la paginació i esbossa la consulta SQL correcta amb cursor.
Solucions
Solució 1. Silenciar és un atribut d'una relació existent, no una relació nova:
PATCH /v1/usuaris/usr_10/seguint/usr_77
Content-Type: application/json
If-Match: "w/rel-usr10-usr77-3"
{"silenciat": true}
HTTP/1.1 200 OK
ETag: "w/rel-usr10-usr77-4"
{"usuariId":"usr_10","seguitId":"usr_77","silenciat":true,"creatEl":"2026-03-04T10:22:11Z"}Codis: 200 correcte; 404 si no segueixes aquest usuari (no hi ha cap relació per modificar); 412 si l'If-Match no coincideix; 422/400 amb dades_invalides si el cos no valida. No es fa servir POST /v1/silenciar perquè la relació ja té URI: crear un verb paral·lel duplicaria el recurs, perdria la idempotència del PATCH sobre un estat concret i obligaria a inventar POST /v1/dessilenciar. Alternativa igual de vàlida: PUT sobre la relació completa amb tots els seus atributs, si prefereixes evitar el PATCH.
Solució 2. El sistema descodifica correctament el JSON (v vàlid, t i i presents), la comprovació d'antiguitat no salta perquè la data és futura, i la consulta WHERE (creat_el, id) < ('2030-01-01', 'pub_99999') retorna simplement la primera pàgina, ja que tot és anterior a aquesta data. No és una fallada de seguretat perquè l'usuariId del WHERE prové del token, no del cursor: manipular-lo només permet reposicionar-se dins de la pròpia bústia, cosa que l'usuari ja pot fer paginant. Tot i així convé rebutjar-ho per detectar clients trencats:
const t = Date.parse(dades.t);
if (Number.isNaN(t)) {
throw new ErrorApi(400, 'cursor_invalid', 'El cursor no és vàlid.');
}
if (t > Date.now() + 60_000) { // marge d'1 min per desfasament de rellotges
throw new ErrorApi(400, 'cursor_invalid', 'El cursor no és vàlid.');
}
if (typeof dades.i !== 'string' || !/^pub_[0-9]+$/.test(dades.i)) {
throw new ErrorApi(400, 'cursor_invalid', 'El cursor no és vàlid.');
}Solució 3. Filtrar en memòria trenca la paginació perquè el LIMIT s'aplica abans del filtre: demanes 20 files, en descartes 6 d'usuaris que t'han bloquejat i en retornes 14, mentre el cursor avança com si n'haguessis lliurat 20. El client veu pàgines irregulars i, si una pàgina sencera queda buida, interpreta que s'ha acabat el contingut. El filtre ha d'anar a la consulta:
SELECT s.seguidor_id, s.creat_el
FROM seguiments s
WHERE s.seguit_id = :perfilId
AND NOT EXISTS (
SELECT 1 FROM bloquejos b
WHERE b.bloquejador_id = s.seguidor_id
AND b.bloquejat_id = :observadorId )
AND (s.creat_el, s.seguidor_id) < (:cursorData, :cursorId)
ORDER BY s.creat_el DESC, s.seguidor_id DESC
LIMIT :limit + 1;Conseqüència que cal documentar: el nombre d'elements recorreguts pot no coincidir amb el comptador seguidors del perfil, perquè aquell comptador és global i la llista és relativa a l'observador. I com que la resposta depèn de l'observador, és private amb Vary: Authorization.
Conclusió
Hem dissenyat CafeSocial amb les mateixes eines que la Botiga Aroma —recursos, mètodes, codis d'estat, capçaleres, hipermèdia— i hem obtingut un disseny gairebé oposat. La relació de seguiment va deixar de ser un camp per convertir-se en un recurs amb PUT idempotent i DELETE. La línia de temps va deixar de ser una col·lecció per ser un recurs derivat sense POST, sense total i sense pàgina N, sostingut per un fan-out híbrid i accessible només mitjançant un cursor opac de marca temporal més identificador. Els comptadors van deixar de ser exactes perquè comptar va deixar de valer la pena. Les imatges van sortir de l'API. L'autorització va deixar de preguntar de qui és el recurs per preguntar qui observa, amb projeccions per visibilitat i 404 allà on l'existència ja és informació. I el temps real va deixar de ser un ornament per ser el producte.
Si aquesta lliçó deixa una sola idea, que sigui la de la taula de l'apartat 8: no hi ha un disseny REST universal, hi ha decisions dependents del domini, i un professional es distingeix per poder anomenar l'alternativa que va descartar i el motiu. Aquesta és també la raó per la qual GraphQL, indefensable per al catàleg de la botiga, aquí és una opció seriosa, sempre que se n'accepti el preu: perdre la memòria cau HTTP, els codis d'estat i el control del cost de les consultes.
Ens queda un últim assumpte, i és el que separa una API ben dissenyada d'una API que continua viva al cap de tres anys: què passa després de publicar-la. A 06-03, Evolució i manteniment d'una API en producció, veurem què fer quan un canvi aparentment innocu trenca un consumidor en producció, com s'escriu un post-mortem que serveixi d'alguna cosa, com s'acumula i es paga el deute de contracte, i com es planifica una migració a v2 amb el seu període de deprecació i el seu govern. Perquè dissenyar bé una API és difícil, però canviar-la sense trencar qui ja la fa servir és encara més difícil.
Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful
Mòdul 1: Introducció a les APIs RESTful
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
