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

  1. REST en microservicios: qué es y qué no es
  2. Recursos y URIs
  3. Verbos HTTP, semántica e idempotencia
  4. Códigos de estado que usaremos
  5. Los contratos REST clave de TechCorp
  6. Formato de errores uniforme: RFC 7807
  7. Paginación, filtrado y ordenación
  8. HATEOAS a nivel práctico
  9. Cabeceras útiles
  10. Documentación con OpenAPI 3
  11. Un endpoint Express y un cliente fetch que aplican el contrato

  1. 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 inventa POST /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.

  1. 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-88213 con {"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 con motivo, 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.

  1. 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.

  1. 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:

  • 400 frente a 422. 400 es "no entiendo tu petición" (no es JSON, falta un campo, un número viene como texto). 422 es "la entiendo pero no tiene sentido" (cantidad 0, fecha de entrega en el pasado). La distinción ayuda al cliente a saber si el error es de serialización o de negocio.
  • 404 frente a 409 en 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 nueva POST /pedidos responde 202 antes de saber si hay stock o si el pago pasa, así que SIN_STOCK y PAGO_RECHAZADO dejan de ser respuestas HTTP de ese endpoint y pasan a ser motivos de pedido.cancelado. SIN_STOCK sigue siendo un 409 de POST /reservas (Inventario). PRODUCTO_NO_DISPONIBLE pasa a 422 (la petición es correcta, pero pide algo que no está a la venta).

  1. 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:

GET /productos?ids=p-501,p-777 HTTP/1.1
Host: servicio-catalogo:3001
Accept: application/json
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 en noEncontrados y es Pedidos quien decide qué hacer (responder 422 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 traductorProducto a su propio modelo, de modo que si Catálogo cambia precio (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:

GET /clientes/c-1024 HTTP/1.1
Host: servicio-clientes:3004
Accept: application/json
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.

  1. 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.

  1. 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:

GET /pedidos?clienteId=c-1024&limite=2&orden=-creadoEn HTTP/1.1
{
  "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=campo ascendente y orden=-campo descendente; varios campos separados por coma: orden=-creadoEn,id.
  • limite con valor por defecto (20) y máximo (100). Un cliente que pide 10.000 recibe 400.
  • 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.

  1. 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 _links con self siempre.
  • Enlaces a acciones que dependen del estado: un pedido PENDIENTE incluye cancelar; uno CONFIRMADO incluye factura pero no cancelar. 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" }
  }
}

  1. 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.

  1. 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.

  1. Un endpoint Express y un cliente fetch que aplican el contrato

No 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:

  1. validarNuevoPedido separa los dos niveles: si la forma es incorrecta devuelve 400; si la forma está bien pero los valores no, 422 con la lista de campos. Es exactamente la distinción del apartado 4.
  2. responderProblema centraliza el formato RFC 7807. En el proyecto real esta función vive en @techcorp/comun-http para que Catálogo, Inventario y el resto respondan idéntico.
  3. La ruta comprueba Idempotency-Key, valida, delega en el caso de uso (que ya conocemos de 02-05: guarda el pedido y su pedido.creado en la misma transacción con guardarConEventos()), y responde 202 con Location. Fíjate en que la ruta no sabe nada de RabbitMQ ni de stock: solo traduce HTTP a negocio y negocio a HTTP.
  4. Los errores de negocio se mapean a códigos del contrato; cualquier otro va al middleware de errores de Express, que responde 500 sin 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 200 para 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.
  • 201 cuando el trabajo no ha terminado. Si al responder aún no sabes si habrá stock, es 202. Un 201 promete 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-Key sin persistencia. Guardar las claves en memoria del proceso no sirve con dos réplicas: la tabla claves_idempotencia de 02-05 es obligatoria.
  • Mensajes de error como contrato. Los clientes deben mirar codigo, no detail. Cambiar un texto no debería romper a nadie.
  • Filtrar internos en errores 500. Nunca detail: err.stack. Log completo con X-Request-Id; al cliente, un mensaje genérico con ese id.
  • Olvidar el timeout en el cliente. fetch sin signal espera 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

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados