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
- El negocio y sus restricciones
- Los consumidores: cinco clientes, cinco necesidades distintas
- Casos de uso críticos y requisitos no funcionales
- Del modelo de dominio a los recursos
- El mapa completo de URIs
- Las decisiones de diseño y sus alternativas descartadas
- El flujo completo de una compra
- Casos difíciles y cómo se resolvieron
- La arquitectura desplegada
- Métricas y SLO del servicio
- Lo que haríamos distinto
- Lista de verificación final del proyecto
- 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.
- 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.
- Casos de uso críticos y requisitos no funcionales
Cuatro casos de uso concentran el 95 % del tráfico y todo el riesgo:
- Buscar un café —
GET /cafescon filtros. Es el 70 % de las peticiones. Debe ser rapidísimo y es totalmente cacheable. - Comprar — carrito, pedido, pago. Es el 5 % de las peticiones y el 100 % de los ingresos. Debe ser correcto aunque sea lento.
- Seguir el envío —
GET /pedidos/{id}y/envio. Genera sondeo repetido: mucha caché condicional y304. - 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.
- 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 | Sí, /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 sí 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.
- 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 UITres regularidades que sostienen todo el mapa y que un consumidor nuevo aprende en cinco minutos:
- Plural siempre, sin excepciones.
/cafes, no/cafeni/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.
- 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 |
- 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.exampleHTTP/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-Keyprotege contra el reintento del mismo cliente: si la respuesta se perdió, repetir la petición devuelve la respuesta guardada sin volver a cobrar.If-Matchprotege contra la escritura sobre una versión obsoleta: si otro proceso ya cambió el pedido, responde412(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-cacheY 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, immutableimmutable 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
- 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-Idestable 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.
- 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.
- 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: avisoComprasFallando 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.
- 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.
- Decide si "suscripción" es un recurso y justifícalo con las tres pruebas del apartado 4.
- Diseña el mapa de URIs completo, incluyendo pausa, reanudación, cancelación y consulta de los pedidos generados.
- Indica qué
_linksdevolverías para una suscripción en estadoactivay en estadopausada. - Decide el modelo de paginación de
/suscripcionesy 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 lecturaPausar 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é sí 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.
- 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:
GETseguro,PUT/DELETEidempotentes. - [ ] Los códigos de estado distinguen
400,409,412,422y429con 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
versionyETag/If-Matchdonde 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
429conRetry-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.
- [ ]
livenessyreadinessdiferenciados. - [ ] 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
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
