El módulo de diseño terminó con una lista de preguntas pendientes, y la primera era la más concreta: cómo hablan exactamente los servicios. Tenemos decidido que POST /pedidos responde 202 Accepted, que Pedidos consulta el catálogo con GET /productos?ids=, que Inventario expone POST /reservas; pero un contrato no es un nombre de ruta: es la forma exacta de la petición, la respuesta, los códigos de estado, el formato de los errores y las cabeceras que ambos lados se comprometen a respetar. Esta lección convierte esas decisiones de diseño en contratos REST completos.
REST es el estilo de comunicación síncrona que TechCorp usará en dos sitios: en la API pública (lo que la web y la app móvil consumen a través del gateway del puerto 8080) y en las pocas llamadas síncronas internas que 02-02 dejó autorizadas (Pedidos → Catálogo y Pedidos → Clientes). Veremos cómo se modelan recursos y URIs, la semántica de los verbos y su idempotencia, los códigos de estado que usaremos en todo el curso, los cinco contratos clave de TechCorp con ejemplos de petición y respuesta, un formato de error uniforme basado en RFC 7807, paginación, filtrado, HATEOAS a nivel práctico, cabeceras útiles, documentación con OpenAPI 3 y, por último, un endpoint Express y un cliente fetch mínimos que aplican el contrato. El versionado se deja para 03-06, el gateway para 03-04 y la resiliencia (reintentos, circuit breakers) para 06-03: aquí solo aparece el timeout como buena práctica mínima.
Contenido
- REST en microservicios: qué es y qué no es
- Recursos y URIs
- Verbos HTTP, semántica e idempotencia
- Códigos de estado que usaremos
- Los contratos REST clave de TechCorp
- Formato de errores uniforme: RFC 7807
- Paginación, filtrado y ordenación
- HATEOAS a nivel práctico
- Cabeceras útiles
- Documentación con OpenAPI 3
- Un endpoint Express y un cliente
fetchque aplican el contrato
- REST en microservicios: qué es y qué no es
REST (Representational State Transfer) no es un protocolo ni una librería: es un conjunto de restricciones de diseño sobre HTTP. Las que nos importan en microservicios son cuatro:
- Recursos identificados por URIs. Un pedido es
/pedidos/ped-88213; un producto,/productos/p-501. La URI identifica la cosa, no la acción. - Manipulación mediante representaciones. El cliente no toca la fila de PostgreSQL: envía y recibe representaciones (JSON) del recurso.
- Interfaz uniforme. Los verbos HTTP (
GET,POST,PUT,PATCH,DELETE) tienen el mismo significado en todos los servicios. Nadie inventaPOST /pedidos/obtener. - Sin estado entre peticiones. Cada petición lleva todo lo necesario (autenticación, identificadores). Esto es lo que permite tener ocho réplicas de Catálogo detrás de un balanceador (03-05) sin que importe cuál responde.
Lo que REST no es: no es "cualquier cosa que devuelva JSON por HTTP". La diferencia entre una API REST y una API "RPC sobre HTTP" está en el uso de la interfaz uniforme, y en microservicios importa porque el contrato REST es la única frontera entre equipos: si el equipo de Pedidos (Luis) puede adivinar cómo se comporta un endpoint de Catálogo con solo leer su URI y su verbo, hay menos reuniones, menos documentación y menos sorpresas.
- Recursos y URIs
Las reglas de TechCorp para nombrar URIs, alineadas con el principio de 02-01 de que "los contratos expresan negocio":
| Regla | Bien | Mal | Por qué |
|---|---|---|---|
| Sustantivos en plural | /pedidos, /productos |
/pedido, /getPedidos |
La colección es el recurso; el verbo lo pone HTTP. |
| Jerarquías para relaciones de contención | /pedidos/ped-88213/lineas |
/lineas?pedido=ped-88213 (aceptable, pero secundario) |
Las líneas no existen sin su pedido (agregado de 02-03). |
| Identificadores opacos con prefijo | /clientes/c-1024 |
/clientes/1024 |
Los ids opacos de 02-04 no filtran detalles de la BD. |
| Minúsculas y guiones | /lineas-pedido |
/lineasPedido, /lineas_pedido |
Las URIs distinguen mayúsculas; el guion es el separador legible. |
| Sin extensiones ni verbos | /pedidos/ped-88213 |
/pedidos/ped-88213.json, /pedidos/cancelar |
El formato va en Accept; la acción, en el verbo o en un sub-recurso. |
| Nombres de negocio, no de tabla | POST /reservas |
PATCH /stock/{id} |
Ya lo decidimos en 02-01: la reserva es el concepto, el stock es el detalle. |
Un caso que confunde: acciones que no encajan en un verbo. ¿Cómo se cancela un pedido? Hay dos opciones respetables:
- Tratar la cancelación como un cambio de estado:
PATCH /pedidos/ped-88213con{"estado": "CANCELADO"}. Sencillo, pero mezcla en un mismo endpoint cambios muy distintos. - Modelar la acción como un sub-recurso:
POST /pedidos/ped-88213/cancelacion. Crea "una cancelación" del pedido, con su propio cuerpo (motivo) y su propia validación. Es la que usará TechCorp, porque la saga de 02-05 trata la cancelación como un hecho de negocio conmotivo, no como una edición de campo.
Lo que nunca haremos es POST /pedidos/ped-88213/cancelar con el verbo en la URI, ni exponer la estructura interna: /pedidos/ped-88213/lineas sí, /lineas_pedido?pedido_id= no.
- Verbos HTTP, semántica e idempotencia
Cada verbo tiene un significado y dos propiedades que en sistemas distribuidos son críticas: si es seguro (no modifica estado) y si es idempotente (repetirlo N veces tiene el mismo efecto que hacerlo una vez).
| Verbo | Significado | Seguro | Idempotente | Uso en TechCorp |
|---|---|---|---|---|
GET |
Leer una representación | Sí | Sí | GET /pedidos/{id}, GET /productos?ids= |
POST |
Crear un recurso subordinado o ejecutar un proceso | No | No | POST /pedidos, POST /reservas |
PUT |
Reemplazar el recurso completo en esa URI | No | Sí | PUT /clientes/{id}/direccion (reemplazar la dirección) |
PATCH |
Modificar parcialmente | No | Depende del cuerpo | PATCH /productos/{id} con {"precio": 54.90} |
DELETE |
Eliminar | No | Sí | DELETE /reservas/{id} (liberar una reserva) |
Por qué importa la idempotencia: en 01-02 vimos que la red no es fiable. Si Pedidos llama a PUT /clientes/c-1024/direccion y la respuesta se pierde, puede repetir la llamada sin miedo: la dirección quedará igual. Si repite un POST /reservas que ya se procesó, habrá dos reservas y el stock de p-501 bajará dos veces. Por eso, en 02-05 introdujimos la cabecera Idempotency-Key para POST /pedidos: convierte un POST en repetible sin cambiar su semántica. La aplicaremos igual a POST /reservas.
Sobre PATCH: {"precio": 54.90} es idempotente (repetirlo deja el mismo precio); {"incrementarStock": 5} no lo es. Regla de TechCorp: los PATCH describen el estado deseado, nunca deltas.
- Códigos de estado que usaremos
No hace falta memorizar los ~60 códigos HTTP. TechCorp usa un subconjunto cerrado, y todos los servicios lo aplican igual (lo empaquetaremos en @techcorp/comun-http, la librería técnica de 02-02):
| Código | Nombre | Cuándo lo devolvemos | Ejemplo en TechCorp |
|---|---|---|---|
200 OK |
Éxito con cuerpo | Lecturas y modificaciones que devuelven el recurso | GET /pedidos/ped-88213 |
201 Created |
Recurso creado ya disponible | Creación síncrona completa; lleva Location |
POST /reservas (la reserva existe al responder) |
202 Accepted |
Petición aceptada, proceso en curso | Creación que dispara una saga; lleva Location |
POST /pedidos (decisión de 02-05) |
204 No Content |
Éxito sin cuerpo | DELETE, algunos PUT |
DELETE /reservas/res-4471 |
400 Bad Request |
Petición malformada | JSON inválido, campo obligatorio ausente, tipo incorrecto | POST /pedidos sin lineas |
401 Unauthorized |
No autenticado | Falta o es inválido el token JWT (07-01) | Cualquier ruta protegida |
403 Forbidden |
Autenticado pero sin permiso | Cliente que intenta leer el pedido de otro | GET /pedidos/ped-99000 ajeno |
404 Not Found |
El recurso no existe | Id inexistente | GET /clientes/c-9999 |
409 Conflict |
Conflicto con el estado actual | Sin stock, versión obsoleta (If-Match), transición de estado inválida |
POST /reservas sin stock; cancelar un pedido ya CONFIRMADO |
422 Unprocessable Entity |
Sintaxis correcta, semántica no | Cantidad negativa, código postal que no existe | POST /pedidos con cantidad: -1 |
429 Too Many Requests |
Límite de peticiones superado | Rate limiting en el gateway (03-04) | 1.000 peticiones/min desde una IP |
500 Internal Server Error |
Fallo no controlado del servidor | Excepción no capturada, BD caída sin manejar | Nunca a propósito |
503 Service Unavailable |
Servicio no disponible temporalmente | Arranque, dependencia caída, mantenimiento; puede llevar Retry-After |
Pedidos cuando Catálogo no responde |
Dos matices que TechCorp fija por convenio para que todos los equipos respondan igual:
400frente a422.400es "no entiendo tu petición" (no es JSON, falta un campo, un número viene como texto).422es "la entiendo pero no tiene sentido" (cantidad0, fecha de entrega en el pasado). La distinción ayuda al cliente a saber si el error es de serialización o de negocio.404frente a409en errores de negocio. Del monolito heredamos cuatro errores:CLIENTE_NO_EXISTE→404,PRODUCTO_NO_DISPONIBLE→400,SIN_STOCK→409,PAGO_RECHAZADO→402. En la arquitectura nuevaPOST /pedidosresponde202antes de saber si hay stock o si el pago pasa, así queSIN_STOCKyPAGO_RECHAZADOdejan de ser respuestas HTTP de ese endpoint y pasan a ser motivos depedido.cancelado.SIN_STOCKsigue siendo un409dePOST /reservas(Inventario).PRODUCTO_NO_DISPONIBLEpasa a422(la petición es correcta, pero pide algo que no está a la venta).
- Los contratos REST clave de TechCorp
5.1 POST /pedidos (Pedidos, puerto 3002)
Es el contrato más importante del curso. Petición: el cliente envía solo lo que sabe: qué quiere y a dónde. No envía nombres ni precios de producto (los congela Pedidos consultando Catálogo, como fija el agregado de 02-03), ni el total (lo calcula Pedidos).
POST /pedidos HTTP/1.1
Host: servicio-pedidos:3002
Content-Type: application/json
Accept: application/json
Idempotency-Key: 7f3c9a2e-1b4d-4e8f-9c21-5a6b7c8d9e0f
X-Request-Id: req-01J4ZK9X2M
{
"clienteId": "c-1024",
"lineas": [
{ "productoId": "p-501", "cantidad": 1 },
{ "productoId": "p-777", "cantidad": 2 }
],
"direccionEnvio": {
"calle": "Gran Vía 12",
"codigoPostal": "28013",
"ciudad": "Madrid",
"pais": "ES"
}
}Respuesta: 202 Accepted porque, como decidimos en 02-05, la saga (reserva de stock, cobro, confirmación) sigue en curso. Location dice dónde consultar el progreso. El cuerpo devuelve el pedido tal y como quedó guardado, con los precios ya congelados y el estado PENDIENTE.
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /pedidos/ped-88213
X-Request-Id: req-01J4ZK9X2M
{
"id": "ped-88213",
"estado": "PENDIENTE",
"clienteId": "c-1024",
"lineas": [
{ "productoId": "p-501", "nombre": "Auriculares BT X200", "cantidad": 1, "precioUnitario": 59.90 },
{ "productoId": "p-777", "nombre": "Cable USB-C 2 m", "cantidad": 2, "precioUnitario": 9.90 }
],
"total": 79.70,
"direccionEnvio": { "calle": "Gran Vía 12", "codigoPostal": "28013", "ciudad": "Madrid", "pais": "ES" },
"creadoEn": "2026-08-15T10:32:07Z",
"_links": {
"self": { "href": "/pedidos/ped-88213" },
"cancelar": { "href": "/pedidos/ped-88213/cancelacion", "method": "POST" }
}
}Comportamiento con Idempotency-Key: si el mismo cliente repite la petición con la misma clave (porque perdió la respuesta), Pedidos consulta claves_idempotencia (02-05) y devuelve exactamente la misma respuesta, sin crear otro pedido. Si la clave se reutiliza con un cuerpo distinto, responde 422 con código CLAVE_IDEMPOTENCIA_REUTILIZADA.
Errores posibles de este endpoint (todos en el formato del apartado 6):
| Situación | Código | codigo |
|---|---|---|
Falta lineas o clienteId, JSON inválido |
400 |
PETICION_INVALIDA |
cantidad ≤ 0, lineas vacío, país no soportado |
422 |
DATOS_NO_VALIDOS |
El cliente no existe (Pedidos lo comprueba en clientes_ref o llamando a Clientes) |
404 |
CLIENTE_NO_EXISTE |
| Algún producto no está a la venta según Catálogo | 422 |
PRODUCTO_NO_DISPONIBLE |
| Catálogo no responde a tiempo | 503 |
DEPENDENCIA_NO_DISPONIBLE |
5.2 GET /pedidos/{id}
Es la lectura que sigue al 202: la web hace polling (o recibe una notificación) hasta que el estado deja de ser PENDIENTE.
GET /pedidos/ped-88213 HTTP/1.1
Host: servicio-pedidos:3002
Accept: application/json
If-None-Match: "v3"HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v4"
{
"id": "ped-88213",
"estado": "CONFIRMADO",
"clienteId": "c-1024",
"lineas": [ "..." ],
"total": 79.70,
"historial": [
{ "estado": "PENDIENTE", "en": "2026-08-15T10:32:07Z" },
{ "estado": "STOCK_RESERVADO", "en": "2026-08-15T10:32:08Z" },
{ "estado": "PAGADO", "en": "2026-08-15T10:32:10Z" },
{ "estado": "CONFIRMADO", "en": "2026-08-15T10:32:10Z" }
],
"_links": { "self": { "href": "/pedidos/ped-88213" } }
}Si el pedido no cambió desde la versión "v3", el servidor responde 304 Not Modified sin cuerpo (ahorra ancho de banda en el polling). Si el pedido es de otro cliente, 403; si no existe, 404.
5.3 GET /productos?ids=p-501,p-777 (Catálogo, puerto 3001)
Es la llamada síncrona interna más frecuente: Pedidos necesita nombre, precio y disponibilidad de cada línea para congelarlos. Diseñar GET /productos/{id} y llamarlo una vez por línea sería el clásico problema N+1: un pedido de 20 líneas serían 20 viajes de red. Por eso el contrato acepta un lote:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=30
{
"datos": [
{ "id": "p-501", "nombre": "Auriculares BT X200", "precio": 59.90, "moneda": "EUR", "disponible": true },
{ "id": "p-777", "nombre": "Cable USB-C 2 m", "precio": 9.90, "moneda": "EUR", "disponible": true }
],
"noEncontrados": []
}Decisiones de contrato que conviene explicar:
- Los ids que no existen no provocan
404. Un lote es una consulta a la colección; que falte uno de los ids no hace fallar la petición. Se devuelven ennoEncontradosy es Pedidos quien decide qué hacer (responder422 PRODUCTO_NO_DISPONIBLE). - Límite de lote. Máximo 100 ids; por encima,
400. Sin límite, alguien acabaría pidiendo 5.000 productos en una URL. Cache-Control: max-age=30. El catálogo cambia poco; permitir 30 s de caché al cliente reduce carga en los picos ×20.- Este JSON es el published language de 02-03; Pedidos lo traduce con su
traductorProductoa su propio modelo, de modo que si Catálogo cambiaprecio(lo veremos en 03-06), solo cambia el traductor.
5.4 GET /clientes/{id} (Clientes, puerto 3004)
Pedidos suele resolver el cliente en su réplica local clientes_ref (02-04), pero cuando la réplica no lo tiene todavía (cliente recién registrado) hace esta llamada:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "c-1024:7"
{
"id": "c-1024",
"nombre": "Ana Ruiz",
"email": "[email protected]",
"direcciones": [
{ "id": "dir-1", "calle": "Gran Vía 12", "codigoPostal": "28013", "ciudad": "Madrid", "pais": "ES", "predeterminada": true }
]
}Nótese lo que no devuelve: contraseña, tokens, datos de pago. La representación es una vista de negocio del cliente, no un volcado de la tabla.
5.5 POST /reservas (Inventario, puerto 3006)
En la saga por coreografía de 02-05, Inventario reserva stock reaccionando al evento pedido.creado, no por una llamada HTTP. ¿Para qué entonces un POST /reservas? Por tres motivos: para el panel de administración interno, para pruebas, y porque en 02-03 dejamos abierta una relación partnership Pedidos↔Inventario que en el futuro podría pasar a síncrona (gRPC en 03-03). Diseñar el contrato ahora cuesta poco y fija el vocabulario.
POST /reservas HTTP/1.1
Host: servicio-inventario:3006
Content-Type: application/json
Idempotency-Key: ped-88213
{
"pedidoId": "ped-88213",
"lineas": [
{ "productoId": "p-501", "cantidad": 1 },
{ "productoId": "p-777", "cantidad": 2 }
],
"expiraEnSegundos": 900
}Respuesta cuando hay stock. Aquí sí es 201, porque la reserva queda hecha en el mismo instante (una transacción local en la BD de Inventario):
HTTP/1.1 201 Created
Content-Type: application/json
Location: /reservas/res-4471
{
"id": "res-4471",
"pedidoId": "ped-88213",
"estado": "ACTIVA",
"expiraEn": "2026-08-15T10:47:07Z",
"lineas": [
{ "productoId": "p-501", "cantidad": 1 },
{ "productoId": "p-777", "cantidad": 2 }
]
}Y cuando no hay stock, un 409 con detalle de qué falta (información que la saga convierte en stock.rechazado):
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://techcorp.example/errores/sin-stock",
"title": "Stock insuficiente",
"status": 409,
"detail": "No hay unidades suficientes de 1 producto",
"codigo": "SIN_STOCK",
"instance": "/reservas",
"faltantes": [ { "productoId": "p-501", "solicitado": 1, "disponible": 0 } ]
}Usar pedidoId como Idempotency-Key es deliberado: "una reserva por pedido" es exactamente la garantía que queremos.
- Formato de errores uniforme: RFC 7807
En el monolito, cada controlador devolvía los errores como le parecía: a veces {"error": "..."}, a veces {"mensaje": "..."}, a veces HTML. Con seis servicios y cuatro equipos, eso se multiplica. RFC 7807 (Problem Details for HTTP APIs) define un formato estándar con tipo MIME application/problem+json:
| Campo | Obligatorio | Significado |
|---|---|---|
type |
Sí (por defecto about:blank) |
URI que identifica el tipo de problema. No tiene por qué resolver a nada, pero es útil que apunte a documentación. |
title |
Sí | Resumen legible, igual para todos los errores del mismo tipo. |
status |
Sí | El código HTTP, repetido en el cuerpo (útil cuando el cuerpo se registra o reenvía sin las cabeceras). |
detail |
No | Explicación específica de esta ocurrencia. |
instance |
No | URI de la petición concreta que falló. |
| extensiones | No | Cualquier campo adicional. TechCorp añade siempre codigo. |
El campo codigo es la extensión que TechCorp estandariza: un identificador estable en MAYÚSCULAS_CON_GUIONES (CLIENTE_NO_EXISTE, SIN_STOCK, PRODUCTO_NO_DISPONIBLE, PETICION_INVALIDA, DATOS_NO_VALIDOS, DEPENDENCIA_NO_DISPONIBLE, VERSION_OBSOLETA, CLAVE_IDEMPOTENCIA_REUTILIZADA). Los clientes hacen switch sobre codigo, nunca sobre detail, que es texto para humanos y puede cambiar.
Ejemplo de error de validación con detalle por campo (extensión errores):
{
"type": "https://techcorp.example/errores/datos-no-validos",
"title": "Los datos de la petición no son válidos",
"status": 422,
"detail": "1 campo no supera la validación",
"codigo": "DATOS_NO_VALIDOS",
"instance": "/pedidos",
"errores": [
{ "campo": "lineas[1].cantidad", "mensaje": "debe ser mayor que 0" }
]
}Mapeo de los errores heredados del monolito al modelo nuevo:
| Error del monolito | Código HTTP antes | Ahora: dónde aparece | Código HTTP ahora |
|---|---|---|---|
CLIENTE_NO_EXISTE |
404 | POST /pedidos (validación previa) |
404 |
PRODUCTO_NO_DISPONIBLE |
400 | POST /pedidos tras consultar Catálogo |
422 |
SIN_STOCK |
409 | POST /reservas (Inventario); en la saga, motivo de pedido.cancelado |
409 |
PAGO_RECHAZADO |
402 | Ya no es respuesta HTTP: es el evento pago.rechazado y el motivo PAGO_RECHAZADO de pedido.cancelado |
— |
Un consejo de seguridad: en 500 el detail nunca incluye el mensaje de la excepción ni la traza (filtraría rutas, consultas SQL, nombres de host). Se registra en el log con el X-Request-Id y el cliente recibe un detail genérico con ese identificador para poder reportarlo.
- Paginación, filtrado y ordenación
Toda colección que pueda crecer se pagina; GET /pedidos sin límite acabaría devolviendo 3.000 pedidos diarios acumulados durante años.
| Estrategia | Petición | Ventajas | Inconvenientes | Uso en TechCorp |
|---|---|---|---|---|
| Offset | GET /productos?limite=20&desplazamiento=40 |
Sencilla; permite saltar a la página N; fácil en SQL (LIMIT 20 OFFSET 40) |
Lenta en offsets grandes; si se inserta un elemento entre dos páginas, se repite o se salta uno | Panel de administración de catálogo (colecciones pequeñas, se necesita "ir a la página 7") |
| Cursor | GET /pedidos?limite=20&cursor=eyJjcmVhZG9FbiI6Li4ufQ |
Estable ante inserciones; rendimiento constante (WHERE (creado_en, id) < (...)) |
No permite saltar a una página arbitraria; el cursor es opaco | Historial de pedidos del cliente, listados de eventos: colecciones grandes que crecen por un extremo |
Respuesta paginada por cursor. El cursor es una cadena opaca (habitualmente base64 de {creadoEn, id} del último elemento) que el cliente devuelve tal cual:
{
"datos": [
{ "id": "ped-88213", "estado": "CONFIRMADO", "total": 79.70, "creadoEn": "2026-08-15T10:32:07Z" },
{ "id": "ped-88102", "estado": "CONFIRMADO", "total": 24.50, "creadoEn": "2026-08-14T18:05:44Z" }
],
"paginacion": {
"limite": 2,
"siguienteCursor": "eyJjcmVhZG9FbiI6IjIwMjYtMDgtMTRUMTg6MDU6NDRaIiwiaWQiOiJwZWQtODgxMDIifQ",
"haySiguiente": true
},
"_links": {
"self": { "href": "/pedidos?clienteId=c-1024&limite=2&orden=-creadoEn" },
"siguiente": { "href": "/pedidos?clienteId=c-1024&limite=2&orden=-creadoEn&cursor=eyJjcmVhZG9FbiI6..." }
}
}Convenios de TechCorp para filtrado y ordenación:
- Filtros como parámetros de query con el nombre del campo:
?estado=CONFIRMADO,?clienteId=c-1024. Rangos con sufijos:?creadoDesde=2026-08-01&creadoHasta=2026-08-15. - Ordenación con
orden=campoascendente yorden=-campodescendente; varios campos separados por coma:orden=-creadoEn,id. limitecon valor por defecto (20) y máximo (100). Un cliente que pide 10.000 recibe400.- Solo se admiten filtros y órdenes documentados en OpenAPI; un parámetro desconocido se ignora (tolerancia, que 03-06 formalizará), pero un valor inválido en uno conocido da
400.
- HATEOAS a nivel práctico
HATEOAS (Hypermedia As The Engine Of Application State) es la restricción de REST que dice que la respuesta debe incluir enlaces a las acciones posibles, de modo que el cliente "navegue" la API en lugar de construir URIs. Llevado al extremo produce APIs difíciles de consumir y pocos lo aplican del todo. TechCorp adopta una versión pragmática:
- Un objeto
_linksconselfsiempre. - Enlaces a acciones que dependen del estado: un pedido
PENDIENTEincluyecancelar; unoCONFIRMADOincluyefacturapero nocancelar. Así el front-end no duplica la máquina de estados de 02-05 para saber qué botón mostrar. - Enlaces de paginación (
siguiente,anterior). - Nada más. No se enlazan recursos de otros servicios (un pedido no enlaza al
/clientes/{id}de Clientes), porque el cliente entra siempre por el gateway y las URIs internas no le sirven.
{
"id": "ped-88213",
"estado": "PENDIENTE",
"_links": {
"self": { "href": "/pedidos/ped-88213" },
"cancelar": { "href": "/pedidos/ped-88213/cancelacion", "method": "POST" }
}
}
- Cabeceras útiles
| Cabecera | Dirección | Para qué la usamos |
|---|---|---|
Content-Type: application/json |
Petición y respuesta | Formato del cuerpo. Los errores usan application/problem+json. |
Accept: application/json |
Petición | Formato deseado. Si el servidor no puede, 406. En 03-06 servirá también para versionar. |
Location |
Respuesta 201/202 |
URI del recurso creado o del que consultar el progreso. |
ETag / If-None-Match |
Respuesta / petición GET |
Caché condicional: 304 si no cambió. |
ETag / If-Match |
Respuesta / petición PUT/PATCH |
Concurrencia optimista: el cliente dice "modifica solo si sigue en la versión que yo vi"; si no, 412 Precondition Failed (o 409 con VERSION_OBSOLETA, el convenio de TechCorp para uniformar). |
Idempotency-Key |
Petición POST |
Repetir sin duplicar (02-05). UUID generado por el cliente. |
X-Request-Id |
Ambas | Identificador de correlación: lo genera el gateway si no viene, cada servicio lo propaga en sus llamadas y en sus logs. Es la semilla de la trazabilidad distribuida que se ve en 06-02. |
Cache-Control |
Respuesta | max-age en lecturas cacheables (catálogo); no-store en pedidos y datos personales. |
Retry-After |
Respuesta 429/503 |
Cuántos segundos esperar antes de reintentar. |
Ejemplo de concurrencia optimista: dos administradores editan el precio de p-501 a la vez.
PATCH /productos/p-501 HTTP/1.1
Content-Type: application/json
If-Match: "p-501:12"
{ "precio": 54.90 }Si el producto ya está en la versión 13 porque el otro administrador guardó antes, Catálogo responde 409 con codigo: VERSION_OBSOLETA y el cliente recarga y decide. Sin If-Match, el segundo guardado pisaría al primero en silencio.
- Documentación con OpenAPI 3
Un contrato que solo vive en la cabeza de Luis no es un contrato. OpenAPI 3 es la especificación estándar (YAML o JSON) para describir APIs REST: rutas, parámetros, cuerpos, respuestas y esquemas. De ella se generan documentación navegable (Swagger UI, Redoc), clientes, validadores de peticiones y, en 04-05, pruebas. Fragmento de la especificación de Pedidos y Catálogo:
openapi: 3.0.3
info:
title: TechCorp - API de Pedidos
version: 1.0.0
servers:
- url: http://servicio-pedidos:3002
paths:
/pedidos:
post:
summary: Crear un pedido
operationId: crearPedido
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NuevoPedido' }
responses:
'202':
description: Pedido aceptado; la saga sigue en curso
headers:
Location:
schema: { type: string, example: /pedidos/ped-88213 }
content:
application/json:
schema: { $ref: '#/components/schemas/Pedido' }
'404':
$ref: '#/components/responses/ClienteNoExiste'
'422':
$ref: '#/components/responses/DatosNoValidos'
components:
schemas:
NuevoPedido:
type: object
required: [clienteId, lineas, direccionEnvio]
properties:
clienteId: { type: string, example: c-1024 }
lineas:
type: array
minItems: 1
items:
type: object
required: [productoId, cantidad]
properties:
productoId: { type: string, example: p-501 }
cantidad: { type: integer, minimum: 1 }
direccionEnvio: { $ref: '#/components/schemas/Direccion' }
Pedido:
type: object
properties:
id: { type: string, example: ped-88213 }
estado:
type: string
enum: [PENDIENTE, STOCK_RESERVADO, PAGADO, CONFIRMADO, CANCELADO]
total: { type: number, format: double, example: 79.70 }
Problema:
type: object
required: [type, title, status, codigo]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
codigo: { type: string, example: CLIENTE_NO_EXISTE }
responses:
ClienteNoExiste:
description: El cliente indicado no existe
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problema' }
DatosNoValidos:
description: Los datos no superan la validación de negocio
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problema' }Y el lote de Catálogo, en su propio documento (cada servicio publica su OpenAPI; no hay un fichero global):
paths:
/productos:
get:
summary: Obtener productos por lote de ids
operationId: obtenerProductos
parameters:
- in: query
name: ids
required: true
description: Ids separados por coma (máximo 100)
schema: { type: string, example: "p-501,p-777" }
responses:
'200':
description: Productos encontrados; los ids inexistentes van en noEncontrados
content:
application/json:
schema:
type: object
properties:
datos:
type: array
items: { $ref: '#/components/schemas/Producto' }
noEncontrados:
type: array
items: { type: string }Cómo se lee: paths agrupa rutas; dentro de cada verbo, parameters describe query/cabeceras/ruta, requestBody el cuerpo, y responses cada código con su esquema. components evita repetir esquemas y respuestas. En 03-06 veremos que escribir este YAML antes del código (design-first) es la forma de que Pedidos y Catálogo trabajen en paralelo.
- Un endpoint Express y un cliente
fetch que aplican el contrato
fetch que aplican el contratoNo montamos aún el servicio completo (estructura, configuración y arranque son del módulo 4); solo el trozo que materializa el contrato de POST /pedidos.
// rutas/pedidos.js (servicio-pedidos) — solo la ruta, sin el resto del servicio
const express = require('express');
const { randomUUID } = require('node:crypto');
const enrutador = express.Router();
// 1. Validación de forma (400) y de negocio básica (422)
function validarNuevoPedido(cuerpo) {
if (!cuerpo || typeof cuerpo.clienteId !== 'string' || !Array.isArray(cuerpo.lineas)) {
return { status: 400, codigo: 'PETICION_INVALIDA', detail: 'Faltan clienteId o lineas' };
}
const errores = [];
if (cuerpo.lineas.length === 0) errores.push({ campo: 'lineas', mensaje: 'debe tener al menos una línea' });
cuerpo.lineas.forEach((l, i) => {
if (!Number.isInteger(l.cantidad) || l.cantidad < 1) {
errores.push({ campo: `lineas[${i}].cantidad`, mensaje: 'debe ser un entero mayor que 0' });
}
});
if (errores.length > 0) {
return { status: 422, codigo: 'DATOS_NO_VALIDOS', detail: `${errores.length} campo(s) no válido(s)`, errores };
}
return null;
}
// 2. Helper para responder errores en formato RFC 7807
function responderProblema(res, req, problema) {
res.status(problema.status)
.type('application/problem+json')
.json({
type: `https://techcorp.example/errores/${problema.codigo.toLowerCase().replace(/_/g, '-')}`,
title: problema.title ?? 'Error en la petición',
status: problema.status,
detail: problema.detail,
codigo: problema.codigo,
instance: req.originalUrl,
...(problema.errores && { errores: problema.errores })
});
}
// 3. La ruta: valida, delega en el caso de uso, responde 202 + Location
enrutador.post('/pedidos', async (req, res, next) => {
const claveIdempotencia = req.get('Idempotency-Key');
if (!claveIdempotencia) {
return responderProblema(res, req, { status: 400, codigo: 'PETICION_INVALIDA', detail: 'Falta la cabecera Idempotency-Key' });
}
const problema = validarNuevoPedido(req.body);
if (problema) return responderProblema(res, req, problema);
try {
// crearPedido consulta Catálogo, congela precios, guarda pedido + evento en outbox (02-05)
// y devuelve el pedido en estado PENDIENTE. Su implementación completa es del módulo 4.
const pedido = await req.app.locals.casosDeUso.crearPedido(req.body, {
claveIdempotencia,
requestId: req.get('X-Request-Id') ?? randomUUID()
});
res.status(202)
.location(`/pedidos/${pedido.id}`)
.json({
...pedido,
_links: {
self: { href: `/pedidos/${pedido.id}` },
cancelar: { href: `/pedidos/${pedido.id}/cancelacion`, method: 'POST' }
}
});
} catch (err) {
// Errores de negocio conocidos → códigos del contrato; el resto → 500 (middleware de errores)
if (err.codigo === 'CLIENTE_NO_EXISTE') return responderProblema(res, req, { status: 404, codigo: err.codigo, detail: err.message });
if (err.codigo === 'PRODUCTO_NO_DISPONIBLE') return responderProblema(res, req, { status: 422, codigo: err.codigo, detail: err.message });
if (err.codigo === 'DEPENDENCIA_NO_DISPONIBLE') return responderProblema(res, req, { status: 503, codigo: err.codigo, detail: err.message });
next(err);
}
});
module.exports = enrutador;Explicación paso a paso:
validarNuevoPedidosepara los dos niveles: si la forma es incorrecta devuelve400; si la forma está bien pero los valores no,422con la lista de campos. Es exactamente la distinción del apartado 4.responderProblemacentraliza el formato RFC 7807. En el proyecto real esta función vive en@techcorp/comun-httppara que Catálogo, Inventario y el resto respondan idéntico.- La ruta comprueba
Idempotency-Key, valida, delega en el caso de uso (que ya conocemos de 02-05: guarda el pedido y supedido.creadoen la misma transacción conguardarConEventos()), y responde202conLocation. Fíjate en que la ruta no sabe nada de RabbitMQ ni de stock: solo traduce HTTP a negocio y negocio a HTTP. - Los errores de negocio se mapean a códigos del contrato; cualquier otro va al middleware de errores de Express, que responde
500sin filtrar detalles.
Y el cliente: cómo Pedidos llama al lote de Catálogo. Node.js 20 incluye fetch de forma nativa, y AbortSignal.timeout es la manera mínima de no quedarse esperando para siempre.
// clientes/catalogoCliente.js (dentro de servicio-pedidos)
const CATALOGO_URL = process.env.CATALOGO_URL ?? 'http://servicio-catalogo:3001';
async function obtenerProductos(ids, { requestId }) {
const url = `${CATALOGO_URL}/productos?ids=${encodeURIComponent(ids.join(','))}`;
let respuesta;
try {
respuesta = await fetch(url, {
headers: { 'Accept': 'application/json', 'X-Request-Id': requestId },
// Si Catálogo no responde en 2 s, fetch lanza un error de tipo TimeoutError
signal: AbortSignal.timeout(2000)
});
} catch (err) {
// Timeout o error de red: para el contrato de POST /pedidos esto es un 503
const error = new Error(`Catálogo no disponible: ${err.name}`);
error.codigo = 'DEPENDENCIA_NO_DISPONIBLE';
throw error;
}
if (!respuesta.ok) {
// Catálogo respondió, pero con error: leemos el problem+json para registrar el codigo
const problema = await respuesta.json().catch(() => ({}));
const error = new Error(`Catálogo respondió ${respuesta.status} (${problema.codigo ?? 'sin código'})`);
error.codigo = respuesta.status >= 500 ? 'DEPENDENCIA_NO_DISPONIBLE' : 'PETICION_INVALIDA';
throw error;
}
const { datos, noEncontrados } = await respuesta.json();
if (noEncontrados.length > 0) {
const error = new Error(`Productos no disponibles: ${noEncontrados.join(', ')}`);
error.codigo = 'PRODUCTO_NO_DISPONIBLE';
throw error;
}
return datos; // el traductorProducto (ACL de 02-03) los convertirá al modelo de Pedidos
}
module.exports = { obtenerProductos };Puntos clave del cliente: la URL base viene de configuración (CATALOGO_URL, cuyo valor por defecto es el nombre estable del servicio que justificaremos en 03-05 y gestionaremos en 04-03); se propaga X-Request-Id; hay un timeout de 2 s (sin él, una caída de Catálogo agotaría las conexiones de Pedidos); se distingue "no respondió" de "respondió con error"; y noEncontrados se traduce al error de negocio del contrato de POST /pedidos. Lo que no hay aquí (reintentos, circuit breaker, fallback) es materia de 06-03.
Errores Comunes y Consejos
- Verbos en las URIs (
/pedidos/crear,/productos/buscar). Si te sorprendes escribiendo un verbo, pregúntate cuál es el recurso: casi siempre es una colección (POST /pedidos) o un sub-recurso (POST /pedidos/{id}/cancelacion). - Devolver
200para todo y meter el error en el cuerpo ({"ok": false}). Rompe la caché HTTP, los monitores del gateway y la intuición de cualquier cliente. Usa la tabla del apartado 4. 201cuando el trabajo no ha terminado. Si al responder aún no sabes si habrá stock, es202. Un201promete que el recurso está completo.- N+1 por comodidad.
GET /productos/{id}en un bucle parece limpio hasta que un pedido de 15 líneas tarda 15 × 40 ms. Diseña lotes (?ids=) desde el principio. Idempotency-Keysin persistencia. Guardar las claves en memoria del proceso no sirve con dos réplicas: la tablaclaves_idempotenciade 02-05 es obligatoria.- Mensajes de error como contrato. Los clientes deben mirar
codigo, nodetail. Cambiar un texto no debería romper a nadie. - Filtrar internos en errores
500. Nuncadetail: err.stack. Log completo conX-Request-Id; al cliente, un mensaje genérico con ese id. - Olvidar el timeout en el cliente.
fetchsinsignalespera indefinidamente. Dos segundos para llamadas internas es un buen punto de partida. - Documentar después. El OpenAPI escrito a posteriori se desactualiza. Escríbelo primero (03-06) y valida las peticiones contra él.
Ejercicios
Ejercicio 1. Diseña el contrato REST de "cancelar un pedido" para el servicio de Pedidos: URI y verbo, cuerpo de petición (con motivo), respuestas de éxito y de error con sus códigos y codigo, teniendo en cuenta la máquina de estados de 02-05 (solo se puede cancelar en PENDIENTE, STOCK_RESERVADO o PAGADO; no en CONFIRMADO ni si ya está CANCELADO). Escribe un ejemplo http de petición y de una respuesta de error.
Ejercicio 2. El equipo de Experiencia de compra propone GET /clientes/c-1024/pedidos (en el servicio de Clientes) para que la web muestre el historial de pedidos de un cliente. Razona si ese endpoint debe vivir en Clientes o en Pedidos, qué URI propondrías, y qué estrategia de paginación (offset o cursor) elegirías y por qué. Escribe la respuesta JSON de la primera página con dos pedidos.
Ejercicio 3. Escribe una función Express patch('/productos/:id') para Catálogo que aplique concurrencia optimista con If-Match. Supón que existe repositorio.obtener(id) que devuelve { ...producto, version } y repositorio.actualizarSiVersion(id, cambios, versionEsperada) que devuelve el producto actualizado o null si la versión no coincide. Responde 428 Precondition Required si falta If-Match, 404 si no existe, 409 VERSION_OBSOLETA si la versión no coincide y 200 con nuevo ETag si va bien.
Soluciones
Solución 1.
Contrato: POST /pedidos/{id}/cancelacion. La cancelación es un hecho de negocio con datos propios (motivo, quién cancela), no la edición de un campo, y POST es el verbo para "crear un sub-recurso". Debe aceptar Idempotency-Key (o, más sencillo, ser idempotente por diseño: cancelar un pedido ya cancelado por el mismo motivo devuelve 200 con el mismo cuerpo).
POST /pedidos/ped-88213/cancelacion HTTP/1.1
Content-Type: application/json
Idempotency-Key: 2c1e5f9a-8b3d-4a7c-9e6f-1d2c3b4a5f60
{ "motivo": "CLIENTE_SE_ARREPIENTE", "comentario": "Pedido duplicado por error" }Respuestas:
| Situación | Código | codigo |
|---|---|---|
Cancelación aceptada (dispara pedido.cancelado; si había reserva o pago, la saga los compensa) |
202 (la compensación es asíncrona; el estado del pedido pasa a CANCELADO de inmediato pero la liberación de stock y el reembolso siguen en curso) con Location: /pedidos/ped-88213 |
— |
| Pedido no existe | 404 |
PEDIDO_NO_EXISTE |
| Pedido de otro cliente | 403 |
ACCESO_DENEGADO |
Pedido en CONFIRMADO |
409 |
TRANSICION_NO_PERMITIDA |
Falta motivo |
400 |
PETICION_INVALIDA |
motivo no está en la lista permitida |
422 |
DATOS_NO_VALIDOS |
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://techcorp.example/errores/transicion-no-permitida",
"title": "La transición de estado no está permitida",
"status": 409,
"detail": "El pedido ped-88213 está CONFIRMADO y ya no admite cancelación",
"codigo": "TRANSICION_NO_PERMITIDA",
"instance": "/pedidos/ped-88213/cancelacion",
"estadoActual": "CONFIRMADO"
}Se puede argumentar 200 en lugar de 202 si se considera que el pedido queda CANCELADO de forma síncrona y las compensaciones son detalle interno; ambas son defendibles, pero hay que documentar la elegida en OpenAPI y aplicarla igual en toda la API.
Solución 2.
Los pedidos son del bounded context de Pedidos (02-03): el historial debe servirlo el servicio de Pedidos, que es quien tiene los datos y su estado actualizado. Poner el endpoint en Clientes obligaría a Clientes a consultar a Pedidos (acoplamiento innecesario) o a replicar pedidos (que no le pertenecen). URI propuesta: GET /pedidos?clienteId=c-1024&orden=-creadoEn&limite=20 en Pedidos. La ruta anidada /clientes/{id}/pedidos puede ofrecerla el gateway o el BFF (03-04) como comodidad para el front, redirigiéndola internamente a Pedidos, pero el contrato canónico es el filtro sobre la colección /pedidos.
Paginación: cursor. El historial de un cliente crece por un extremo (los nuevos pedidos entran arriba), es una colección potencialmente grande y el caso de uso es "scroll infinito" en la web y la app, no "ir a la página 7". Con offset, un pedido nuevo creado mientras el usuario navega desplazaría todo y repetiría un elemento entre páginas.
{
"datos": [
{ "id": "ped-88213", "estado": "CONFIRMADO", "total": 79.70, "creadoEn": "2026-08-15T10:32:07Z", "_links": { "self": { "href": "/pedidos/ped-88213" } } },
{ "id": "ped-88102", "estado": "CONFIRMADO", "total": 24.50, "creadoEn": "2026-08-14T18:05:44Z", "_links": { "self": { "href": "/pedidos/ped-88102" } } }
],
"paginacion": { "limite": 2, "siguienteCursor": "eyJjcmVhZG9FbiI6IjIwMjYtMDgtMTRUMTg6MDU6NDRaIiwiaWQiOiJwZWQtODgxMDIifQ", "haySiguiente": true },
"_links": {
"self": { "href": "/pedidos?clienteId=c-1024&orden=-creadoEn&limite=2" },
"siguiente": { "href": "/pedidos?clienteId=c-1024&orden=-creadoEn&limite=2&cursor=eyJjcmVhZG9FbiI6IjIwMjYtMDgtMTRUMTg6MDU6NDRaIiwiaWQiOiJwZWQtODgxMDIifQ" }
}
}Solución 3.
enrutador.patch('/productos/:id', async (req, res, next) => {
const ifMatch = req.get('If-Match');
if (!ifMatch) {
return responderProblema(res, req, {
status: 428, codigo: 'PRECONDICION_REQUERIDA',
detail: 'Debe enviar If-Match con la versión del producto que está modificando'
});
}
try {
const actual = await repositorio.obtener(req.params.id);
if (!actual) {
return responderProblema(res, req, { status: 404, codigo: 'PRODUCTO_NO_EXISTE', detail: `No existe ${req.params.id}` });
}
// El ETag tiene la forma "p-501:12"; extraemos el número de versión entre comillas
const versionEsperada = Number(ifMatch.replace(/"/g, '').split(':')[1]);
const actualizado = await repositorio.actualizarSiVersion(req.params.id, req.body, versionEsperada);
if (!actualizado) {
return responderProblema(res, req, {
status: 409, codigo: 'VERSION_OBSOLETA',
detail: `El producto ${req.params.id} ha cambiado; recárguelo (versión actual ${actual.version})`
});
}
res.set('ETag', `"${actualizado.id}:${actualizado.version}"`).status(200).json(actualizado);
} catch (err) {
next(err);
}
});Puntos a comprobar: el ETag de respuesta refleja la nueva versión (13), de modo que un segundo PATCH del mismo cliente puede encadenar; y actualizarSiVersion debe hacer la comprobación en la misma sentencia SQL (UPDATE ... WHERE id = $1 AND version = $2), no en dos pasos, para que la concurrencia optimista sea real.
Conclusión
Hemos convertido las decisiones de diseño del módulo 2 en contratos REST concretos: URIs con sustantivos y jerarquías, verbos con su idempotencia (y Idempotency-Key cuando el verbo no la tiene), una tabla cerrada de códigos de estado que todos los servicios aplican igual, los cinco contratos clave de TechCorp (POST /pedidos con 202 + Location, GET /pedidos/{id} con ETag, GET /productos?ids= en lote para evitar el N+1, GET /clientes/{id}, POST /reservas con 201/409), errores uniformes RFC 7807 con el campo codigo que sustituye a los errores dispersos del monolito, paginación por cursor para colecciones que crecen, HATEOAS mínimo, cabeceras de caché, concurrencia optimista y correlación, OpenAPI 3 como forma escrita del contrato, y una ruta Express y un cliente fetch con timeout que lo aplican.
REST cubre las llamadas síncronas, pero en 02-05 decidimos que el corazón del flujo de pedido (reservar stock, cobrar, confirmar, notificar) viaja por eventos: pedido.creado, stock.reservado, pago.confirmado, pedido.confirmado, pedido.cancelado. Falta ver cómo se publican y consumen de verdad: qué es un exchange y una cola en RabbitMQ, cómo se enruta cada evento a quien le interesa, qué pasa cuando un consumidor falla y cómo garantizamos que un evento no se pierde ni se procesa dos veces. Eso es la mensajería asíncrona, la siguiente lección.
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
