Durante cinco módulos hemos ido construyendo la API de Tienda Aroma por partes: primero los conceptos, luego el diseño, después el código, la seguridad, la operación y las herramientas. Cada lección resolvía un problema y dejaba el siguiente abierto. Esta lección hace algo distinto: mira el resultado completo y pregunta por qué es así.

No es un resumen. Un resumen repetiría lo que ya sabes; aquí lo que interesa es la justificación en conjunto, que es lo que nunca aparece cuando aprendes pieza a pieza. Por qué el carrito acabó siendo un recurso y el checkout no. Por qué los pedidos se paginan por cursor y los cafés por desplazamiento. Por qué el dinero viaja en euros pero se guarda en céntimos. Y, sobre todo, qué se descartó en cada bifurcación y qué precio se pagó por elegir.

Piensa en esta lección como la memoria técnica que entregarías a un equipo que hereda el proyecto: contexto, decisiones con su alternativa descartada, flujos completos, los casos difíciles que casi nadie documenta, la arquitectura desplegada, los objetivos de nivel de servicio y una autocrítica honesta de lo que no salió bien. Al final tendrás una lista de verificación que puedes aplicar a cualquier API que diseñes.

Contenido

  1. El negocio y sus restricciones
  2. Los consumidores: cinco clientes, cinco necesidades distintas
  3. Casos de uso críticos y requisitos no funcionales
  4. Del modelo de dominio a los recursos
  5. El mapa completo de URIs
  6. Las decisiones de diseño y sus alternativas descartadas
  7. El flujo completo de una compra
  8. Casos difíciles y cómo se resolvieron
  9. La arquitectura desplegada
  10. Métricas y SLO del servicio
  11. Lo que haríamos distinto
  12. Lista de verificación final del proyecto

  1. El negocio y sus restricciones

Tienda Aroma vende café de especialidad. No es un supermercado: el catálogo tiene entre 40 y 120 referencias vivas, con origen, tueste, notas de cata y lotes que se agotan. El margen es alto, el volumen es moderado y la fidelidad del cliente lo es todo: un comprador habitual pide cada tres o cuatro semanas durante años.

Estas tres características del negocio condicionan la API mucho más de lo que parece:

Rasgo del negocio Consecuencia técnica
Catálogo pequeño y de cambio lento El catálogo es cacheable de forma agresiva; no necesita paginación por cursor ni búsqueda distribuida
Stock real y finito por lote El stock es consistente y transaccional, no aproximado; no podemos vender lo que no hay
Pocos pedidos pero de alto valor Un pedido duplicado es un problema serio: la idempotencia no es un lujo
Compra recurrente Los pedidos crecen sin parar por cliente: paginación por cursor en /pedidos
Campaña de Navidad Picos de 20-30 veces el tráfico normal durante seis semanas
Datos personales de clientes europeos RGPD aplicable: derecho de acceso, rectificación y supresión

Ninguna de estas decisiones sale de un libro de estilo. Salen del negocio. Ese es el primer mensaje de la lección: el mismo conjunto de reglas REST produce APIs distintas según el dominio, y en 06-02 lo veremos de forma mucho más brutal.

  1. Los consumidores: cinco clientes, cinco necesidades distintas

En 01-01 dijimos que una API se diseña para sus consumidores, no para su base de datos. Tienda Aroma tiene cinco, y cada uno tira del diseño en una dirección.

Consumidor Quién es Qué necesita Cómo tiró del diseño
SPA tiendaaroma.example Web pública, navegador Catálogo rápido, carrito, compra Forzó CORS con lista blanca (04-05), caché con ETag (04-06) y _links en pedidos
Aroma Móvil App nativa iOS/Android Lo mismo pero con red mala y datos caros Forzó campos para respuestas parciales, compresión y tolerancia a reintentos
Panel interno panel.tiendaaroma.example Empleados y administradores Moderar reseñas, gestionar pedidos, ver en vivo Forzó roles (empleado, administrador) y SSE para el tiempo real
RápidoEnvíos Transportista, sistema a sistema Enterarse de pedidos pagados sin sondear Forzó webhooks firmados con HMAC-SHA256 y reintentos
CataBox App de terceros, socios Leer catálogo y escribir reseñas en nombre del usuario Forzó OAuth 2.0 con ámbitos (04-03) y el rol socio

El detalle importante: los cinco consumen la misma API. No hay una API para móvil y otra para web. Esa fue una decisión consciente, con su coste: la SPA recibe algunos campos que no usa, y el móvil tiene que pedir campos=id,nombre,precioEuros para adelgazar la respuesta. La alternativa —un backend for frontend por cliente— habría dado respuestas perfectas para cada uno a cambio de multiplicar por tres el código, las pruebas y el contrato. Con cinco consumidores y un equipo pequeño, no compensaba. Con veinte consumidores y tres equipos, la respuesta habría sido otra.

  1. Casos de uso críticos y requisitos no funcionales

Cuatro casos de uso concentran el 95 % del tráfico y todo el riesgo:

  1. Buscar un caféGET /cafes con filtros. Es el 70 % de las peticiones. Debe ser rapidísimo y es totalmente cacheable.
  2. Comprar — carrito, pedido, pago. Es el 5 % de las peticiones y el 100 % de los ingresos. Debe ser correcto aunque sea lento.
  3. Seguir el envíoGET /pedidos/{id} y /envio. Genera sondeo repetido: mucha caché condicional y 304.
  4. Moderar reseñas — panel interno. Volumen bajo, autorización estricta.

Y los requisitos no funcionales que acordamos con negocio:

Requisito Objetivo Dónde se resolvió
Disponibilidad 99,9 % mensual (unos 43 min de caída) Réplicas, readiness, blue-green (05-05)
Latencia lectura p95 < 200 ms, p99 < 500 ms Caché HTTP + Redis (04-06), índices (03-05)
Latencia escritura p95 < 400 ms Transacciones cortas, webhooks asíncronos
Pico de campaña 30× el tráfico base durante 6 semanas Caché de catálogo, escalado horizontal, rate limiting (04-04)
Protección de datos RGPD: acceso, rectificación, supresión Anonimización, minimización de logs (04-02)
Corrección del pedido Cero pedidos duplicados, cero sobreventa Idempotency-Key + transacción + 409

Fíjate en la asimetría deliberada: la lectura se optimiza para velocidad y la escritura para corrección. Un catálogo que tarda 400 ms molesta; un pedido cobrado dos veces es una llamada al banco, una devolución y un cliente perdido.

  1. Del modelo de dominio a los recursos

En 02-02 vimos el método: escribe cómo describe el negocio su trabajo, subraya los sustantivos y pregúntate cuáles tienen identidad propia, estado y ciclo de vida. Aplicado a Tienda Aroma:

Sustantivo del negocio ¿Recurso? Razón
Café Sí, colección /cafes Identidad propia, se lista, se filtra, se enlaza
Cliente Sí, /clientes Identidad propia y datos personales
Pedido Sí, /pedidos Identidad, estado y ciclo de vida largo
Reseña Sí, /resenas Identidad propia; se modera de forma independiente
Carrito , /carritos Tiene estado que sobrevive entre peticiones
Línea de carrito Sí, subrecurso /carritos/{id}/lineas/{cafeId} Se manipula individualmente
Sesión Sí, /sesiones El login como creación de un recurso
Imagen de café Sí, /cafes/{id}/imagen Representación binaria con su propia caché
Checkout No Es un proceso, no una cosa
Búsqueda No Es un GET /cafes con parámetros
Descuento No (v1) Se resolvió como campo calculado del pedido

Por qué el carrito es un recurso y el checkout no

Esta es la distinción que más cuesta y la que mejor separa a quien ha entendido REST de quien traduce funciones a URLs.

El carrito es un recurso porque cumple las tres pruebas: tiene identidad (car_77), tiene estado que persiste entre peticiones (las líneas que has añadido siguen ahí mañana) y responde con sentido a los métodos HTTP. GET /carritos/car_77 devuelve algo. DELETE /carritos/car_77 significa vaciarlo. Puedes enlazarlo. Puedes cachearlo (mal, porque cambia, pero el verbo tiene sentido).

El checkout no es un recurso porque es un proceso: la transición de un carrito a un pedido. No tiene estado propio que consultar. GET /checkout no significa nada. Y sobre todo: el resultado del proceso es un recurso, y ya tiene nombre. El proceso se expresa creando ese recurso:

POST /v1/pedidos
Idempotency-Key: 6f1b2c9e-8a4d-4f7a-9c3e-2b5d7e1f0a44

{"carritoId": "car_77", "direccionEnvioId": "dir_12"}

La alternativa habría sido POST /checkout, que funciona pero no dice qué se ha creado, no puede devolver un Location coherente y no se deja versionar ni enlazar. La regla que sacamos de aquí: si un proceso produce algo con identidad, expón lo producido, no el proceso. Y si un proceso no produce nada nuevo pero cambia el estado de algo existente —pagar, enviar, anular—, expón esa transición como subrecurso con POST, que es exactamente lo que hicimos con /pedidos/{id}/pago.

  1. El mapa completo de URIs

Este es el contrato completo de la v1, tal como quedó:

https://api.tiendaaroma.example/v1

Catálogo
  GET    /cafes                        Listar (filtros, orden, offset)
  POST   /cafes                        Crear                    [administrador]
  GET    /cafes/{id}                   Detalle
  PUT    /cafes/{id}                   Reemplazar (If-Match)    [administrador]
  PATCH  /cafes/{id}                   Modificar (If-Match)     [administrador]
  DELETE /cafes/{id}                   Retirar                  [administrador]
  GET    /cafes/{id}/imagen            Imagen (binario, caché larga)
  PUT    /cafes/{id}/imagen            Sustituir imagen         [administrador]
  GET    /cafes/{id}/resenas           Reseñas del café
  POST   /cafes/{id}/resenas           Publicar reseña          [cliente|socio]

Clientes
  GET    /clientes                     Listar                   [empleado+]
  POST   /clientes                     Alta
  GET    /clientes/{id}                Detalle                  [propietario|empleado+]
  PATCH  /clientes/{id}                Modificar                [propietario|administrador]
  DELETE /clientes/{id}                Baja (anonimiza)         [propietario|administrador]
  GET    /clientes/{id}/preferencias   Preferencias
  PUT    /clientes/{id}/preferencias   Sustituir preferencias
  GET    /clientes/{id}/pedidos        Pedidos del cliente (cursor)

Carritos
  POST   /carritos                     Crear carrito
  GET    /carritos/{id}                Ver carrito
  DELETE /carritos/{id}                Vaciar
  PUT    /carritos/{id}/lineas/{cafeId}    Fijar cantidad (idempotente)
  DELETE /carritos/{id}/lineas/{cafeId}    Quitar línea

Pedidos
  GET    /pedidos                      Listar (cursor)
  POST   /pedidos                      Crear (Idempotency-Key)
  GET    /pedidos/{id}                 Detalle (ETag, _links)
  POST   /pedidos/{id}/pago            Pagar
  POST   /pedidos/{id}/envio           Marcar enviado          [empleado+]
  GET    /pedidos/{id}/factura         Descargar factura (PDF)
  POST   /pedidos/{id}/anulacion       Anular
  POST   /pedidos/{id}/devolucion      Devolver

Reseñas
  GET    /resenas                      Listar (moderación)     [empleado+]
  GET    /resenas/{id}                 Detalle
  POST   /resenas/{id}/aprobacion      Aprobar                 [resenas.moderar]
  POST   /resenas/{id}/rechazo         Rechazar                [resenas.moderar]
  POST   /resenas/{id}/respuestas      Responder               [empleado+]

Sesiones y sistema
  POST   /sesiones                     Login (devuelve JWT)
  DELETE /sesiones/actual              Logout
  GET    /salud/vivo                   Liveness
  GET    /salud/listo                  Readiness
  GET    /metricas                     Prometheus              [interno]
  GET    /docs                         Swagger UI

Tres regularidades que sostienen todo el mapa y que un consumidor nuevo aprende en cinco minutos:

  • Plural siempre, sin excepciones. /cafes, no /cafe ni /coffeeList.
  • Sin verbos en la ruta; el verbo es el método HTTP. Las únicas "acciones" son sustantivos de transición (/pago, /anulacion).
  • Máximo dos niveles de anidamiento, y el subrecurso siempre pertenece de verdad al padre.

  1. Las decisiones de diseño y sus alternativas descartadas

Esta es la tabla que más valor tiene en una memoria técnica. Cada fila es una bifurcación real del proyecto.

# Decisión tomada Alternativa descartada Por qué
1 Acciones como subrecurso con POST (/pedidos/{id}/pago) Verbos en la ruta (/pedidos/{id}/pagar) o PATCH con {"estado":"pagado"} El subrecurso permite cuerpo propio, respuesta propia y ámbitos OAuth distintos. El PATCH de estado convierte la máquina de estados en un campo editable, y entonces nada impide saltar de pendiente_pago a enviado
2 Anidamiento máximo de dos niveles /clientes/{c}/pedidos/{p}/lineas/{l} Con tres niveles, la URI de una línea deja de ser estable si el pedido cambia de cliente, y obliga a conocer toda la jerarquía para enlazar
3 Ids con prefijo (caf_001, ped_5001) Enteros autoincrementales o UUID pelados El prefijo hace los errores obvios en logs y soporte (cafe_no_encontrado: ped_5001 canta solo), no filtra volumen de negocio y permite cambiar el almacenamiento sin cambiar el formato público
4 Envoltorio {"datos": [...], "total": n} en colecciones Array desnudo [...] Deja sitio para metadatos futuros sin romper el contrato. (Ver la autocrítica del apartado 11: la decisión fue correcta pero incompleta)
5 _links selectivos: solo en pedidos y según estado HATEOAS completo en todos los recursos, o ninguno Richardson 2 con hipermedia donde aporta. En un pedido, saber si pago está disponible evita que el cliente reimplemente la máquina de estados. En un café, un self es todo lo que nadie va a usar
6 Dinero en céntimos por dentro, euros con dos decimales por fuera Flotantes en todas partes, o céntimos también en el JSON 0.1 + 0.2 !== 0.3 arruina totales. Pero exponer 1450 obliga a cada consumidor a saber la escala; exponer "14.50" es inequívoco y legible en el navegador
7 Paginación offset en /cafes, cursor en /pedidos Un único modelo para toda la API Coherencia mal entendida. El catálogo es pequeño y estable y la gente quiere ir a "página 3"; los pedidos crecen sin fin y se insertan por delante, donde el offset produce duplicados y saltos (02-06)
8 Versión en la ruta (/v1) Cabecera Accept con perfil, o parámetro ?version= Visible en logs, en el navegador, en las métricas por ruta y en la configuración del gateway. La cabecera es más pura y mucho menos operable
9 Errores propios {"error":{"codigo",...}} application/problem+json (RFC 9457) Se eligió por familiaridad del equipo. (Ver apartado 11: fue un error)
10 Reseñas anidadas para escribir (POST /cafes/{id}/resenas) y planas para moderar (GET /resenas) Solo anidadas, o solo planas Escribir siempre es en el contexto de un café; moderar nunca lo es. (Con matices: apartado 11)
11 Idempotency-Key obligatoria en POST /pedidos y /pago Confiar en que el cliente no reintente Las redes móviles reintentan solas. Un timeout no dice si el servidor procesó la petición
12 Prefijo propio Aroma- en cabeceras no estándar X- (obsoleto desde RFC 6648) X- está desaconsejado y colisiona; el prefijo de marca es inequívoco

  1. El flujo completo de una compra

Aquí es donde el diseño se pone a prueba. Seguimos a Marta García (cli_842) desde que busca café hasta que descarga su factura. Todas las peticiones son reales según el contrato de la v1.

Paso 1 — Buscar en el catálogo

GET /v1/cafes?tueste=claro&precioMax=16.00&disponible=true&ordenar=-puntuacionMedia&limite=20 HTTP/1.1
Host: api.tiendaaroma.example
Accept: application/json
Origin: https://tiendaaroma.example
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "cat-9f2a17b4"
Vary: Accept, Accept-Encoding, Origin
Link: <https://api.tiendaaroma.example/v1/cafes?tueste=claro&limite=20&desplazamiento=20>; rel="next"
Aroma-RateLimit-Restante: 98

{
  "datos": [
    {
      "id": "caf_001",
      "nombre": "Etiopía Yirgacheffe",
      "origen": "Etiopía",
      "tueste": "claro",
      "precioEuros": "14.50",
      "stock": 120,
      "notasCata": ["jazmín", "bergamota", "melocotón"],
      "version": 7,
      "_links": { "self": { "href": "/v1/cafes/caf_001" } }
    }
  ],
  "total": 1
}

Sin autenticación: el catálogo es público. Con ETag, para que la siguiente visita reciba un 304 de 150 bytes (04-06). Con Vary: Origin porque la respuesta lleva cabeceras CORS y una caché compartida no debe mezclarlas (04-05).

Paso 2 — Añadir al carrito

PUT /v1/carritos/car_77/lineas/caf_001 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json

{"cantidad": 2}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "cafeId": "caf_001",
  "nombre": "Etiopía Yirgacheffe",
  "cantidad": 2,
  "precioUnitarioEuros": "14.50",
  "subtotalEuros": "29.00"
}

PUT y no POST. Esta es una de las decisiones más útiles de todo el contrato y merece explicarse. Con POST /carritos/car_77/lineas, si el usuario pulsa dos veces "añadir" acaba con dos líneas del mismo café, o con una lógica de fusión escondida en el servidor. Con PUT sobre la URI de la línea, la operación es idempotente (02-03): "la cantidad de caf_001 en este carrito es 2". Pulsar diez veces deja el mismo resultado. La interfaz del carrito, con su selector de cantidad, encaja de forma natural: cada cambio del selector es un PUT.

Y el subrecurso tiene URI propia, así que quitar un café es DELETE /v1/carritos/car_77/lineas/caf_001, sin cuerpo y sin ambigüedad.

Aquí no se reserva stock. Es deliberado: reservar en el carrito obliga a expirar reservas, complica el inventario y genera falsos "agotado" en campaña. El stock se comprueba y se descuenta al crear el pedido, en una transacción.

Paso 3 — Crear el pedido

POST /v1/pedidos HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
Idempotency-Key: 6f1b2c9e-8a4d-4f7a-9c3e-2b5d7e1f0a44

{"carritoId": "car_77", "direccionEnvioId": "dir_12"}
HTTP/1.1 201 Created
Location: /v1/pedidos/ped_5001
ETag: "ped-5001-v1"
Content-Type: application/json

{
  "id": "ped_5001",
  "clienteId": "cli_842",
  "estado": "pendiente_pago",
  "lineas": [
    {"cafeId": "caf_001", "nombre": "Etiopía Yirgacheffe", "cantidad": 2,
     "precioUnitarioEuros": "14.50", "subtotalEuros": "29.00"}
  ],
  "totalEuros": "29.00",
  "fechaCreacion": "2026-08-15T09:14:22Z",
  "version": 1,
  "_links": {
    "self":      {"href": "/v1/pedidos/ped_5001"},
    "pago":      {"href": "/v1/pedidos/ped_5001/pago", "method": "POST"},
    "anulacion": {"href": "/v1/pedidos/ped_5001/anulacion", "method": "POST"}
  }
}

Dentro, todo ocurre en una transacción (03-05): se leen las líneas del carrito, se bloquean y comprueban los stocks, se congela el precio unitario en cada línea, se calcula el total en céntimos, se inserta el pedido, se descuenta el stock y se vacía el carrito. Si algo falla, no queda rastro.

Y en _links aparece la hipermedia selectiva de la decisión 5: como el pedido está pendiente_pago, el cliente ve pago y anulacion. No verá envio ni devolucion, porque no son posibles todavía. La SPA no necesita conocer la máquina de estados: le basta con pintar los enlaces que recibe.

Paso 4 — Pagar

POST /v1/pedidos/ped_5001/pago HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Idempotency-Key: 3d8e5a11-77c0-4b2e-9a10-4c6f8b0e2d31
If-Match: "ped-5001-v1"
Content-Type: application/json

{"metodo": "tarjeta", "tokenPasarela": "tok_ficticio_9f3a"}
HTTP/1.1 200 OK
ETag: "ped-5001-v2"
Content-Type: application/json

{
  "id": "ped_5001",
  "estado": "pagado",
  "totalEuros": "29.00",
  "version": 2,
  "_links": {
    "self":    {"href": "/v1/pedidos/ped_5001"},
    "factura": {"href": "/v1/pedidos/ped_5001/factura"},
    "envio":   {"href": "/v1/pedidos/ped_5001/envio"}
  }
}

Tres mecanismos actuando a la vez, y conviene distinguirlos porque se confunden:

  • Idempotency-Key protege contra el reintento del mismo cliente: si la respuesta se perdió, repetir la petición devuelve la respuesta guardada sin volver a cobrar.
  • If-Match protege contra la escritura sobre una versión obsoleta: si otro proceso ya cambió el pedido, responde 412 (04-06 y 03-05).
  • La transición de estado protege contra lo semánticamente imposible: pagar dos veces con claves distintas responde 409 pedido_ya_pagado.

Y observa cómo cambian los _links: han desaparecido pago y anulacion, y han aparecido factura y envio. Los enlaces son la máquina de estados.

Paso 5 — El webhook hacia RápidoEnvíos

El pago dispara un evento. La API no llama a RápidoEnvíos dentro de la transacción; encola el evento y lo entrega después, porque un transportista lento no puede bloquear un cobro.

POST /hooks/aroma HTTP/1.1
Host: api.rapidoenvios.example
Content-Type: application/json
Aroma-Evento-Id: evt_88213
Aroma-Firma: sha256=9c1f...4b7e
Aroma-Traza-Id: 4bf92f3577b34da6a3ce929d0e0e4736

{
  "evento": "pedido.pagado",
  "fechaCreacion": "2026-08-15T09:15:03Z",
  "datos": {
    "pedidoId": "ped_5001",
    "totalEuros": "29.00",
    "destinatario": {"nombre": "Marta García", "codigoPostal": "46001"}
  }
}

La firma es HMAC-SHA256 del cuerpo crudo con el secreto compartido. RápidoEnvíos la verifica, responde 2xx y encola su trabajo. Si responde 5xx o no responde, reintentamos con espera exponencial. Aroma-Evento-Id permite a RápidoEnvíos descartar duplicados: la entrega es "al menos una vez", así que el receptor debe ser idempotente.

Paso 6 — Seguir el envío y descargar la factura

Cuando RápidoEnvíos recoge el paquete, un empleado (o su integración) marca el envío:

POST /v1/pedidos/ped_5001/envio HTTP/1.1
Authorization: Bearer <token con ambito envios.escribir>
Content-Type: application/json

{"transportista": "RapidoEnvios", "seguimiento": "RE9928471ES"}

Marta consulta el estado. Como sondea cada minuto, la caché condicional hace su trabajo:

GET /v1/pedidos/ped_5001 HTTP/1.1
If-None-Match: "ped-5001-v3"

HTTP/1.1 304 Not Modified
ETag: "ped-5001-v3"
Cache-Control: private, no-cache

Y la factura, que es un recurso con otra representación:

GET /v1/pedidos/ped_5001/factura HTTP/1.1
Accept: application/pdf

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="factura-ped_5001.pdf"
Cache-Control: private, max-age=31536000, immutable

immutable porque una factura emitida no cambia jamás. Es uno de los pocos sitios de toda la API donde esa directiva está plenamente justificada.

El flujo en un diagrama

sequenceDiagram
  participant S as SPA
  participant G as Gateway Kong
  participant A as API Aroma
  participant D as Base de datos
  participant R as RapidoEnvios

  S->>G: GET /v1/cafes?tueste=claro
  G->>A: reenvia
  A-->>S: 200 + ETag + Cache-Control
  S->>A: PUT /carritos/car_77/lineas/caf_001
  A-->>S: 200 linea fijada
  S->>A: POST /pedidos (Idempotency-Key)
  A->>D: TX: comprobar stock, congelar precio, insertar
  D-->>A: ok
  A-->>S: 201 Created + Location + _links(pago)
  S->>A: POST /pedidos/ped_5001/pago (If-Match)
  A->>D: TX: estado=pagado, version=2
  A-->>S: 200 + _links(factura, envio)
  A->>R: POST webhook pedido.pagado (Aroma-Firma)
  R-->>A: 202 aceptado
  R->>A: POST /pedidos/ped_5001/envio
  A-->>R: 200 estado=enviado
  S->>A: GET /pedidos/ped_5001 (If-None-Match)
  A-->>S: 304 Not Modified

  1. Casos difíciles y cómo se resolvieron

Un diseño se juzga por lo que hace cuando las cosas van mal. Estos seis casos son los que de verdad costaron reuniones.

8.1 Stock insuficiente con concurrencia

El problema. Quedan 2 unidades de caf_001 y dos clientes crean un pedido de 2 en el mismo instante. Si comprobamos el stock y luego lo descontamos en dos pasos separados, ambos leen 2, ambos ven suficiente y ambos venden. Sobreventa.

La solución. Todo dentro de una transacción, y el descuento con la condición incorporada en la propia sentencia:

BEGIN IMMEDIATE;

UPDATE cafes
   SET stock = stock - :cantidad
 WHERE id = :cafeId
   AND stock >= :cantidad;
-- si changes() = 0, no habia stock: se aborta

INSERT INTO pedidos (...) VALUES (...);
COMMIT;

La comprobación y la escritura son la misma operación atómica. Si changes() devuelve 0, no había stock y se lanza el error:

// src/servicios/pedidos.js (extracto)
const resultado = repositorioCafes.descontarStock(cafeId, cantidad);
if (resultado.changes === 0) {
  throw new ErrorApi(409, 'stock_insuficiente',
    'No hay unidades suficientes del café solicitado', [
      { campo: 'lineas[0].cantidad', cafeId, solicitado: cantidad }
    ]);
}

Por qué 409 y no 400. El 400 dice "tu petición está mal escrita"; reenviarla igual siempre fallará. El 409 dice "tu petición es válida pero choca con el estado actual"; mañana, con stock repuesto, la misma petición funcionará. La diferencia importa para el cliente, que en el segundo caso puede ofrecer "avisarme cuando haya".

8.2 Doble pago

El problema. El móvil de Marta pierde cobertura justo después de enviar POST /pago. El servidor cobra; la respuesta no llega. La app reintenta. ¿Se cobra dos veces?

La solución, en dos capas.

La primera es la Idempotency-Key. Antes de procesar, se intenta insertar la clave en una tabla con restricción única, junto al hash del cuerpo:

// src/middleware/idempotencia.js (extracto)
const registro = repositorioIdempotencia.buscar(clave);
if (registro) {
  if (registro.hashCuerpo !== hashDe(req.body)) {
    throw new ErrorApi(422, 'clave_idempotencia_reutilizada',
      'Esta clave de idempotencia ya se usó con un cuerpo diferente');
  }
  if (registro.estado === 'en_curso') {
    throw new ErrorApi(409, 'operacion_en_curso',
      'La operación con esta clave todavía se está procesando');
  }
  return res.status(registro.codigo).set(registro.cabeceras).json(registro.respuesta);
}

El reintento devuelve la misma respuesta guardada, con el mismo 201/200 y el mismo cuerpo. No se cobra dos veces.

La segunda capa es la máquina de estados: si llega un pago con una clave nueva sobre un pedido que ya está pagado, responde 409 pedido_ya_pagado. La idempotencia cubre el reintento; el estado cubre el error genuino.

8.3 El precio cambia con el pedido ya creado

El problema. Marta crea el pedido a las 9:14 con caf_001 a 14,50 €. A las 9:20, un administrador sube el precio a 15,90 €. Marta paga a las 9:25. ¿Cuánto paga?

La solución: precio congelado en la línea. Cada línea de pedido guarda su propio precioUnitarioCentimos, copiado del catálogo en el momento de la creación. El pedido no consulta el precio del café al mostrarse ni al pagar.

CREATE TABLE lineas_pedido (
  pedido_id                TEXT NOT NULL,
  cafe_id                  TEXT NOT NULL,
  nombre_cafe              TEXT NOT NULL,  -- copiado, no referenciado
  cantidad                 INTEGER NOT NULL CHECK (cantidad > 0),
  precio_unitario_centimos INTEGER NOT NULL, -- congelado
  PRIMARY KEY (pedido_id, cafe_id)
);

Fíjate en que también se copia el nombre. No es redundancia por descuido: si el café se renombra o se retira del catálogo, la factura de Marta debe seguir diciendo lo que compró. Un pedido es un documento histórico, no una vista de datos actuales, y esa distinción cambia cómo se modela la tabla.

La consecuencia que hay que asumir: un carrito viejo puede mostrar precios desactualizados. Se resolvió recalculando los precios del carrito en cada GET /carritos/{id} (el carrito sí es una vista actual) y avisando en la interfaz si algo cambió desde la última visita.

8.4 Reseña de quien no compró el café

El problema. ¿Puede cli_842 reseñar caf_002 si nunca lo ha comprado?

La decisión de negocio fue: sí, pero con distinción visible. Reseñar es una barrera baja a propósito, porque el volumen de reseñas importa comercialmente. Pero una reseña de un comprador verificado vale más.

La solución técnica. Al crear la reseña, el servicio consulta si existe algún pedido pagado de ese cliente que contenga ese café, y sella el resultado en el recurso:

// src/servicios/resenas.js (extracto)
const compraVerificada = repositorioPedidos.clienteComproCafe(clienteId, cafeId);

return repositorioResenas.crear({
  cafeId, clienteId, puntuacion, comentario,
  compraVerificada,                       // sello inmutable
  estado: 'pendiente_moderacion'          // toda reseña se modera
});
{
  "id": "res_101",
  "cafeId": "caf_001",
  "clienteId": "cli_842",
  "puntuacion": 5,
  "comentario": "Floral y limpio, muy recomendable.",
  "compraVerificada": true,
  "estado": "pendiente_moderacion",
  "fechaCreacion": "2026-08-15T10:02:00Z"
}

Y una regla añadida: una reseña por cliente y café, con restricción única en la base de datos. Un segundo intento responde 409. Esa regla vive en el índice, no solo en el servicio, porque con dos instancias de la API en paralelo la comprobación en código no basta.

8.5 Borrar un cliente que tiene pedidos

El problema. Marta ejerce su derecho de supresión (RGPD, artículo 17). Pero sus pedidos son documentos contables que la legislación mercantil obliga a conservar varios años. Dos obligaciones legales que apuntan en direcciones opuestas.

La solución: anonimizar, no borrar. DELETE /clientes/cli_842 no ejecuta un DELETE en la tabla. Sustituye los datos personales por valores neutros, conserva el registro con su identificador y desactiva la cuenta:

UPDATE clientes
   SET nombre        = 'Cliente eliminado',
       email         = '[email protected]',
       telefono      = NULL,
       direcciones   = NULL,
       anonimizado_en = :ahora,
       activo        = 0
 WHERE id = :clienteId;
-- Los pedidos conservan cliente_id: el importe y la fecha siguen siendo auditables,
-- pero ya no hay forma de saber quien fue.

La respuesta es 204 No Content. A partir de ahí, GET /clientes/cli_842 responde 404 cliente_no_encontrado a cualquiera que no sea auditoría interna, y el pedido ped_5001 sigue existiendo con su importe, su fecha y su IVA, pero sin ninguna persona detrás.

Advertencia importante. Lo que acabas de leer es una solución técnica plausible, no asesoramiento legal. Qué se puede conservar, durante cuánto tiempo, con qué base jurídica y qué cuenta como anonimización efectiva —frente a mera seudonimización, que sigue siendo dato personal— depende de la jurisdicción, del sector y del caso concreto. En un proyecto real, este diseño se valida con el responsable de protección de datos y con asesoría jurídica antes de escribir la primera línea de código. También hay que decidir qué hacer con las copias de seguridad, con los logs y con los sistemas terceros a los que se envió el dato (aquí, RápidoEnvíos), y eso rara vez lo resuelve un UPDATE.

8.6 Un webhook que RápidoEnvíos no confirmó

El problema. Enviamos pedido.pagado y no llega respuesta. ¿Lo recibieron? ¿Lo procesaron? No hay forma de saberlo desde fuera.

La solución: cola persistente con reintentos y desactivación. El evento se guarda en una tabla con su estado, y un proceso lo reintenta con espera exponencial y jitter: 1 min, 2, 4, 8, 16, 32, 64 min. Tras siete intentos fallidos pasa a fallido_permanente, se dispara una alerta y el evento queda disponible para reenvío manual desde el panel.

// src/servicios/webhooks.js (extracto)
const ESPERAS_MINUTOS = [1, 2, 4, 8, 16, 32, 64];

function calcularProximoIntento(numeroIntento) {
  const base = ESPERAS_MINUTOS[numeroIntento] ?? 64;
  const jitter = Math.random() * base * 0.2;  // evita tormentas sincronizadas
  return new Date(Date.now() + (base + jitter) * 60_000);
}

Tres detalles que hacen que esto funcione en producción:

  • Aroma-Evento-Id estable entre reintentos. El mismo evento se reintenta con el mismo identificador, para que el receptor pueda descartar duplicados. Si cambiara, cada reintento parecería un evento nuevo y RápidoEnvíos crearía siete envíos.
  • Entrega "al menos una vez", nunca "exactamente una vez". Es imposible garantizar lo segundo sobre una red no fiable, y prometerlo en la documentación es engañar. Lo que se documenta es: reintentamos; sé idempotente.
  • Cortocircuito. Si RápidoEnvíos lleva 50 fallos seguidos, se deja de intentar durante unos minutos en vez de castigar a un servicio ya caído.

  1. La arquitectura desplegada

graph TB
  SPA[SPA web] --> CDN[CDN]
  MOV[Aroma Movil] --> GW
  PAN[Panel interno] --> GW
  CB[CataBox OAuth] --> GW
  CDN --> GW[Gateway Kong<br/>TLS, rate limit, CORS, JWT]

  GW --> API1[API Aroma 1]
  GW --> API2[API Aroma 2]
  GW --> API3[API Aroma 3]

  API1 --> RED[(Redis<br/>cache + rate limit + idempotencia)]
  API2 --> RED
  API3 --> RED
  API1 --> DB[(Base de datos<br/>primaria + replica)]
  API2 --> DB
  API3 --> DB
  API2 --> INV[Servicio de inventario<br/>gRPC interno]
  API2 -.webhook firmado.-> RE[RapidoEnvios]
  PAN -.SSE.-> API3
  API1 --> OBS[Observabilidad<br/>Prometheus + trazas + logs]

Cada pieza está donde está por una razón concreta:

Pieza Qué resuelve Qué pasaría sin ella
CDN Sirve imágenes de café y respuestas públicas cacheadas El pico de Navidad llegaría entero a la API
Gateway Kong TLS, rate limiting global, CORS, validación de JWT, cuotas por consumidor Cada instancia repetiría esas reglas y divergirían
3 instancias sin estado Escalado horizontal y despliegue blue-green No se podría desplegar sin corte ni absorber picos
Redis Caché cache-aside, contadores de rate limit, claves de idempotencia Los límites serían por instancia (y por tanto 3× el real) y la idempotencia no cruzaría instancias
Primaria + réplica Escrituras a la primaria, lecturas pesadas a la réplica El catálogo competiría con los pedidos por la misma base
Inventario por gRPC Consulta de stock del almacén físico, contrato tipado, baja latencia REST interno con más latencia y sin tipos compartidos (01-07)
Observabilidad Señales de oro, trazas correlacionadas por Aroma-Traza-Id Diagnosticar a ciegas (04-07)

Por qué la idempotencia vive en Redis y no en memoria. Con tres instancias detrás del gateway, el reintento del móvil de Marta puede aterrizar en una instancia distinta a la original. Una caché en memoria no lo vería y volvería a cobrar. Es el mismo razonamiento que llevó el rate limiting a Redis en 04-04: cualquier estado compartido entre peticiones debe vivir fuera del proceso, o el escalado horizontal lo rompe en silencio.

  1. Métricas y SLO del servicio

Los SLO se derivan de los requisitos del apartado 3, y cada uno tiene su indicador medible (04-07):

SLO Objetivo Indicador Presupuesto de error mensual
Disponibilidad de lectura 99,95 % % de GET sin 5xx ~22 min
Disponibilidad de compra 99,9 % % de POST /pedidos y /pago sin 5xx ~43 min
Latencia de catálogo p95 < 200 ms Histograma por ruta
Latencia de compra p95 < 400 ms Histograma por ruta
Entrega de webhooks 99 % en < 5 min % de eventos confirmados
Corrección de pedidos 0 duplicados Contador de colisiones de idempotencia 0

Y las alertas que de verdad despiertan a alguien de noche —deliberadamente pocas, porque una alerta que suena sin consecuencia entrena al equipo a ignorarlas—:

# extracto de las reglas de alerta
- alerta: TasaErrores5xxAlta
  expr: sum(rate(http_peticiones_total{codigo=~"5.."}[5m]))
      / sum(rate(http_peticiones_total[5m])) > 0.01
  durante: 5m
  gravedad: critica

- alerta: ComprasFallando
  expr: sum(rate(http_peticiones_total{ruta="/v1/pedidos",metodo="POST",codigo=~"5.."}[5m])) > 0
  durante: 2m
  gravedad: critica          # cualquier fallo de compra es dinero perdido

- alerta: WebhooksAtascados
  expr: aroma_webhooks_pendientes > 100
  durante: 10m
  gravedad: aviso

ComprasFallando dispara con un solo error, mientras que la tasa general tolera un 1 %. Esa asimetría es intencionada y refleja la del apartado 3: no todos los endpoints valen lo mismo.

  1. Lo que haríamos distinto

Ninguna memoria técnica honesta termina sin esta sección. Cuatro decisiones que, con el proyecto ya en producción, no repetiríamos.

11.1 El envoltorio datos/total: correcto pero incompleto

Qué hicimos. {"datos": [...], "total": 42}.

Qué falló. El envoltorio fue acertado —dejó sitio para metadatos— pero se quedó a medias. Los metadatos de paginación acabaron repartidos entre la cabecera Link y el campo total, y cada consumidor tuvo que aprender un sitio distinto para cada cosa. Peor: total obliga a un COUNT(*) en cada listado, que en /pedidos con filtros amplios es la consulta más lenta de toda la API. Y en una colección paginada por cursor, un total exacto es además conceptualmente dudoso.

Qué haríamos. Un objeto meta explícito, y total opcional bajo demanda:

{
  "datos": [ ],
  "meta": {
    "limite": 20,
    "desplazamiento": 40,
    "total": 128,
    "siguienteCursor": null
  }
}

Coste de arreglarlo ahora. Es un cambio rompedor para los cinco consumidores. Va a la lista de la v2, y ese es exactamente el tipo de deuda de contrato que se trata en 06-03.

11.2 No haber usado application/problem+json

Qué hicimos. Formato propio: {"error": {"codigo", "mensaje", "detalles"}}.

Qué falló. Es un formato razonable, coherente y bien documentado. Pero es nuestro. Existe un estándar, RFC 9457, con type, title, status, detail e instance, que las bibliotecas de cliente, los gateways y las herramientas de monitorización ya entienden. Al integrar CataBox tuvimos que explicar nuestro formato desde cero y escribir un adaptador; con problem+json habría sido una línea de configuración. Y el Content-Type habría sido autodescriptivo, que es justo la restricción de REST que más nos gusta citar (01-04).

Qué haríamos. problem+json con extensiones propias, que el estándar permite:

{
  "type": "https://api.tiendaaroma.example/errores/stock-insuficiente",
  "title": "Stock insuficiente",
  "status": 409,
  "detail": "Quedan 1 unidades de caf_001 y se solicitaron 2",
  "instance": "/v1/pedidos",
  "codigo": "stock_insuficiente",
  "detalles": [{"cafeId": "caf_001", "disponible": 1, "solicitado": 2}]
}

La lección general: antes de inventar un formato, comprueba si ya existe uno estándar. Que el tuyo sea coherente no compensa que el mundo entero hable otro idioma.

11.3 El anidamiento de reseñas: dos caminos para lo mismo

Qué hicimos. POST /cafes/{id}/resenas para crear, GET /resenas para moderar, GET /cafes/{id}/resenas para listar las de un café.

Qué falló. Acabamos con dos rutas para el mismo recurso, y eso se paga en sitios inesperados: dos entradas en el OpenAPI que hay que mantener sincronizadas, dos rutas en las métricas por ruta —lo que dificulta responder "¿cuántas reseñas se crean al día?"—, dos reglas de rate limiting, dos rutas en el gateway, dos conjuntos de pruebas. Cuando llegó POST /resenas/{id}/respuestas, la asimetría se hizo evidente: las respuestas cuelgan de la reseña plana, no del café. Y algún cliente empezó a usar GET /resenas?cafeId=caf_001, que devuelve lo mismo que GET /cafes/caf_001/resenas pero con otra forma de paginar.

Qué haríamos. Colección plana /resenas como única fuente, con POST /resenas incluyendo cafeId en el cuerpo, y GET /cafes/{id}/resenas conservado solo como atajo de lectura documentado como tal —o eliminado, sustituido por GET /resenas?cafeId=caf_001—.

El matiz honesto: hay un argumento sólido a favor de lo que hicimos, y es que POST /cafes/{id}/resenas hace imposible crear una reseña sin café y deja el cafeId fuera del cuerpo, donde no puede falsificarse. No es una decisión obviamente mala; es una decisión cuyo coste no valoramos bien de antemano.

11.4 No reservar stock en el carrito

Qué hicimos. El stock se comprueba y descuenta solo al crear el pedido.

Qué falló. En el pico de Navidad, con lotes pequeños, aumentaron los 409 stock_insuficiente justo en el paso final de la compra. Desde el punto de vista del usuario, es la peor experiencia posible: has llegado hasta el pago y entonces te dicen que no hay.

Qué haríamos. Mantener la decisión de fondo —reservar en el carrito trae más problemas de los que resuelve— pero avisar antes: mostrar el stock restante en el carrito, avisar cuando quedan pocas unidades y comprobar disponibilidad al abrir el proceso de compra, no solo al confirmarlo. El error 409 seguiría existiendo, pero dejaría de ser la primera noticia.

La lección general: algunos problemas de diseño de API se resuelven mejor con información temprana que con más maquinaria transaccional.

Errores Comunes y Consejos

Confundir "acción" con "recurso" al primer obstáculo. En cuanto aparece una operación que no encaja en CRUD, la tentación es POST /pedidos/{id}/procesarPagoYNotificar. Pregúntate qué cosa produce o qué estado cambia. Casi siempre hay un sustantivo detrás: un pago, una anulación, una devolución.

Coherencia mal entendida. Usar el mismo modelo de paginación en toda la API suena a buena práctica, pero en Tienda Aroma habría sido un error: el catálogo y los pedidos tienen dinámicas opuestas. La coherencia que importa es la de criterios, no la de mecanismos: "colecciones pequeñas y estables por offset, colecciones grandes que crecen por delante por cursor" es una regla coherente que produce dos mecanismos distintos.

Guardar dinero en flotantes. 0.1 + 0.2 da 0.30000000000000004. En una línea no se nota; en un total de campaña, sí. Céntimos como entero por dentro, cadena con dos decimales por fuera.

Un pedido que consulta el catálogo para mostrar precios. Es el error de modelado más caro de este dominio. Un pedido es historia congelada; el catálogo es presente. Copia lo que necesites, aunque parezca redundante.

Enviar webhooks dentro de la transacción. Si la llamada a RápidoEnvíos ocurre antes del COMMIT, un fallo posterior deja notificado un pedido que no existe. Y si ocurre dentro, un transportista lento alarga la transacción y bloquea la base de datos. Confirma primero, encola después.

Prometer "exactamente una vez" en la documentación de webhooks. No es realizable sobre una red no fiable. Documenta "al menos una vez" y exige idempotencia al receptor. Es más honesto y evita integraciones rotas.

Consejo: escribe la tabla de decisiones mientras diseñas, no después. La columna que importa no es "qué hicimos", que se deduce del código, sino "qué descartamos y por qué". Dentro de un año, cuando alguien proponga cambiar el modelo de paginación, esa columna evita repetir la discusión entera.

Consejo: haz el flujo completo antes de escribir código. Escribir las diez peticiones y respuestas de una compra en un fichero de texto, encadenadas, revela huecos que ningún diagrama de recursos muestra: cabeceras que faltan, ids que nadie devolvió, estados imposibles.

Ejercicios

Ejercicio 1 — Un recurso nuevo: la suscripción

Tienda Aroma quiere lanzar suscripciones: el cliente recibe 500 g de un café cada mes, puede pausarla, reanudarla, cambiar el café o cancelarla, y cada mes se genera automáticamente un pedido.

  1. Decide si "suscripción" es un recurso y justifícalo con las tres pruebas del apartado 4.
  2. Diseña el mapa de URIs completo, incluyendo pausa, reanudación, cancelación y consulta de los pedidos generados.
  3. Indica qué _links devolverías para una suscripción en estado activa y en estado pausada.
  4. Decide el modelo de paginación de /suscripciones y justifícalo.

Ejercicio 2 — Reproducir y arreglar la sobreventa

Escribe una prueba de integración que demuestre la sobreventa cuando la comprobación de stock y el descuento van en dos pasos separados, y verifica después que la solución del apartado 8.1 la evita. Comprueba también el código y el cuerpo del error.

Ejercicio 3 — Auditoría de decisiones

Toma la tabla del apartado 6 y, para cada una de estas tres filas, argumenta el caso contrario de forma convincente: (a) _links selectivos, (b) versión en la ruta, (c) ids con prefijo. Después decide si mantendrías la decisión original y por qué. El objetivo es distinguir las decisiones bien fundamentadas de las que se sostienen solo por costumbre.

Soluciones

Solución 1

1. ¿Es un recurso? Sí, sin dudarlo. Identidad: sus_301. Estado persistente: activa, pausada, cancelada, más la fecha de la próxima entrega. Ciclo de vida: largo, con transiciones bien definidas. Cumple las tres pruebas mejor que el carrito.

2. Mapa de URIs:

GET    /v1/suscripciones                       Listar        [propietario|empleado+]
POST   /v1/suscripciones                       Crear (Idempotency-Key)
GET    /v1/suscripciones/{id}                  Detalle (ETag)
PATCH  /v1/suscripciones/{id}                  Cambiar cafe/cantidad (If-Match)
DELETE /v1/suscripciones/{id}                  Cancelar (o POST .../cancelacion)
POST   /v1/suscripciones/{id}/pausa            Pausar
POST   /v1/suscripciones/{id}/reanudacion      Reanudar
GET    /v1/suscripciones/{id}/pedidos          Pedidos generados (cursor)
GET    /v1/clientes/{id}/suscripciones         Atajo de lectura

Pausar y reanudar son subrecursos con POST, igual que /pago: son transiciones de estado, no ediciones de campos. Un PATCH {"estado":"pausada"} permitiría saltar a cualquier estado sin control.

Cambiar el café es PATCH, porque es una edición de un atributo, no una transición del ciclo de vida. La distinción es exactamente la de la decisión 1 del apartado 6.

3. Enlaces por estado:

// activa
"_links": {
  "self":         {"href": "/v1/suscripciones/sus_301"},
  "pausa":        {"href": "/v1/suscripciones/sus_301/pausa", "method": "POST"},
  "cancelacion":  {"href": "/v1/suscripciones/sus_301/cancelacion", "method": "POST"},
  "pedidos":      {"href": "/v1/suscripciones/sus_301/pedidos"}
}

// pausada
"_links": {
  "self":         {"href": "/v1/suscripciones/sus_301"},
  "reanudacion":  {"href": "/v1/suscripciones/sus_301/reanudacion", "method": "POST"},
  "cancelacion":  {"href": "/v1/suscripciones/sus_301/cancelacion", "method": "POST"},
  "pedidos":      {"href": "/v1/suscripciones/sus_301/pedidos"}
}

Una suscripción cancelada solo tendría self y pedidos: es terminal.

4. Paginación: offset. Un cliente tiene una, dos o tres suscripciones; ni el más entusiasta llegará a veinte. La colección es minúscula, estable y se consulta entera. El cursor añadiría complejidad sin resolver ningún problema. Distinto es /suscripciones/{id}/pedidos, que crece cada mes durante años y hereda el cursor de /pedidos.

Solución 2

// pruebas/integracion/stock-concurrencia.prueba.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { crearApp } from '../../src/app.js';
import { prepararBaseDePruebas } from '../ayudas/base-datos.js';

test('dos pedidos simultaneos no pueden vender mas stock del existente', async (t) => {
  const bd = prepararBaseDePruebas();
  bd.prepare('UPDATE cafes SET stock = 2 WHERE id = ?').run('caf_001');
  const app = crearApp({ bd });

  const cuerpo = { carritoId: 'car_77', direccionEnvioId: 'dir_12' };

  const [a, b] = await Promise.all([
    request(app).post('/v1/pedidos')
      .set('Authorization', `Bearer ${t.tokenDe('cli_842')}`)
      .set('Idempotency-Key', 'clave-a')
      .send(cuerpo),
    request(app).post('/v1/pedidos')
      .set('Authorization', `Bearer ${t.tokenDe('cli_001')}`)
      .set('Idempotency-Key', 'clave-b')
      .send(cuerpo)
  ]);

  const codigos = [a.status, b.status].sort();
  assert.deepEqual(codigos, [201, 409], 'uno debe crearse y el otro chocar');

  const fallido = a.status === 409 ? a : b;
  assert.equal(fallido.body.error.codigo, 'stock_insuficiente');
  assert.ok(Array.isArray(fallido.body.error.detalles));
  assert.equal(fallido.body.error.trazaId, undefined, 'trazaId solo en 5xx');

  const { stock } = bd.prepare('SELECT stock FROM cafes WHERE id = ?').get('caf_001');
  assert.equal(stock, 0, 'el stock nunca puede quedar negativo');
});

Cómo demostrar el fallo primero. Sustituye temporalmente el UPDATE ... WHERE stock >= :cantidad por dos pasos —SELECT stock y luego UPDATE stock = :nuevo— fuera de transacción. Con la versión ingenua verás dos 201 y stock = -2. Restaura la versión correcta y la prueba pasa. Esta prueba es valiosa precisamente porque falla con la implementación ingenua: una prueba que pasa con el código roto no prueba nada.

Nota: con SQLite y better-sqlite3 las escrituras se serializan, lo que ya ayuda; con PostgreSQL o MySQL, el UPDATE ... WHERE condicional dentro de la transacción es lo que garantiza la atomicidad. La condición en la propia sentencia es lo portable.

Solución 3

(a) Caso contrario a los _links selectivos. «La selectividad obliga al servidor a calcular los enlaces en cada respuesta, es más código y más pruebas, y produce una API inconsistente: unos recursos traen enlaces y otros no, lo que confunde a quien la descubre. Además, un cliente que quiera saber si un pedido es anulable debe pedirlo entero. O HATEOAS completo, o ninguno y que el cliente conozca el estado.» Veredicto: se mantiene. El argumento de la inconsistencia es real, pero se resuelve documentándola, no eliminando el valor. Los enlaces de pedido evitan que cinco consumidores reimplementen —y desincronicen— la misma máquina de estados. En los cafés no había nada que evitar.

(b) Caso contrario a la versión en la ruta. «/v1/cafes/caf_001 y /v2/cafes/caf_001 son URIs distintas para el mismo recurso, lo que contradice la identificación de recursos de 01-04. La versión es un detalle de la representación y su sitio natural es Accept. Con la versión en la ruta, cada cliente tiene enlaces hardcodeados y HATEOAS se vuelve imposible de versionar limpiamente.» Veredicto: se mantiene, con el coste reconocido. El argumento es teóricamente correcto y lo asumimos. A cambio: la versión aparece en logs, métricas y configuración del gateway, se prueba desde el navegador, se enruta sin lógica en la aplicación y responde a "¿quién sigue en v1?" con una consulta trivial. En 06-03 verás cuánto vale eso al migrar.

(c) Caso contrario a los ids con prefijo. «El prefijo mezcla el tipo con el identificador, se vuelve mentira cuando un recurso cambia de tipo o se fusiona con otro, ocupa bytes en cada respuesta y tienta a los clientes a parsear el id para deducir el tipo, creando un acoplamiento con un detalle interno.» Veredicto: se mantiene. El riesgo del parseo es real y se mitiga documentando explícitamente que el id es opaco. A cambio, cada mensaje de error, cada log y cada ticket de soporte se leen sin consultar la base de datos, y eso se cobra todos los días.

  1. Lista de verificación final del proyecto

Una lista aplicable a cualquier API que diseñes, derivada de todo lo recorrido:

Diseño

  • [ ] Los consumidores están identificados, y cada uno con su necesidad concreta.
  • [ ] Los recursos salen del dominio, no de las tablas.
  • [ ] URIs en plural, sin verbos, con anidamiento máximo de dos niveles.
  • [ ] Cada método HTTP respeta su semántica: GET seguro, PUT/DELETE idempotentes.
  • [ ] Los códigos de estado distinguen 400, 409, 412, 422 y 429 con criterio.
  • [ ] Catálogo de errores cerrado, documentado y con códigos estables.
  • [ ] Modelo de paginación elegido por dominio y justificado por escrito.
  • [ ] Estrategia de versionado y política de deprecación publicadas.

Implementación

  • [ ] Validación de toda la entrada en el borde, con esquemas.
  • [ ] Transacciones donde hay invariantes; condiciones dentro de la sentencia.
  • [ ] Concurrencia optimista con version y ETag/If-Match donde importa.
  • [ ] Idempotencia en toda operación con efectos de dinero o de terceros.
  • [ ] Errores unificados por un único manejador.
  • [ ] Dinero en enteros; fechas en ISO-8601 UTC.
  • [ ] Datos históricos copiados, no referenciados.

Seguridad

  • [ ] Autenticación y autorización por rol y por propiedad del recurso.
  • [ ] CORS con lista blanca explícita; nunca * con credenciales.
  • [ ] Rate limiting con estado compartido y 429 con Retry-After.
  • [ ] Secretos fuera del código; cabeceras de seguridad con helmet.
  • [ ] Datos personales minimizados en logs, y borrado con criterio legal validado.

Operación

  • [ ] Logs estructurados con identificador de traza propagado.
  • [ ] Métricas de las señales de oro y SLO acordados con negocio.
  • [ ] liveness y readiness diferenciados.
  • [ ] Alertas pocas y accionables, priorizadas por impacto de negocio.
  • [ ] Caché HTTP calibrada recurso a recurso.

Contrato y entrega

  • [ ] OpenAPI completo, validado con Spectral y publicado.
  • [ ] Pruebas unitarias, de integración, de contrato y e2e en CI.
  • [ ] Detección automática de cambios rompedores.
  • [ ] Despliegue sin corte con migraciones retrocompatibles y vuelta atrás.
  • [ ] Decisiones importantes registradas como ADR, con su alternativa descartada.

Conclusión

Has recorrido la API de Tienda Aroma completa, de los requisitos al despliegue, y sobre todo has visto por qué es como es. El carrito es recurso y el checkout no, porque uno tiene estado y el otro es un proceso. El dinero va en céntimos por dentro y en euros por fuera, porque la aritmética y la legibilidad piden cosas distintas. Los cafés se paginan por desplazamiento y los pedidos por cursor, porque son colecciones con dinámicas opuestas y forzar un único mecanismo habría sido coherencia mal entendida. La Idempotency-Key está en el pago porque las redes móviles reintentan solas y un timeout no dice si el servidor cobró.

Has visto también lo que rara vez se enseña: los casos difíciles resueltos con nombre y apellidos —la sobreventa cerrada con una condición dentro del UPDATE, el doble pago cerrado en dos capas, el precio congelado en la línea porque un pedido es historia y no una vista, la anonimización del cliente con la advertencia de que ese diseño lo firma un jurista y no un programador, y los webhooks entregados "al menos una vez" porque prometer más sería mentir—. Y una autocrítica honesta: el envoltorio que se quedó a medias, el problem+json que deberíamos haber usado, las reseñas con dos caminos y el stock que avisaba demasiado tarde. Ese es el material del que están hechas las memorias técnicas útiles: no la lista de aciertos, sino la de bifurcaciones con su precio.

El riesgo de una lección así es sacar la conclusión equivocada: creer que ya tienes la plantilla de una API REST y que basta con cambiar cafes por lo que toque. No es así. Cada decisión de esta lección es correcta para este dominio: un catálogo pequeño y estable, stock finito y transaccional, pocas escrituras de altísimo valor, cinco consumidores conocidos y consistencia fuerte como requisito irrenunciable. Cambia el dominio y varias de esas decisiones dejan de sostenerse.

Eso es exactamente lo que hace la siguiente lección. En 06-02, Caso de estudio: API de una red social, diseñamos CafeSocial, la red de catadores que Tienda Aroma quiere lanzar, y varias certezas de hoy se caen una a una: el grafo de seguidores obliga a modelar la relación como recurso; la línea de tiempo no es una colección normal sino un recurso derivado con el problema del fan-out detrás; la paginación por desplazamiento deja de funcionar directamente —duplicados y saltos— y el cursor opaco pasa de opción a obligación; los contadores de "me gusta" se vuelven aproximados a propósito; las imágenes ya no suben por la API; la autorización deja de ser "de quién es esto" para convertirse en "quién pregunta y qué le dejamos ver", con 404 donde la tienda respondía 403; y el tiempo real deja de ser un extra del panel para ser el producto. Terminaremos con la tabla que da sentido a los dos casos juntos, decisión a decisión, y con el motivo de que en ese dominio GraphQL sea una alternativa mucho más defendible de lo que era aquí.

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