En la lección anterior, 06-01, cerramos la memoria técnica de la API de Tienda Aroma con una lista de certezas: paginación por desplazamiento, contadores exactos, autorización basada en la propiedad del recurso, colecciones envueltas en datos con su total, y tiempo real como accesorio del panel interno. Todas esas decisiones eran correctas para aquel dominio. Ahora cambiamos de dominio sin cambiar de estilo arquitectónico: diseñamos CafeSocial, la red de catadores que Tienda Aroma quiere lanzar, y una a una vamos a ver caer esas certezas. No porque estuvieran mal, sino porque estaban atadas a un contexto que ya no existe. Ese contraste —la misma disciplina REST produciendo diseños opuestos— es el verdadero contenido de esta lección.

Contenido

  1. El escenario: CafeSocial, requisitos y consumidores
  2. Modelado del grafo de relaciones con REST
  3. La línea de tiempo: fan-out, recurso derivado y cursor opaco
  4. Volumen y escala: contadores aproximados, caché y colas
  5. Contenido generado por usuarios: subida, moderación y denuncias
  6. Privacidad y autorización a nivel de recurso
  7. Notificaciones y tiempo real
  8. Tienda Aroma frente a CafeSocial, decisión a decisión
  9. Por qué aquí GraphQL sí es defendible
  10. Errores comunes y consejos
  11. Ejercicios y soluciones

  1. El escenario: CafeSocial, requisitos y consumidores

CafeSocial es una red social vertical de catadores. Un usuario publica una cata: una foto del café, la variedad, el origen, el método de preparación y una nota del 0 al 100. Otros usuarios comentan, dan "me gusta", siguen a los catadores que les interesan y reciben una línea de tiempo con las publicaciones de la gente a la que siguen. Hay hashtags (#geisha, #v60), notificaciones y mensajes directos.

Los identificadores mantienen la convención del curso: usr_10, usr_77, pub_2100, com_310, not_55. El JSON sigue siendo camelCase, los errores siguen el catálogo de {"error": {"codigo","mensaje","detalles":[]}} y la versión sigue en la ruta (/v1). Cambia el dominio, no las convenciones: eso es justamente lo que permite comparar.

Requisitos que condicionan el diseño

Requisito Cifra objetivo (ficticia) Consecuencia de diseño
Proporción lectura/escritura ~500:1 Todo se optimiza para lectura; la escritura puede ser más cara
Latencia de la línea de tiempo p95 < 150 ms Precalcular, no calcular en la petición
Tamaño del grafo 2 M usuarios, 180 M aristas de seguimiento La relación es un recurso de primer nivel, no un campo
Distribución de seguidores Cola larga: el 0,01 % supera 100 000 seguidores Un único algoritmo de reparto no sirve
Contenido de usuarios 40 000 publicaciones con foto al día Moderación y almacenamiento fuera de la API
Consistencia Eventual aceptable en línea de tiempo y contadores Se puede desacoplar con colas

Consumidores

  • App móvil (iOS/Android): el consumidor principal, con ancho de banda y batería limitados. Le duele cada byte y cada ida y vuelta.
  • Web pública: perfiles y publicaciones indexables por buscadores cuando son públicos.
  • Panel de moderación interno: pocos usuarios, permisos amplios, necesita colas de trabajo.
  • Integración con Tienda Aroma: cuando una publicación menciona un café del catálogo, la ficha enlaza a /v1/cafes/caf_001 de la API de la tienda. Son dos APIs distintas que se enlazan por hipermedia, no una sola.

A diferencia de la tienda, aquí no hay dinero en la petición. Nada exige la exactitud transaccional que en 06-01 nos obligó a resolver la sobreventa dentro del UPDATE. Esa libertad es la que hace posible casi todo lo que viene a continuación.


  1. Modelado del grafo de relaciones con REST

En Tienda Aroma casi todo era una relación de contención simple: un pedido pertenece a un cliente, una línea pertenece a un pedido. Aquí la relación es la entidad interesante.

2.1 Las dos colecciones del grafo

GET /v1/usuarios/usr_10/seguidores?limite=20 HTTP/1.1
GET /v1/usuarios/usr_10/siguiendo?limite=20 HTTP/1.1

Son dos vistas de la misma arista, desde los dos extremos. Ninguna es "la buena": la app necesita las dos y con cardinalidades muy distintas (un usuario sigue a 300, pero puede tener 300 000 seguidores).

2.2 La relación como recurso: PUT frente a POST /seguir

La tentación es crear un verbo: POST /v1/seguir con {"seguidoId": "usr_77"}. Funciona, y es exactamente lo que en 02-02 llamábamos convertir una acción en recurso sin necesidad. La alternativa es tratar la arista como un recurso direccionable:

PUT /v1/usuarios/usr_10/siguiendo/usr_77 HTTP/1.1
Authorization: Bearer <token de usr_10>

HTTP/1.1 204 No Content
Aroma-Grafo-Estado: siguiendo
DELETE /v1/usuarios/usr_10/siguiendo/usr_77 HTTP/1.1

HTTP/1.1 204 No Content

Las ventajas, recuperando la tabla de métodos de 02-03:

Aspecto PUT /siguiendo/{id} POST /seguir
Idempotencia Sí: pulsar "seguir" cinco veces deja el mismo estado No garantizada; hay que deduplicar
Reintento tras timeout Seguro por definición Requiere Idempotency-Key
Consulta del estado GET del mismo URI (204/404) Hay que inventar otro endpoint
Deshacer DELETE del mismo URI Otro verbo: POST /dejar-de-seguir
Cacheable / enlazable Sí, tiene URI propia No

En una app móvil con red inestable, la idempotencia no es elegancia teórica: es la diferencia entre un botón que se queda "a medias" y uno que no. El cliente puede reintentar el PUT sin pensar.

GET /v1/usuarios/usr_10/siguiendo/usr_77 devuelve 204 si la relación existe y 404 si no, lo que da al cliente una comprobación barata para pintar el botón.

2.3 "Me gusta": ¿usuario en la ruta o en el token?

Las dos formas son legítimas y conviene entender el compromiso:

PUT /v1/publicaciones/pub_2100/megusta/usr_10     # A: sujeto explícito
PUT /v1/publicaciones/pub_2100/megusta            # B: sujeto implícito en el token
Criterio A (explícito) B (implícito)
URI autodescriptiva Sí No: el mismo URI significa cosas distintas según quién llame
Riesgo de suplantación Hay que validar que la ruta coincide con el token Imposible por construcción
Actuar en nombre de otro (admin, importación) Directo Necesita una cabecera o endpoint aparte
Listar quién dio "me gusta" GET /publicaciones/pub_2100/megusta natural Igual de natural
Caché Cacheable por URI Necesita Vary: Authorization

En CafeSocial elegimos A para el grafo de seguimiento y para "me gusta", por coherencia y porque el panel de moderación necesita poder retirar un "me gusta" de otro usuario. La regla que aplicamos: si algún consumidor legítimo puede actuar sobre la relación de un tercero, el sujeto va en la ruta. Cuando la ruta y el token no coinciden y el llamante no tiene el ámbito adecuado, respondemos 403 con permisos_insuficientes.

2.4 Cuándo la relación necesita cuerpo propio

Una arista con atributos deja de ser un simple "existe o no". Seguir a alguien puede tener preferencias asociadas:

PUT /v1/usuarios/usr_10/siguiendo/usr_77 HTTP/1.1
Content-Type: application/json

{"notificaciones": "silenciadas", "verRepublicaciones": false}
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "w/rel-usr10-usr77-3"

{
  "usuarioId": "usr_10",
  "seguidoId": "usr_77",
  "creadoEn": "2026-03-04T10:22:11Z",
  "notificaciones": "silenciadas",
  "verRepublicaciones": false,
  "_links": {
    "self":    {"href": "/v1/usuarios/usr_10/siguiendo/usr_77"},
    "seguido": {"href": "/v1/usuarios/usr_77"}
  }
}

Regla práctica: sin atributos, 204 y cuerpo vacío; con atributos, 200 y representación completa con ETag, y entonces las modificaciones parciales (PATCH) y la concurrencia optimista de 03-06 vuelven a aplicar. No conviene inventar atributos "por si acaso": una arista con cuerpo cuesta una fila más ancha multiplicada por 180 millones.


  1. La línea de tiempo: fan-out, recurso derivado y cursor opaco

3.1 El problema del fan-out

Cuando usr_77 publica, ¿quién paga el coste de que aparezca en la línea de tiempo de sus seguidores?

graph LR
  A[usr_77 publica pub_2100] --> B{Estrategia}
  B -->|Fan-out en escritura| C[Cola de reparto]
  C --> D[Insertar en 300.000 buzones]
  D --> E[GET /lineatiempo lee un buzon: rapido]
  B -->|Fan-out en lectura| F[Escritura barata: 1 fila]
  F --> G[GET /lineatiempo consulta a 300 seguidos y mezcla]
  G --> H[Latencia alta y variable]
Criterio Fan-out en escritura (push) Fan-out en lectura (pull)
Coste de publicar Alto: O(seguidores) Mínimo: O(1)
Coste de leer la línea Mínimo: lectura secuencial de un buzón Alto: O(seguidos), mezcla ordenada
Latencia p95 de lectura Baja y estable Alta y dependiente del usuario
Almacenamiento Enorme (duplicado por seguidor) Mínimo
Publicar con 1 M de seguidores Un millón de escrituras por publicación Sin coste extra
Borrar una publicación Hay que limpiar todos los buzones Desaparece sola
Encaja con Mayoría de usuarios normales Cuentas muy seguidas

Con 500 lecturas por cada escritura, el fan-out en escritura gana casi siempre. El problema es la cola larga: si usr_77 es un catador famoso con un millón de seguidores, cada publicación suya dispara un millón de inserciones y la cola se atasca durante minutos.

Solución híbrida, que es la que adopta CafeSocial:

  1. Los usuarios con menos de 10 000 seguidores usan push: al publicar, una tarea asíncrona inserta la referencia en el buzón de cada seguidor.
  2. Los usuarios por encima de ese umbral se marcan como cuentas de alcance y usan pull: no reparten nada.
  3. GET /v1/lineatiempo lee el buzón del usuario y mezcla en el momento las publicaciones recientes de las pocas cuentas de alcance que sigue (normalmente menos de 20), ordenando por marca temporal.

Es más código y más complejidad, pero es el único diseño que sobrevive a las dos puntas de la distribución.

3.2 /lineatiempo no es una colección

En Tienda Aroma, /cafes era una colección: se podía crear (POST), contar (total), filtrar y saltar a la página 7. La línea de tiempo no es nada de eso: es un recurso derivado, una vista calculada que solo existe para un usuario y en un instante.

Propiedad de una colección normal En /lineatiempo
POST para crear un elemento No existe: se publica en /publicaciones, no en la línea
total estable Imposible: no hay un conjunto cerrado que contar
Saltar a la página N No tiene sentido: la página 7 de hace 3 s ya no es la misma
Filtros arbitrarios Muy limitados (?soloConFoto=true), no es un buscador
DELETE de un elemento No: se oculta o se deja de seguir al autor

Dicho de otro modo: /lineatiempo responde "¿qué hay de nuevo para mí?", no "¿qué elementos contiene este conjunto?". Igual que en 02-06 distinguíamos búsqueda de listado, aquí distinguimos flujo de colección.

3.3 Por qué la paginación por desplazamiento no sirve

Recuperemos el mecanismo de 02-06: ?limite=20&desplazamiento=20 traduce a LIMIT 20 OFFSET 20. Eso presupone que el conjunto ordenado no cambia entre peticiones. En una línea de tiempo ordenada por fecha descendente, cambia cada segundo.

Ejemplo numérico. La línea de usr_10 contiene, ordenadas de más nueva a más antigua, las publicaciones pub_2100 (posición 1) hasta pub_2001 (posición 100).

Caso de duplicados. El cliente pide la primera página:

GET /v1/lineatiempo?limite=20&desplazamiento=0
→ posiciones 1..20  = pub_2100 ... pub_2081

Mientras el usuario lee, llegan 3 publicaciones nuevas. Ahora todo se ha desplazado 3 posiciones. El cliente pide la segunda página:

GET /v1/lineatiempo?limite=20&desplazamiento=20
→ posiciones 21..40 del conjunto NUEVO = pub_2084 ... pub_2065

pub_2084, pub_2083 y pub_2082 ya se habían mostrado en la primera página. El usuario ve tres publicaciones repetidas.

Caso de saltos. Con el mismo estado inicial, entre la primera y la segunda petición se borran 3 publicaciones de las 20 primeras. Al pedir desplazamiento=20, las posiciones 21..40 del conjunto reducido corresponden a lo que antes eran las posiciones 24..43: pub_2078 y las dos anteriores nunca se muestran. El usuario no las verá jamás, y no hay ningún error visible que lo delate.

Añádase que OFFSET 200000 obliga al motor a recorrer y descartar 200 000 filas: el coste crece con la profundidad. Duplicados, huecos silenciosos y coste creciente: tres razones independientes, cada una suficiente.

3.4 El cursor opaco

En Tienda Aroma el cursor era una opción que aplicamos a /pedidos. Aquí es obligatorio. El cursor implementa paginación por clave (keyset): en vez de "sáltate 20 filas", dice "dame lo anterior a este punto exacto".

Contenido del cursor: marca temporal + id de desempate. La marca sola no basta porque dos publicaciones pueden compartir milisegundo; el id rompe el empate y garantiza un orden total.

// utilidades/cursor.js — codificación y decodificación del cursor opaco
const CLAVE_VERSION = 'v1';

function codificarCursor({ creadoEn, id }) {
  const carga = JSON.stringify({ v: CLAVE_VERSION, t: creadoEn, i: id });
  return Buffer.from(carga, 'utf8').toString('base64url');
}

function decodificarCursor(cursor) {
  let datos;
  try {
    datos = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
  } catch {
    throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
  }
  if (datos.v !== CLAVE_VERSION || !datos.t || !datos.i) {
    throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
  }
  // Límite de antigüedad: un cursor viejo apunta a un buzón ya recortado.
  const antiguedadDias = (Date.now() - Date.parse(datos.t)) / 86_400_000;
  if (antiguedadDias > 30) {
    throw new ErrorApi(410, 'cursor_caducado', 'Vuelve a empezar desde el principio.');
  }
  return { creadoEn: datos.t, id: datos.i };
}

La consulta correspondiente usa la comparación de tuplas, que aprovecha el índice compuesto (usuario_id, creado_en DESC, publicacion_id DESC):

-- Pedimos limite+1 filas para saber si hay página siguiente sin contar el total
SELECT p.publicacion_id, p.autor_id, p.creado_en
FROM buzon b
JOIN publicaciones p ON p.publicacion_id = b.publicacion_id
WHERE b.usuario_id = :usuarioId
  AND (p.creado_en, p.publicacion_id) < (:cursorFecha, :cursorId)
ORDER BY p.creado_en DESC, p.publicacion_id DESC
LIMIT :limite + 1;

La respuesta no lleva total —no existe— y expone el enlace siguiente tanto en el cuerpo como en la cabecera Link de RFC 8288, igual que en la tienda:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=0, must-revalidate
Vary: Authorization
Link: </v1/lineatiempo?limite=20&cursor=eyJ2IjoidjEiLCJ0IjoiMjAyNi0wOC0xNFQwOToxMjozMy40MTJaIiwiaSI6InB1Yl8yMDgxIn0>; rel="next"

{
  "datos": [
    {
      "id": "pub_2100",
      "autor": {"id": "usr_77", "alias": "catadora_geisha"},
      "nota": 92,
      "metodo": "v60",
      "megusta": 1240,
      "megustaAproximado": true,
      "_links": {"self": {"href": "/v1/publicaciones/pub_2100"}}
    }
  ],
  "paginacion": {
    "siguiente": "eyJ2IjoidjEiLCJ0IjoiMjAyNi0wOC0xNFQwOToxMjozMy40MTJaIiwiaSI6InB1Yl8yMDgxIn0",
    "hayMas": true
  }
}

Por qué opaco. El cursor es base64url de un JSON, no cifrado: cualquiera puede leerlo. La opacidad es un contrato, no una medida de seguridad: al no documentar su interior, podemos cambiar mañana de (fecha, id) a un identificador de posición en un índice distribuido sin romper a ningún cliente. Lo que sí garantizamos es la validación: si el cliente lo manipula, la decodificación falla (400 cursor_invalido) o los valores no superan la comprobación de tipo. Y como el cursor no contiene el identificador del usuario —ese sale siempre del token—, manipularlo no permite leer el buzón de otro. Ese punto es esencial: nunca metas en el cursor información de autorización.

Antigüedad máxima. Los buzones se recortan a las últimas 800 entradas y a 30 días. Un cursor más antiguo apunta a un vacío, así que respondemos 410 Gone con cursor_caducado y el cliente vuelve al principio, en lugar de devolver una lista vacía que la app interpretaría como "fin del contenido".


  1. Volumen y escala

4.1 Contadores aproximados a propósito

En Tienda Aroma, el stock tenía que ser exacto: de ahí salió la condición dentro del UPDATE que resolvía la sobreventa. Aquí, SELECT COUNT(*) FROM megusta WHERE publicacion_id = 'pub_2100' en cada lectura de la línea de tiempo significa 20 consultas de conteo por pantalla, multiplicadas por millones de pantallas.

La decisión: contador denormalizado, actualizado de forma asíncrona y declarado como aproximado.

// servicios/megusta.js
async function darMeGusta(publicacionId, usuarioId) {
  const creado = await repoMeGusta.insertarSiNoExiste(publicacionId, usuarioId);
  if (creado) {
    // El contador no se actualiza aquí: se agrega por lotes cada 5 segundos.
    await cola.publicar('contadores.megusta', { publicacionId, delta: 1 });
  }
  return creado; // permite responder 201 la primera vez y 204 en reintentos
}

Las consecuencias hay que asumirlas de forma explícita, no esconderlas:

  • La respuesta marca "megustaAproximado": true cuando el contador supera 1 000. Por debajo se recalcula en el momento y es exacto: los usuarios notan un desfase en 12 pero no en 12 480.
  • Un trabajo nocturno reconcilia los contadores con la tabla real.
  • El propio usuario siempre ve reflejado su propio "me gusta" al instante (lectura de tus propias escrituras), aunque el número global tarde: es lo que evita que la interfaz parezca rota.

4.2 Caché de perfiles y el problema de "depende de quién pregunta"

Retomando 04-06, el perfil es el candidato ideal a caché: se lee constantemente y cambia poco.

GET /v1/usuarios/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: Authorization

Lo delicado es qué se puede cachear compartidamente:

Recurso Directiva Motivo
Perfil público, sin sesión public, max-age=300 Igual para todo el mundo
Perfil visto por un usuario autenticado private, max-age=60 + Vary: Authorization Incluye "¿le sigo?", "¿me bloqueó?"
Línea de tiempo private, max-age=0, must-revalidate Única por usuario y por instante
Imagen de publicación (CDN) public, max-age=31536000, immutable URL con huella de contenido

Vary: Authorization es correcto pero, en la práctica, destruye la caché compartida: cada token genera una entrada distinta. Por eso la estrategia real es partir la respuesta en dos: los datos objetivos del perfil (alias, biografía, foto) se sirven cacheables y públicos, y lo relativo al observador (siguiendoAEsteUsuario, meBloquea) se pide aparte o se marca private. Cachear la mezcla es el error clásico, y su versión más grave es un proxy devolviendo el perfil "visto por otro usuario" a quien no debe.

4.3 Colas, 202 y recursos de tarea

El reparto del fan-out y otras operaciones largas no caben en el ciclo de la petición. Igual que en 04-08 con los informes de la tienda:

POST /v1/usuarios/usr_10/exportacion HTTP/1.1

HTTP/1.1 202 Accepted
Location: /v1/tareas/tar_9001
Retry-After: 10

{"id": "tar_9001", "estado": "en_proceso",
 "_links": {"self": {"href": "/v1/tareas/tar_9001"}}}

Publicar es lo mismo pero al revés: POST /v1/publicaciones responde 201 inmediatamente con la publicación creada —el autor la ve ya en su perfil— mientras el reparto a los buzones ocurre por detrás. Es la consistencia eventual hecha contrato: un seguidor puede tardar unos segundos en verla, y eso está documentado.

4.4 El coste de la denormalización

Denormalizar no es gratis. En CafeSocial pagamos: almacenamiento multiplicado (una publicación aparece en cientos de miles de buzones), un camino de escritura más frágil (si la cola falla, hay buzones incompletos y hace falta un proceso de reparación), y dos fuentes de verdad que pueden divergir. La regla es que la fuente canónica sigue siendo /publicaciones/{id}; los buzones son caché reconstruible. Todo lo que se pueda regenerar desde la fuente canónica es aceptable denormalizarlo; lo que no, no.


  1. Contenido generado por usuarios

5.1 Subida de imágenes con URL prefirmada

La API no recibe los megabytes de la foto. Se emite una autorización de subida directa al almacenamiento de objetos:

sequenceDiagram
  participant App as App movil
  participant API as API CafeSocial
  participant Alm as Almacenamiento
  App->>API: POST /v1/publicaciones/pub_2100/imagenes {tipo, tamano}
  API-->>App: 201 {urlSubida, campos, expiraEn, imagenId}
  App->>Alm: PUT urlSubida (bytes de la foto)
  Alm-->>App: 200 OK
  App->>API: POST /v1/publicaciones/pub_2100/imagenes/img_44/confirmacion
  API->>Alm: Verificar tipo, tamano y cabecera del fichero
  API-->>App: 200 {estado: "pendiente_moderacion"}
POST /v1/publicaciones/pub_2100/imagenes HTTP/1.1
Content-Type: application/json

{"tipoContenido": "image/jpeg", "tamanoBytes": 2411520}
HTTP/1.1 201 Created
Location: /v1/publicaciones/pub_2100/imagenes/img_44

{
  "id": "img_44",
  "urlSubida": "https://almacen.cafesocial.example/subidas/img_44?firma=...",
  "metodo": "PUT",
  "expiraEn": "2026-08-14T09:27:00Z",
  "tamanoMaximoBytes": 5242880,
  "estado": "pendiente_subida"
}

Las razones para no pasar los bytes por la API son concretas:

  • Un proceso Node ocupado 8 segundos con una subida de 5 MB es un proceso que no atiende a nadie más.
  • El escalado del tráfico de subida se desacopla del escalado de la lógica de negocio.
  • El almacenamiento y el CDN ya resuelven reanudación, multiparte y distribución geográfica.
  • Los tiempos de espera de proxies y balanceadores dejan de ser un problema.

Validación. La URL prefirmada limita tipo y tamaño, pero eso no basta: el cliente declara image/jpeg y sube cualquier cosa. Por eso la confirmación es obligatoria y comprueba en servidor los bytes reales (números mágicos de la cabecera del fichero, dimensiones, tamaño), reprocesa la imagen a varios tamaños y descarta los metadatos EXIF —que incluyen coordenadas GPS: publicar la ubicación exacta de la casa de un usuario sería una fuga de datos personales. Una imagen sin confirmar en 15 minutos se borra y la publicación queda como borrador.

5.2 Moderación

El estado replica el de las reseñas de Tienda Aroma: pendiente_moderacion | publicada | rechazada. Lo que cambia es la escala, que obliga a dos etapas:

  1. Automática, en el momento: clasificador de imagen y texto. Puntuación baja, publicación directa; puntuación media, cola humana; puntuación alta, rechazo inmediato con posibilidad de recurso.
  2. Humana, sobre una cola de trabajo:
GET /v1/moderacion/pendientes?limite=50&cursor=... HTTP/1.1
Authorization: Bearer <token con ambito moderacion:leer>

POST /v1/moderacion/publicaciones/pub_2100/aprobacion HTTP/1.1
POST /v1/moderacion/publicaciones/pub_2100/rechazo HTTP/1.1
Content-Type: application/json

{"motivo": "contenido_no_relacionado", "notificarAutor": true}

Detalles de diseño que importan:

  • La cola de moderación vive bajo /moderacion/*, un espacio de nombres propio con sus ámbitos (moderacion:leer, moderacion:escribir). Mezclarla con /publicaciones obligaría a que el mismo endpoint se comportara de forma radicalmente distinta según el ámbito, que es justo lo que hace las pruebas ingobernables.
  • Aprobación y rechazo son recursos-acción (POST a un subrecurso) porque no son idempotentes en sus efectos: disparan notificaciones y quedan auditados. Cada decisión se guarda con moderador, marca temporal y motivo.
  • La cola se pagina por cursor: crece y se consume al mismo tiempo.

5.3 Denuncias

POST /v1/publicaciones/pub_2100/denuncias HTTP/1.1

{"motivo": "spam", "comentario": "Publica el mismo enlace en todas las catas"}
HTTP/1.1 202 Accepted
{"estado": "recibida", "_links": {"self": {"href": "/v1/denuncias/den_77"}}}

Se responde 202 y no 201 con el veredicto: la denuncia se acepta, no se resuelve. El denunciante no debe poder consultar el estado detallado ni la identidad del moderador, y el denunciado no debe conocer al denunciante. Varias denuncias sobre la misma publicación se agregan en un único caso.

Advertencia imprescindible. Todo lo anterior es la parte de ingeniería, que es la fácil. Un servicio real con contenido de usuarios tiene obligaciones legales que no son decisiones técnicas: bases de licitud y derechos RGPD, plazos de retirada de contenido ilícito, protección de menores y verificación de edad, retención y entrega de datos a autoridades, transparencia de la moderación y vías de recurso. Cambian por país y con el tiempo. Diséñalo con asesoría legal y de compliance desde el principio; un endpoint de borrado que no cumple el plazo legal es un problema jurídico, no un TODO del backlog.


  1. Privacidad y autorización a nivel de recurso

En Tienda Aroma la pregunta era "¿de quién es esto?": el pedido ped_5001 es de cli_842, luego solo cli_842 y los administradores lo ven. Binario y sencillo.

En CafeSocial la pregunta es "¿quién pregunta y qué le dejamos ver?", y la respuesta ya no es sí o no, sino una representación distinta:

Quién pregunta por usr_77 (perfil privado) Qué recibe
El propio usr_77 Todo, incluidos borradores y correo
Un seguidor aceptado Perfil completo y publicaciones
Un usuario autenticado que no le sigue Alias, foto, biografía, contadores; sin publicaciones
Un usuario bloqueado por usr_77 404 usuario_no_encontrado
Sin autenticar 404 si el perfil es privado; ficha reducida si es público

6.1 Proyección por visibilidad

El patrón: la capa de servicio devuelve la entidad completa y una proyección decide qué campos sobreviven según la relación entre observador y observado.

// presentacion/proyecciones/usuario.js
const CAMPOS = {
  propietario: ['id','alias','nombre','bio','foto','correo','seguidores','siguiendo','privado'],
  seguidor:    ['id','alias','nombre','bio','foto','seguidores','siguiendo','privado'],
  publico:     ['id','alias','foto','bio','seguidores','privado'],
};

function proyectarUsuario(usuario, contexto) {
  const nivel = calcularNivelVisibilidad(usuario, contexto); // propietario|seguidor|publico|oculto
  if (nivel === 'oculto') throw new ErrorApi(404, 'usuario_no_encontrado', 'No encontrado.');
  const salida = {};
  for (const campo of CAMPOS[nivel]) salida[campo] = usuario[campo];
  salida._links = enlacesSegunNivel(usuario, nivel);
  return salida;
}

Tres reglas que evitan los fallos habituales:

  1. Lista blanca, nunca lista negra. Un campo nuevo en la entidad no debe filtrarse solo por haberse añadido.
  2. La proyección se aplica también dentro de las listas. GET /usuarios/usr_77/seguidores recorta a los usuarios bloqueados y a los privados: la lista devuelta puede ser más corta que el contador que muestra el perfil, y eso es correcto.
  3. La visibilidad se decide en la consulta, no después. Filtrar en memoria tras traer 50 filas rompe la paginación por cursor: pedirías 20 y devolverías 14.

6.2 404 en vez de 403

Si usr_77 bloquea a usr_10 y este pide GET /v1/usuarios/usr_77, un 403 permisos_insuficientes sería técnicamente honesto y filtraría información: confirmaría que la cuenta existe, y por diferencia de respuestas se podría enumerar quién ha bloqueado a quién o descubrir qué alias están registrados. Cuando la mera existencia del recurso es información sensible, se responde 404.

El criterio general: 403 cuando el recurso es conocido por el llamante o su existencia no revela nada (el moderador que no tiene el ámbito adecuado); 404 cuando revelar la existencia ya es una fuga. Y hay que ser consistente en tiempos de respuesta: un 404 que tarda 5 ms cuando el recurso no existe y 40 ms cuando existe pero está bloqueado vuelve a filtrar la información por un canal lateral.

6.3 Efecto sobre caché y pruebas

  • Caché: cualquier respuesta cuya forma dependa del observador es private y con Vary: Authorization. Una CDN compartida solo puede servir lo objetivamente público. Esto reduce el rendimiento y es un coste asumido conscientemente.
  • Pruebas: la matriz crece de golpe. Cada endpoint sensible necesita casos para propietario, seguidor, no seguidor, bloqueado y anónimo. En CafeSocial se resuelve con una tabla de casos parametrizada:
describe('GET /v1/usuarios/usr_77 según observador', () => {
  const casos = [
    { observador: 'propietario', estado: 200, incluye: ['correo'],  excluye: [] },
    { observador: 'seguidor',    estado: 200, incluye: ['nombre'],  excluye: ['correo'] },
    { observador: 'extrano',     estado: 200, incluye: ['alias'],   excluye: ['nombre','correo'] },
    { observador: 'bloqueado',   estado: 404, incluye: [],          excluye: [] },
  ];
  for (const c of casos) it(`observador ${c.observador} → ${c.estado}`, async () => { /* ... */ });
});

  1. Notificaciones y tiempo real

En Tienda Aroma, SSE era un extra del panel interno. Aquí, la inmediatez es el producto: una red social en la que la notificación llega dos minutos tarde se percibe como rota.

REST puro obligaría al sondeo: la app pregunta GET /v1/notificaciones cada 10 segundos. Con 500 000 usuarios activos son 50 000 peticiones por segundo, de las cuales el 99 % devuelven lo mismo. Ni con ETag y 304 (que ahorran ancho de banda, no peticiones) sale la cuenta.

El diseño combina tres piezas:

GET /v1/notificaciones?limite=30&cursor=... HTTP/1.1

{"datos": [
   {"id": "not_55", "tipo": "megusta", "leida": false,
    "actor": {"id": "usr_77", "alias": "catadora_geisha"},
    "recurso": {"href": "/v1/publicaciones/pub_2100"},
    "creadaEn": "2026-08-14T09:12:33Z"}
 ],
 "noLeidas": 4,
 "paginacion": {"siguiente": "eyJ2IjoidjEi...", "hayMas": true}}
POST /v1/notificaciones/lectura HTTP/1.1
{"hasta": "not_55"}          # marca como leídas hasta un punto, idempotente

El histórico se pagina por cursor (mismo mecanismo que la línea de tiempo) y el canal en vivo solo transporta avisos de que hay algo nuevo, no el contenido completo: el cliente recibe la señal y recarga por REST. Así el canal en tiempo real no se convierte en una segunda API que mantener en paralelo.

Mecanismo Dirección Cuándo elegirlo en CafeSocial Coste
Sondeo con ETag/304 Cliente→servidor Respaldo cuando falla todo lo demás; datos poco urgentes Peticiones constantes
Sondeo largo Cliente→servidor Compatibilidad con redes hostiles Conexiones retenidas
SSE Servidor→cliente Contador de no leídas, publicaciones nuevas: es lo que usa la web Unidireccional; reconexión automática y Last-Event-ID de serie
WebSockets Bidireccional Mensajes directos con "escribiendo…" y confirmación de lectura Infraestructura propia, estado por conexión, más difícil de escalar
Webhooks (01-07) Servidor→servidor Integraciones: avisar a Tienda Aroma de una cata con nota alta Reintentos, firma HMAC, entrega "al menos una vez"
Notificaciones push (APNs/FCM) Servidor→dispositivo App cerrada o en segundo plano Dependencia de plataformas externas

CafeSocial usa SSE para la web y el contador de notificaciones, WebSockets solo en la pantalla de mensajes directos, push cuando la app no está activa y webhooks firmados con HMAC-SHA256 hacia Tienda Aroma, reutilizando exactamente el mecanismo de 06-01.


  1. Tienda Aroma frente a CafeSocial, decisión a decisión

Esta es la tabla que da sentido a las dos lecciones juntas.

Decisión Tienda Aroma CafeSocial Qué lo cambia
Proporción lectura/escritura ~10:1 ~500:1 Justifica precalcular y denormalizar
Paginación limite/desplazamiento en cafés; cursor en pedidos Cursor obligatorio en todo flujo El conjunto cambia entre peticiones
total en colecciones Sí, útil y barato No existe en flujos No hay conjunto cerrado que contar
Consistencia Fuerte y transaccional (stock, pagos) Eventual y documentada No hay dinero en la petición
Contadores Exactos por definición Aproximados por diseño Coste de contar frente a valor de la exactitud
Autorización "¿de quién es?" → 200/403 "¿quién pregunta?" → proyecciones y 404 La existencia del recurso es información
Forma de la respuesta Estable para todos Variable según observador Privacidad y bloqueos
Caché public con ETag en catálogo Casi todo private + Vary Depende del observador
Escrituras Sincrónicas, con concurrencia optimista Asíncronas, cola y 202 El fan-out no cabe en la petición
Coste del fan-out Inexistente Dominante: híbrido push/pull La distribución de seguidores tiene cola larga
Ficheros Sin subidas relevantes URL prefirmada, fuera de la API Volumen y tiempo de ocupación
Tiempo real SSE como extra del panel SSE + WebSockets como producto La inmediatez es el valor percibido
Moderación Reseñas, volumen bajo, revisión manual Dos etapas, cola dedicada, denuncias 40 000 elementos diarios
Relaciones Contención simple (pedido→cliente) Grafo: la arista es recurso con PUT/DELETE 180 M de aristas con semántica propia
Idempotencia Idempotency-Key en pagos PUT idempotente por diseño en el grafo Reintentos en redes móviles

La conclusión no es que un diseño sea mejor. Es que no existe un diseño REST universal: existen decisiones dependientes del dominio, y la competencia profesional consiste en saber cuál aplica y poder justificarlo. Si alguien te propone "la forma correcta de paginar" sin preguntar por el dominio, te está vendiendo una respuesta antes de haber oído la pregunta.


  1. Por qué aquí GraphQL sí es defendible

En 01-07 concluimos que para Tienda Aroma GraphQL habría sido complejidad sin retorno: pocos tipos de pantalla, alto valor de la caché HTTP en el catálogo, un consumidor principal muy estable. En CafeSocial los argumentos cambian de signo:

  • Pantallas con datos heterogéneos. El detalle de una publicación necesita publicación, autor, si lo sigo, primeros cinco comentarios con sus autores, contador de "me gusta", si yo di "me gusta", hashtags y café enlazado del catálogo. En REST son 5-7 peticiones, o un endpoint compuesto a medida que envejece mal.
  • Clientes móviles con ancho de banda limitado. La lista pide 6 campos por publicación; el detalle, 30. Con REST se acaba inventando ?campos= o ?vista=resumen, que es GraphQL mal hecho.
  • Evolución rápida del cliente. Un rediseño de la línea de tiempo cada trimestre implica, en REST, negociar cambios de contrato con el backend cada trimestre.
  • Un grafo se consulta como un grafo. "Los últimos comentarios de la gente a la que sigo en publicaciones que también les gustaron a mis seguidores" es una consulta natural en GraphQL y un endpoint retorcido en REST.

Y lo que se pierde, que hay que poner en la misma balanza:

Se gana Se pierde
Una petición por pantalla La caché HTTP intermedia: todo es POST /graphql
El cliente elige los campos ETag/304 y CDN dejan de servir
Evolución sin versionar rutas Códigos de estado: casi todo es 200 con errors
Esquema tipado y autodocumentado El cliente puede construir consultas carísimas: hacen falta límites de profundidad, complejidad y consultas persistidas
Un único punto de entrada Observabilidad y rate limiting por endpoint dejan de funcionar tal cual
— Problema N+1 en los resolutores: obliga a DataLoader desde el primer día

La decisión realista de CafeSocial —y la que veréis en muchas empresas— es híbrida: GraphQL para las pantallas de la app móvil, REST para lo público e indexable (perfiles y publicaciones cacheables en CDN), para las integraciones con terceros y para los webhooks. Elegir GraphQL no es abandonar lo aprendido: los recursos, los estados, la idempotencia, la paginación por cursor y la autorización por observador siguen siendo exactamente los mismos problemas, solo cambia la capa de transporte.


Errores Comunes y Consejos

  1. Usar POST /seguir porque "es una acción". Pierdes idempotencia, consulta y borrado gratuitos. Si la acción crea o destruye una relación entre dos entidades identificables, esa relación tiene URI: PUT/DELETE (02-03).
  2. Meter información de autorización en el cursor. Un cursor con usuarioId dentro es una escalada de privilegios esperando a ocurrir. El sujeto sale siempre del token.
  3. Confundir opaco con seguro. Base64url no cifra nada. La opacidad es libertad para cambiar el formato, no protección: valida siempre el contenido decodificado.
  4. Devolver total en un flujo. Obliga a un COUNT caro sobre un conjunto que cambia, y el número resultante es falso en cuanto se envía. Usa hayMas.
  5. Filtrar por visibilidad después de paginar. Pides 20, ocultas 6 y devuelves 14: el cliente cree que está llegando al final. La visibilidad va en la consulta.
  6. Lista negra de campos en las proyecciones. El día que añadas correoRecuperacion a la entidad, se publicará solo. Lista blanca siempre.
  7. 403 donde la existencia ya filtra información. Y ojo también con los tiempos de respuesta y los mensajes de error, que filtran por canales laterales.
  8. Cachear como public una respuesta que depende del observador. Es la vía más rápida a que un proxy sirva el perfil privado de un usuario a otro. Ante la duda, private.
  9. Subir ficheros grandes a través de la API. Ocupa procesos, choca con los tiempos de espera de los proxies y no escala. URL prefirmada y confirmación posterior.
  10. Fiarte del Content-Type declarado por el cliente. Verifica los bytes reales, reprocesa la imagen y elimina los metadatos EXIF antes de publicarla.
  11. Diseñar el fan-out con un solo algoritmo. Push puro muere con las cuentas muy seguidas; pull puro muere con la latencia. El híbrido es feo y es el que funciona.
  12. Consejo de proceso: escribe la tabla comparativa del apartado 8 antes de programar. Obliga a justificar cada decisión frente a una alternativa concreta y es la mejor defensa contra copiar el diseño del último proyecto por inercia.

Ejercicios

Ejercicio 1 — El grafo como recurso. Diseña los endpoints para "silenciar" a un usuario al que sigues (dejas de ver sus publicaciones en tu línea de tiempo, pero sigues siendo su seguidor). Indica método, URI, códigos de estado y si necesita cuerpo. Justifica por qué no lo modelas como POST /v1/silenciar.

Ejercicio 2 — Cursor a prueba de manipulación. Un cliente envía ?cursor=eyJ2IjoidjEiLCJ0IjoiMjAzMC0wMS0wMVQwMDowMDowMFoiLCJpIjoicHViXzk5OTk5In0 (una fecha en el futuro). Explica qué devuelve el sistema, por qué eso no es un fallo de seguridad, y añade a decodificarCursor la validación que falta.

Ejercicio 3 — Proyección y paginación juntas. GET /v1/usuarios/usr_77/seguidores?limite=20 debe ocultar a los usuarios que han bloqueado al observador. Explica por qué filtrar en memoria rompe la paginación y esboza la consulta SQL correcta con cursor.

Soluciones

Solución 1. Silenciar es un atributo de una relación existente, no una relación nueva:

PATCH /v1/usuarios/usr_10/siguiendo/usr_77
Content-Type: application/json
If-Match: "w/rel-usr10-usr77-3"

{"silenciado": true}

HTTP/1.1 200 OK
ETag: "w/rel-usr10-usr77-4"
{"usuarioId":"usr_10","seguidoId":"usr_77","silenciado":true,"creadoEn":"2026-03-04T10:22:11Z"}

Códigos: 200 correcto; 404 si no sigues a ese usuario (no hay relación que modificar); 412 si el If-Match no coincide; 422/400 con datos_invalidos si el cuerpo no valida. No se usa POST /v1/silenciar porque la relación ya tiene URI: crear un verbo paralelo duplicaría el recurso, perdería la idempotencia del PATCH sobre un estado concreto y obligaría a inventar POST /v1/dessilenciar. Alternativa igual de válida: PUT sobre la relación completa con todos sus atributos, si prefieres evitar PATCH.

Solución 2. El sistema decodifica correctamente el JSON (v válido, t e i presentes), la comprobación de antigüedad no salta porque la fecha es futura, y la consulta WHERE (creado_en, id) < ('2030-01-01', 'pub_99999') devuelve simplemente la primera página, ya que todo es anterior a esa fecha. No es un fallo de seguridad porque el usuarioId del WHERE procede del token, no del cursor: manipularlo solo permite reposicionarse dentro del propio buzón, algo que el usuario ya puede hacer paginando. Aun así conviene rechazarlo para detectar clientes rotos:

  const t = Date.parse(datos.t);
  if (Number.isNaN(t)) {
    throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
  }
  if (t > Date.now() + 60_000) {           // margen de 1 min por desfase de relojes
    throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
  }
  if (typeof datos.i !== 'string' || !/^pub_[0-9]+$/.test(datos.i)) {
    throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
  }

Solución 3. Filtrar en memoria rompe la paginación porque el LIMIT se aplica antes del filtro: pides 20 filas, descartas las 6 de usuarios que te han bloqueado y devuelves 14, mientras el cursor avanza como si hubieras entregado 20. El cliente ve páginas irregulares y, si una página entera queda vacía, interpreta que se acabó el contenido. El filtro debe ir en la consulta:

SELECT s.seguidor_id, s.creado_en
FROM seguidores s
WHERE s.seguido_id = :perfilId
  AND NOT EXISTS (
        SELECT 1 FROM bloqueos b
        WHERE b.bloqueador_id = s.seguidor_id
          AND b.bloqueado_id  = :observadorId )
  AND (s.creado_en, s.seguidor_id) < (:cursorFecha, :cursorId)
ORDER BY s.creado_en DESC, s.seguidor_id DESC
LIMIT :limite + 1;

Consecuencia que hay que documentar: el número de elementos recorridos puede no coincidir con el contador seguidores del perfil, porque ese contador es global y la lista es relativa al observador. Y como la respuesta depende del observador, es private con Vary: Authorization.


Conclusión

Hemos diseñado CafeSocial con las mismas herramientas que Tienda Aroma —recursos, métodos, códigos de estado, cabeceras, hipermedia— y hemos obtenido un diseño casi opuesto. La relación de seguimiento dejó de ser un campo para convertirse en un recurso con PUT idempotente y DELETE. La línea de tiempo dejó de ser una colección para ser un recurso derivado sin POST, sin total y sin página N, sostenido por un fan-out híbrido y accesible solo mediante un cursor opaco de marca temporal más id. Los contadores dejaron de ser exactos porque contar dejó de merecer la pena. Las imágenes salieron de la API. La autorización dejó de preguntar de quién es el recurso para preguntar quién observa, con proyecciones por visibilidad y 404 allí donde la existencia ya es información. Y el tiempo real dejó de ser un adorno para ser el producto.

Si esta lección deja una sola idea, que sea la de la tabla del apartado 8: no hay un diseño REST universal, hay decisiones dependientes del dominio, y un profesional se distingue por poder nombrar la alternativa que descartó y el motivo. Esa es también la razón por la que GraphQL, indefendible para el catálogo de la tienda, es aquí una opción seria, siempre que se acepte el precio: perder la caché HTTP, los códigos de estado y el control del coste de las consultas.

Nos queda un último asunto, y es el que separa una API bien diseñada de una API que sigue viva a los tres años: qué ocurre después de publicarla. En 06-03, Evolución y mantenimiento de una API en producción, veremos qué hacer cuando un cambio aparentemente inocuo rompe a un consumidor en producción, cómo se escribe un post-mortem que sirva para algo, cómo se acumula y se paga la deuda de contrato, y cómo se planifica una migración a v2 con su periodo de deprecación y su gobierno. Porque diseñar bien una API es difícil, pero cambiarla sin romper a quien ya la usa es más difícil todavía.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados