Patrones comunes en arquitecturas distribuidas
Este bloque tiene un objetivo muy concreto: ayudarte a reconocer, entender y razonar los patrones que aparecen de forma recurrente cuando un sistema deja de ser una aplicación simple y pasa a convertirse en un conjunto de componentes que cooperan a través de la red.
No vamos a presentar estos patrones como “recetas”, sino como respuestas técnicas a problemas reales que aparecen con el crecimiento del software: aumento de complejidad, equipos más grandes, necesidad de escalar, cambios frecuentes, fallos parciales y evolución a largo plazo.
La idea clave que debe acompañarte durante todo el documento es esta: una arquitectura distribuida no nace distribuida, evoluciona hacia ello.
1. Arquitectura cliente-servidor
La arquitectura cliente-servidor es el punto de partida de prácticamente todos los sistemas distribuidos, incluso de aquellos que hoy llamamos microservicios o plataformas cloud.
En su forma más básica, este patrón define una separación clara de roles:
- El cliente solicita una operación.
- El servidor la procesa y devuelve una respuesta
Esta separación no es solo técnica, es conceptual. El cliente no decide, el cliente solicita. El servidor no presenta, el servidor resuelve.
En los primeros sistemas web, esta arquitectura se materializaba de forma muy directa: un navegador hacía una petición HTTP y un servidor devolvía HTML. Con el tiempo, el formato cambió (JSON, APIs REST, GraphQL), pero el patrón siguió siendo el mismo.
Lo importante aquí no es la mecánica, sino las implicaciones:
- El cliente desconoce cómo se implementa la lógica.
- El servidor centraliza reglas, datos y decisiones.
- La comunicación ocurre a través de la red, lo cual introduce latencia, errores y estados intermedios.
En cuanto introduces red, introduces incertidumbre. Y esa incertidumbre es uno de los motores principales de la arquitectura distribuida.
Cliente-servidor en sistemas modernos
Aunque hoy hablemos de sistemas distribuidos, microservicios o backend desacoplado, el patrón cliente-servidor no desaparece, simplemente se replica a distintos niveles:
- Un frontend web es cliente de un API Gateway.
- Un API Gateway es cliente de varios servicios backend.
- Un servicio backend puede ser cliente de otro servicio.
- Un job batch puede actuar como cliente de una API interna
Es decir, cliente-servidor no es una arquitectura “antigua”, es la unidad mínima de comunicación sobre la que se construyen arquitecturas más complejas.
Por eso conviene no trivializarla. Muchos errores de diseño en sistemas distribuidos vienen de olvidar que, en el fondo, seguimos teniendo clientes y servidores, solo que multiplicados.
Ejemplo conceptual realista
Piensa en una aplicación de reservas:
- El frontend no “crea reservas”. El frontend solicita crear una reserva.
- El backend no “muestra botones”. El backend decide si la reserva es válida, si hay disponibilidad y qué estado final tiene.
Si mañana decides cambiar el frontend web por una app móvil, la arquitectura cliente-servidor permite que el backend siga funcionando sin cambios. Esa es una de sus grandes virtudes.
Ejercicio práctico: Servidor de tarifas y cliente web
Este ejercicio muestra el patrón cliente-servidor en una forma que se parece a lo que pasa en sistemas reales: el cliente recoge datos, el servidor decide el resultado aplicando reglas, y el cliente solo representa la respuesta.
El ejemplo será un cálculo de tarifa de hotel simplificado. El cliente envía:
- Fecha de entrada y salida
- Número de huéspedes
- Tipo de habitación
El servidor responde:
- Noches calculadas
- Precio por noche aplicado según reglas
- Total
- Explicación del cálculo
La clave didáctica es que el cliente no debe poder “inventarse” el total. Si cambia la lógica del precio, la lógica está solo en el servidor, y el cliente no se entera: solo consume el contrato.
Estructura del mini proyecto
patron-01-cliente-servidor/
├── server/
│ ├── package.json
│ └── server.mjs
└── client/
├── index.html
└── app.js
Código del servidor
server/package.json
{
"name": "patron-01-cliente-servidor-server",
"version": "1.0.0",
"type": "module",
"dependencies": {
"express": "^4.19.2"
}
}
server/server.mjs
import express from "express";
const app = express();
const PORT = 4001;
/*
Este servidor implementa un caso típico de cliente-servidor.
Idea central del patrón:
- El cliente pide una operación (calcular tarifa).
- El servidor aplica reglas y devuelve una respuesta.
Qué se aprende aquí:
- Contrato HTTP: qué se manda y qué se recibe.
- La lógica de negocio vive en el servidor.
- El cliente no calcula, solo muestra.
- Validaciones serias están en el servidor.
Nota:
- Usamos GET con query params para simplificar el laboratorio.
- En un caso real, podrías usar POST con JSON, pero el patrón es el mismo.
*/
app.use((req, res, next) => {
/*
CORS mínimo para permitir que el cliente HTML (abierto desde file://
o servido por Live Server) pueda llamar al backend en otro puerto.
Esto no es el objetivo del ejercicio, pero es necesario para que funcione
sin introducir más infraestructura.
*/
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
next();
});
/*
Utilidad: calcular noches entre dos fechas YYYY-MM-DD.
En un sistema real, esta parte debe ser extremadamente cuidadosa:
- Zonas horarias
- Horas de check-in y check-out
- Reglas del negocio
Aquí lo simplificamos intencionalmente:
- Consideramos fechas a medianoche en UTC para evitar desviaciones
- Solo permitimos formato YYYY-MM-DD
*/
function calcularNoches(fechaEntrada, fechaSalida) {
const entrada = new Date(`${fechaEntrada}T00:00:00.000Z`);
const salida = new Date(`${fechaSalida}T00:00:00.000Z`);
if (Number.isNaN(entrada.getTime()) || Number.isNaN(salida.getTime())) {
return null;
}
const ms = salida.getTime() - entrada.getTime();
const noches = Math.floor(ms / (1000 * 60 * 60 * 24));
return noches;
}
/*
Catálogo interno de tipos de habitación.
Observa el detalle importante:
- El cliente enviará "tipo" como un string.
- El servidor decide si es válido y qué precio base tiene.
En sistemas reales, esto podría venir de base de datos o servicio externo,
pero lo que importa aquí es que la "verdad" vive del lado servidor.
*/
const habitaciones = {
standard: { nombre: "Standard", precioBase: 90 },
deluxe: { nombre: "Deluxe", precioBase: 140 },
suite: { nombre: "Suite", precioBase: 210 }
};
/*
Reglas de tarificación.
Queremos que el alumno vea reglas reales típicas:
- Ajuste por temporada
- Ajuste por número de huéspedes
- Descuento por estancias largas
Importante:
- Estas reglas están solo en el servidor.
- El cliente jamás debe replicarlas.
*/
function calcularTarifa({ tipo, noches, huespedes }) {
const habitacion = habitaciones[tipo];
// Regla 1: precio base según tipo
let precioNoche = habitacion.precioBase;
const explicacion = [];
explicacion.push(`Tipo: ${habitacion.nombre}, base ${habitacion.precioBase} EUR/noche`);
// Regla 2: suplemento por huésped extra (a partir de 2)
if (huespedes > 2) {
const extra = (huespedes - 2) * 15;
precioNoche += extra;
explicacion.push(`Suplemento huéspedes extra: +${extra} EUR/noche (15 por huésped a partir del 3)`);
} else {
explicacion.push("Sin suplemento por huéspedes (hasta 2 incluidos)");
}
// Regla 3: descuento por estancia larga
// 7 noches o más: 10% sobre el total
const subtotal = precioNoche * noches;
let descuento = 0;
if (noches >= 7) {
descuento = Math.round(subtotal * 0.10 * 100) / 100;
explicacion.push("Descuento estancia larga: 10% (a partir de 7 noches)");
} else {
explicacion.push("Sin descuento por estancia (menos de 7 noches)");
}
const total = Math.round((subtotal - descuento) * 100) / 100;
return {
precioNoche,
subtotal: Math.round(subtotal * 100) / 100,
descuento,
total,
explicacion
};
}
/*
Endpoint principal del ejercicio.
GET /api/tarifa?tipo=standard&fechaEntrada=2025-12-20&fechaSalida=2025-12-22&huespedes=2
Qué enseña:
- El cliente envía datos mínimos
- El servidor valida
- El servidor decide reglas
- El servidor devuelve un JSON con el resultado
*/
app.get("/api/tarifa", (req, res) => {
const tipo = typeof req.query.tipo === "string" ? req.query.tipo : "";
const fechaEntrada = typeof req.query.fechaEntrada === "string" ? req.query.fechaEntrada : "";
const fechaSalida = typeof req.query.fechaSalida === "string" ? req.query.fechaSalida : "";
const huespedesRaw = typeof req.query.huespedes === "string" ? req.query.huespedes : "2";
const huespedes = Number(huespedesRaw);
// Validaciones básicas de contrato
if (!habitaciones[tipo]) {
return res.status(400).json({
error: "tipo inválido",
detalle: `tipos permitidos: ${Object.keys(habitaciones).join(", ")}`
});
}
if (!Number.isInteger(huespedes) || huespedes < 1 || huespedes > 6) {
return res.status(400).json({
error: "huespedes inválido",
detalle: "Debe ser entero entre 1 y 6"
});
}
const noches = calcularNoches(fechaEntrada, fechaSalida);
if (noches === null) {
return res.status(400).json({
error: "fechas inválidas",
detalle: "Usa formato YYYY-MM-DD"
});
}
if (noches <= 0) {
return res.status(400).json({
error: "rango de fechas inválido",
detalle: "fechaSalida debe ser posterior a fechaEntrada"
});
}
// Aquí se ve el corazón del patrón:
// el servidor calcula, decide reglas, y devuelve resultado.
const tarifa = calcularTarifa({ tipo, noches, huespedes });
res.json({
input: {
tipo,
fechaEntrada,
fechaSalida,
huespedes,
noches
},
output: tarifa,
server: {
version: "1.0",
timestamp: new Date().toISOString()
}
});
});
app.listen(PORT, () => {
console.log(`Servidor de tarifas en http://localhost:${PORT}`);
console.log("Endpoint: GET /api/tarifa");
});
Código del cliente
client/index.html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cliente-Servidor: Tarifa Hotel</title>
</head>
<body>
<h1>Cliente-Servidor: Tarifa Hotel</h1>
<label>
Tipo habitación:
<select id="tipo">
<option value="standard">Standard</option>
<option value="deluxe">Deluxe</option>
<option value="suite">Suite</option>
</select>
</label>
<br /><br />
<label>
Fecha entrada:
<input id="fechaEntrada" type="date" />
</label>
<br /><br />
<label>
Fecha salida:
<input id="fechaSalida" type="date" />
</label>
<br /><br />
<label>
Huéspedes:
<input id="huespedes" type="number" min="1" max="6" value="2" />
</label>
<br /><br />
<button id="btn">Calcular tarifa</button>
<pre id="out"></pre>
<script src="app.js"></script>
</body>
</html>
client/app.js
/*
Este cliente representa el rol típico de un cliente web.
Observa que:
- No hay reglas de negocio aquí.
- No hay precios base.
- No hay cálculo del total.
- Solo recoge input, llama al servidor y muestra output.
Esto es exactamente lo que se busca con cliente-servidor:
- El cliente depende del servidor para la decisión.
- El servidor es la autoridad del cálculo.
*/
const API_BASE = "http://localhost:4001";
const out = document.getElementById("out");
const btn = document.getElementById("btn");
// Pequeña utilidad para imprimir JSON de forma legible
function print(obj) {
out.textContent = JSON.stringify(obj, null, 2);
}
btn.addEventListener("click", async () => {
const tipo = document.getElementById("tipo").value;
const fechaEntrada = document.getElementById("fechaEntrada").value;
const fechaSalida = document.getElementById("fechaSalida").value;
const huespedes = document.getElementById("huespedes").value;
// Validación mínima en cliente:
// Solo para mejorar UX, pero la validación real está en el servidor.
if (!fechaEntrada || !fechaSalida) {
print({ error: "Completa fechaEntrada y fechaSalida" });
return;
}
const url =
`${API_BASE}/api/tarifa` +
`?tipo=${encodeURIComponent(tipo)}` +
`&fechaEntrada=${encodeURIComponent(fechaEntrada)}` +
`&fechaSalida=${encodeURIComponent(fechaSalida)}` +
`&huespedes=${encodeURIComponent(huespedes)}`;
try {
const resp = await fetch(url);
// Si el servidor devuelve error, intentamos leer JSON igualmente
const data = await resp.json();
// Mostramos siempre lo que el servidor diga.
// Esto refuerza la idea: la fuente de verdad es el servidor.
print({
http_status: resp.status,
respuesta: data
});
} catch (err) {
// Esto simula un fallo típico distribuido:
// el servidor está caído, no hay red, etc.
print({
error: "No se pudo contactar con el servidor",
detalle: String(err)
});
}
});
Ejecución desde VSCode en Windows
- Abre la carpeta patron-01-cliente-servidor en VSCode
- Abre una terminal en VSCode
- Ejecuta:
cd server
npm install
node server.mjs
- Abre client/index.html con Live Server o en el navegador
- Selecciona fechas y pulsa Calcular tarifa
Qué comprobar para fijar el patrón:
- Si apagas el servidor, el cliente falla: el cliente depende del servidor
- Si cambias reglas de precios en el servidor, el cliente sigue igual
- El cliente no puede “inventar” el total, solo mostrar lo recibido
2. Arquitectura por capas
La arquitectura por capas aparece cuando una aplicación cliente-servidor empieza a crecer y el servidor deja de ser algo trivial.
Al principio, todo suele convivir en un mismo archivo o en pocas funciones: rutas HTTP, validaciones, lógica de negocio y consultas a base de datos. Funciona… hasta que deja de hacerlo.
La arquitectura por capas surge como respuesta a un problema muy concreto: el acoplamiento excesivo dentro del servidor.
Qué problema resuelve realmente
- El problema no es el tamaño del código. El problema es que todo cambia por las mismas razones.
- Si una modificación en la base de datos te obliga a tocar controladores HTTP.
- Si cambiar una regla de negocio rompe varias rutas.
- Si no puedes probar lógica sin levantar el servidor.
Entonces no tienes un problema de lenguaje ni de framework, tienes un problema de arquitectura.
La arquitectura por capas introduce una separación explícita de responsabilidades dentro de un mismo servicio.
Capas típicas y su sentido real
Aunque los nombres pueden variar, el concepto suele repetirse:
- Capa de presentación o HTTP
- Capa de aplicación o servicios
- Capa de acceso a datos
No se trata de “ordenar carpetas”, sino de ordenar razones de cambio.
- La capa HTTP cambia cuando cambia el protocolo o la forma de exponer la API.
- La capa de aplicación cambia cuando cambian las reglas del negocio.
- La capa de datos cambia cuando cambia el almacenamiento.
- Si cada capa cambia por motivos distintos, el sistema es más estable.
Relación con sistemas distribuidos
Aquí aparece algo importante: la arquitectura por capas no compite con los sistemas distribuidos, los prepara. Cuando más adelante separas un servicio en varios servicios independientes, lo normal es que cada uno mantenga su propia arquitectura por capas internamente. Un microservicio desordenado no deja de ser un monolito pequeño.
Ejemplo conceptual real
Piensa en un servicio de reservas:
- La capa HTTP recibe una petición POST /reservas.
- La capa de aplicación decide si la reserva es válida.
- La capa de datos guarda la reserva y devuelve un identificador.
Si mañana decides exponer ese mismo servicio por otro canal (por ejemplo, una cola o un job), reutilizas la capa de aplicación sin tocar la lógica. Ese es el verdadero valor del patrón.
Ejercicio práctico: Servicio de reservas por capas con SQLite
Implementaremos un mini servicio de reservas con:
- Controladores HTTP limpios y consistentes
- Servicios con reglas y validaciones de negocio
- Repositorio aislando SQL
- SQLite como persistencia real, con inicialización automática
Endpoints:
- POST /api/reservas
- GET /api/reservas/:id
- GET /api/reservas?email=...
- PATCH /api/reservas/:id/cancelar
Estructura del proyecto:
patron-02-arquitectura-por-capas/
├── package.json
└── src/
├── server.mjs
├── db/
│ └── sqlite.mjs
├── repositories/
│ └── reservas.repository.mjs
├── services/
│ └── reservas.service.mjs
└── controllers/
└── reservas.controller.mjs
2.1 package.json
package.json
{
"name": "patron-02-arquitectura-por-capas",
"version": "1.0.0",
"type": "module",
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.19.2"
}
}
2.2 Capa DB
src/db/sqlite.mjs
import Database from "better-sqlite3";
import path from "node:path";
import { fileURLToPath } from "node:url";
/*
Esta capa es deliberadamente pequeña.
Qué debe hacer:
- Abrir conexión SQLite
- Asegurar esquema (tablas, índices)
- Exponer un "db" que otras capas puedan usar
Qué NO debe hacer:
- No debe contener reglas de negocio
- No debe "decidir" estados de una reserva
- No debe formatear respuestas HTTP
Decisión didáctica:
- Usamos better-sqlite3 para tener un flujo claro y estable en laboratorio.
- Las operaciones son síncronas, lo que simplifica el seguimiento mental.
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Guardamos la base en una carpeta de datos dentro de src/db
const DB_PATH = path.join(__dirname, "reservas.db");
/*
Abrimos conexión.
En SQLite, la conexión en un proceso Node suele ser "una por proceso".
Si tuvieras alta concurrencia o varios procesos, ya entrarías en otras estrategias.
*/
const db = new Database(DB_PATH);
/*
Aseguramos integridad referencial, aunque aquí no hay FKs.
Este PRAGMA se deja como hábito sano.
*/
db.pragma("foreign_keys = ON");
/*
Inicialización del esquema.
Importante:
- Un servicio real suele tener migraciones.
- En laboratorio, creamos si no existe para poder ejecutar sin pasos extras.
*/
db.exec(`
CREATE TABLE IF NOT EXISTS reservas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reserva_id TEXT NOT NULL UNIQUE,
habitacion_tipo TEXT NOT NULL,
fecha_entrada TEXT NOT NULL,
fecha_salida TEXT NOT NULL,
huespedes INTEGER NOT NULL,
huesped_nombre TEXT NOT NULL,
huesped_email TEXT NOT NULL,
estado TEXT NOT NULL CHECK(estado IN ('pendiente','confirmada','cancelada')),
monto_total REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
cancel_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_reservas_email
ON reservas(huesped_email);
CREATE INDEX IF NOT EXISTS idx_reservas_estado
ON reservas(estado);
CREATE INDEX IF NOT EXISTS idx_reservas_fecha_entrada
ON reservas(fecha_entrada);
`);
/*
Exportamos db para que el repositorio lo use.
Observa:
- El repositorio depende de db (y por tanto de SQLite).
- Las capas superiores no deberían depender de db directamente.
*/
export { db, DB_PATH };
2.3 Capa Repository
src/repositories/reservas.repository.mjs
import { db } from "../db/sqlite.mjs";
/*
Esta capa encapsula SQL.
Qué debe hacer:
- Insertar, consultar, actualizar usando SQL
- Devolver objetos de datos “crudos” (rows)
- No debe aplicar reglas del negocio (eso es de services)
- No debe decidir códigos HTTP (eso es de controllers)
Este repositorio es el único lugar donde debería existir SQL en el proyecto.
*/
const stmtInsert = db.prepare(`
INSERT INTO reservas (
reserva_id,
habitacion_tipo,
fecha_entrada,
fecha_salida,
huespedes,
huesped_nombre,
huesped_email,
estado,
monto_total
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const stmtGetById = db.prepare(`
SELECT *
FROM reservas
WHERE id = ?
`);
const stmtGetByReservaId = db.prepare(`
SELECT *
FROM reservas
WHERE reserva_id = ?
`);
const stmtListByEmail = db.prepare(`
SELECT *
FROM reservas
WHERE huesped_email = ?
ORDER BY created_at DESC
`);
const stmtCancel = db.prepare(`
UPDATE reservas
SET estado = 'cancelada',
cancel_reason = ?,
updated_at = datetime('now')
WHERE id = ?
`);
const stmtConfirm = db.prepare(`
UPDATE reservas
SET estado = 'confirmada',
updated_at = datetime('now')
WHERE id = ?
`);
/*
Inserta una reserva.
- Si hay colisión de reserva_id (UNIQUE), SQLite lanzará error.
- La capa services decide cómo convertir eso a un error de negocio.
*/
export function insertReserva(row) {
const result = stmtInsert.run(
row.reserva_id,
row.habitacion_tipo,
row.fecha_entrada,
row.fecha_salida,
row.huespedes,
row.huesped_nombre,
row.huesped_email,
row.estado,
row.monto_total
);
return result.lastInsertRowid;
}
export function getReservaById(id) {
return stmtGetById.get(id);
}
export function getReservaByReservaId(reservaId) {
return stmtGetByReservaId.get(reservaId);
}
export function listReservasByEmail(email) {
return stmtListByEmail.all(email);
}
export function cancelReservaById(id, reason) {
const r = stmtCancel.run(reason ?? null, id);
return r.changes;
}
export function confirmReservaById(id) {
const r = stmtConfirm.run(id);
return r.changes;
}
2.4 Capa Service
src/services/reservas.service.mjs
import crypto from "node:crypto";
import {
insertReserva,
getReservaById,
getReservaByReservaId,
listReservasByEmail,
cancelReservaById,
confirmReservaById
} from "../repositories/reservas.repository.mjs";
/*
Esta capa contiene la lógica del negocio.
Aquí es donde:
- Validamos datos con sentido de negocio
- Calculamos noches y monto_total
- Decidimos estado inicial
- Convertimos errores técnicos en errores de dominio
Importante:
- El service no sabe nada de HTTP.
- Si mañana ejecutaras esto desde un worker o desde tests, funcionaría igual.
Estrategia de errores:
- Creamos errores tipados con "code" para que el controlador traduzca a HTTP.
*/
function createError(code, message, details = null) {
const err = new Error(message);
err.code = code;
err.details = details;
return err;
}
function isISODate(s) {
// YYYY-MM-DD, suficiente para laboratorio
return typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function calcularNoches(fechaEntrada, fechaSalida) {
const a = new Date(`${fechaEntrada}T00:00:00.000Z`);
const b = new Date(`${fechaSalida}T00:00:00.000Z`);
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null;
const ms = b.getTime() - a.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
function normalizarEmail(email) {
if (typeof email !== "string") return "";
return email.trim().toLowerCase();
}
function validarEntradaCrearReserva(input) {
/*
Validación de negocio, no de HTTP.
Buscamos errores útiles:
- Fechas con sentido
- Rangos razonables
- Tipos aceptados
*/
const tipo = typeof input.habitacion_tipo === "string" ? input.habitacion_tipo.trim() : "";
const fechaEntrada = input.fecha_entrada;
const fechaSalida = input.fecha_salida;
const huespedes = Number(input.huespedes);
const nombre = typeof input.huesped_nombre === "string" ? input.huesped_nombre.trim() : "";
const email = normalizarEmail(input.huesped_email);
const tiposValidos = ["standard", "deluxe", "suite"];
if (!tiposValidos.includes(tipo)) {
throw createError("VALIDATION_ERROR", "habitacion_tipo inválido", { allowed: tiposValidos });
}
if (!isISODate(fechaEntrada) || !isISODate(fechaSalida)) {
throw createError("VALIDATION_ERROR", "Fechas inválidas, usa YYYY-MM-DD");
}
const noches = calcularNoches(fechaEntrada, fechaSalida);
if (noches === null || noches <= 0) {
throw createError("VALIDATION_ERROR", "Rango de fechas inválido, fecha_salida debe ser posterior");
}
if (!Number.isInteger(huespedes) || huespedes < 1 || huespedes > 6) {
throw createError("VALIDATION_ERROR", "huespedes inválido, debe ser entero entre 1 y 6");
}
if (nombre.length < 3) {
throw createError("VALIDATION_ERROR", "huesped_nombre demasiado corto");
}
if (!email.includes("@") || email.length < 6) {
throw createError("VALIDATION_ERROR", "huesped_email inválido");
}
return { tipo, fechaEntrada, fechaSalida, noches, huespedes, nombre, email };
}
function calcularMontoTotal({ tipo, noches, huespedes }) {
/*
Reglas simples, pero realistas:
- base por tipo
- suplemento por huéspedes extra
- descuento por estancia larga
Igual que en cliente-servidor, la lógica vive aquí.
La capa HTTP jamás debe calcular este total.
*/
const basePorTipo = {
standard: 95,
deluxe: 150,
suite: 230
};
const base = basePorTipo[tipo];
let precioNoche = base;
if (huespedes > 2) {
precioNoche += (huespedes - 2) * 18;
}
const subtotal = precioNoche * noches;
const descuento = noches >= 7 ? subtotal * 0.1 : 0;
const total = Math.round((subtotal - descuento) * 100) / 100;
return {
precio_noche: Math.round(precioNoche * 100) / 100,
subtotal: Math.round(subtotal * 100) / 100,
descuento: Math.round(descuento * 100) / 100,
total
};
}
export function crearReserva(input) {
const v = validarEntradaCrearReserva(input);
const monto = calcularMontoTotal({
tipo: v.tipo,
noches: v.noches,
huespedes: v.huespedes
});
// Reserva_id es un identificador de dominio, distinto del id interno autoincremental.
// En sistemas distribuidos suele existir algo similar para no filtrar ids internos.
const reservaId = `RES-${Date.now()}-${crypto.randomBytes(2).toString("hex").toUpperCase()}`;
const row = {
reserva_id: reservaId,
habitacion_tipo: v.tipo,
fecha_entrada: v.fechaEntrada,
fecha_salida: v.fechaSalida,
huespedes: v.huespedes,
huesped_nombre: v.nombre,
huesped_email: v.email,
estado: "pendiente",
monto_total: monto.total
};
try {
const newId = insertReserva(row);
// La capa service puede decidir que, tras crear, pasa a confirmada automáticamente
// en este ejercicio. En la vida real, confirmación podría depender de pago.
confirmReservaById(newId);
const creada = getReservaById(newId);
return {
reserva: creada,
pricing: monto,
noches: v.noches
};
} catch (err) {
// Traducción de error técnico a error de dominio
// Si es UNIQUE constraint, puede ser colisión de reserva_id (muy raro, pero posible)
if (String(err.message || "").toLowerCase().includes("unique")) {
throw createError("CONFLICT", "Conflicto creando reserva, intenta de nuevo");
}
throw createError("INTERNAL_ERROR", "Error interno creando reserva");
}
}
export function obtenerReserva(id) {
const n = Number(id);
if (!Number.isInteger(n) || n <= 0) {
throw createError("VALIDATION_ERROR", "id inválido, debe ser entero positivo");
}
const row = getReservaById(n);
if (!row) throw createError("NOT_FOUND", "Reserva no encontrada");
return row;
}
export function listarReservasPorEmail(email) {
const e = normalizarEmail(email);
if (!e) throw createError("VALIDATION_ERROR", "email requerido");
if (!e.includes("@")) throw createError("VALIDATION_ERROR", "email inválido");
return listReservasByEmail(e);
}
export function cancelarReserva(id, reason) {
const n = Number(id);
if (!Number.isInteger(n) || n <= 0) {
throw createError("VALIDATION_ERROR", "id inválido, debe ser entero positivo");
}
const row = getReservaById(n);
if (!row) throw createError("NOT_FOUND", "Reserva no encontrada");
// Regla de negocio simple:
// - Si ya está cancelada, respondemos idempotente.
if (row.estado === "cancelada") {
return { already: true, reserva: row };
}
const r = typeof reason === "string" && reason.trim().length > 0 ? reason.trim() : "CANCELACION_USUARIO";
const changes = cancelReservaById(n, r);
if (changes !== 1) throw createError("INTERNAL_ERROR", "No se pudo cancelar la reserva");
return { already: false, reserva: getReservaById(n) };
}
2.5 Capa Controller
src/controllers/reservas.controller.mjs
import {
crearReserva,
obtenerReserva,
listarReservasPorEmail,
cancelarReserva
} from "../services/reservas.service.mjs";
/*
La capa controller traduce:
- HTTP -> llamada a service
- resultado de service -> JSON + código HTTP
- errores del service -> códigos HTTP coherentes
Observa el reparto:
- Aquí NO hay SQL
- Aquí NO se calculan montos
- Aquí NO se decide la lógica de negocio
El controlador hace el trabajo "de frontera".
*/
function mapServiceErrorToHttp(err) {
const code = err?.code;
if (code === "VALIDATION_ERROR") return { status: 400, body: { error: err.message, details: err.details ?? null } };
if (code === "NOT_FOUND") return { status: 404, body: { error: err.message } };
if (code === "CONFLICT") return { status: 409, body: { error: err.message } };
return { status: 500, body: { error: "Error interno" } };
}
export function postReserva(req, res) {
try {
const result = crearReserva(req.body);
res.status(201).json(result);
} catch (err) {
const mapped = mapServiceErrorToHttp(err);
res.status(mapped.status).json(mapped.body);
}
}
export function getReservaById(req, res) {
try {
const row = obtenerReserva(req.params.id);
res.json(row);
} catch (err) {
const mapped = mapServiceErrorToHttp(err);
res.status(mapped.status).json(mapped.body);
}
}
export function getReservas(req, res) {
try {
const email = req.query.email;
const rows = listarReservasPorEmail(email);
res.json(rows);
} catch (err) {
const mapped = mapServiceErrorToHttp(err);
res.status(mapped.status).json(mapped.body);
}
}
export function patchCancelarReserva(req, res) {
try {
const { reason } = req.body ?? {};
const result = cancelarReserva(req.params.id, reason);
res.json(result);
} catch (err) {
const mapped = mapServiceErrorToHttp(err);
res.status(mapped.status).json(mapped.body);
}
}
2.6 Servidor HTTP
src/server.mjs
import express from "express";
import { DB_PATH } from "./db/sqlite.mjs";
import {
postReserva,
getReservaById,
getReservas,
patchCancelarReserva
} from "./controllers/reservas.controller.mjs";
/*
Este archivo es la “capa de entrada” del servicio.
Su responsabilidad es:
- Montar Express
- Configurar middlewares
- Registrar rutas
No debería contener lógica de negocio ni SQL.
*/
const app = express();
const PORT = 4002;
app.use(express.json());
app.use((req, res, next) => {
// CORS mínimo para trabajar con REST Client o un frontend en otro puerto.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") return res.status(200).end();
next();
});
app.get("/health", (req, res) => {
res.json({
service: "reservas-por-capas",
status: "healthy",
db_path: DB_PATH,
endpoints: [
"POST /api/reservas",
"GET /api/reservas/:id",
"GET /api/reservas?email=...",
"PATCH /api/reservas/:id/cancelar"
]
});
});
app.post("/api/reservas", postReserva);
app.get("/api/reservas/:id", getReservaById);
app.get("/api/reservas", getReservas);
app.patch("/api/reservas/:id/cancelar", patchCancelarReserva);
app.listen(PORT, () => {
console.log(`Servicio reservas (capas) en http://localhost:${PORT}`);
console.log("Health: GET /health");
});
2.7 Pruebas con REST Client en VSCode
Crea un archivo en la raíz del proyecto:
- patron-02-arquitectura-por-capas/reservas.http
Contenido:
### Health
GET http://localhost:4002/health
### Crear reserva
POST http://localhost:4002/api/reservas
Content-Type: application/json
{
"habitacion_tipo": "deluxe",
"fecha_entrada": "2025-12-20",
"fecha_salida": "2025-12-23",
"huespedes": 3,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local"
}
### Obtener reserva por id (cambia el id)
GET http://localhost:4002/api/reservas/1
### Listar reservas por email
GET http://localhost:4002/api/reservas?email=cliente@demo.local
### Cancelar reserva (cambia el id)
PATCH http://localhost:4002/api/reservas/1/cancelar
Content-Type: application/json
{
"reason": "CAMBIO_DE_PLANES"
}
2.8 Cómo ejecutarlo desde VSCode en Windows
- Abre la carpeta patron-02-arquitectura-por-capas en VSCode
- Terminal en VSCode:
npm install
node .\src\server.mjs
- Instala la extensión REST Client si no la tienes (Huachao Mao)
- Abre reservas.http y ejecuta las peticiones una por una
Qué deberías observar para fijar el patrón por capas:
- Si cambias el SQL (repositorio), no tocas controladores
- Si cambias reglas de cálculo (service), no tocas SQL ni rutas
- Si cambias rutas o formatos HTTP (controller), no tocas reglas ni SQL
- Cuando hay errores, el service devuelve un error de dominio y el controller lo traduce a HTTP
3. Servicios independientes
La arquitectura de servicios independientes aparece cuando una arquitectura por capas, aun estando bien diseñada, empieza a mostrar límites que no se solucionan reorganizando código.
Hasta este punto, todo vive dentro de un mismo servicio. Las capas separan responsabilidades internas, pero el sistema sigue teniendo una característica fundamental: todo se despliega, escala y falla junto.
Mientras el sistema es pequeño o estable, esto no supone un problema real. El conflicto aparece cuando distintas partes del backend empiezan a comportarse de forma diferente.
El problema que la arquitectura por capas ya no resuelve
En aplicaciones reales, no todo cambia al mismo ritmo ni tiene el mismo impacto operativo.
Algunas partes del sistema:
- Cambian con mucha frecuencia porque el negocio evoluciona
- Requieren más recursos en momentos concretos
- Son críticas y no pueden fallar
- Otras fallan a menudo y deberían ser tolerantes
Cuando todo está dentro del mismo proceso, estas diferencias se convierten en fricción. Un pequeño cambio obliga a desplegar todo. Un fallo secundario puede afectar a operaciones críticas. Escalar una parte implica escalar todo el sistema.
Aquí es donde la arquitectura por capas deja de ser suficiente.
Qué significa realmente separar en servicios independientes
Separar en servicios independientes no consiste en “dividir carpetas” ni en levantar varios servidores por capricho. Supone aceptar que ciertas responsabilidades merecen vivir como aplicaciones completas, con su propio ciclo de vida.
Un servicio independiente es una unidad que:
- Tiene una responsabilidad clara y limitada
- Se despliega de forma autónoma
- Mantiene su propio almacenamiento
- Se comunica con otros servicios exclusivamente por red
A partir de ese momento, la relación entre partes del sistema deja de ser una llamada a función y pasa a ser un contrato explícito.
Esto introduce una frontera muy importante: la red.
La red como frontera arquitectónica
En una arquitectura por capas, las capas se comunican mediante llamadas directas en memoria. En servicios independientes, toda comunicación pasa por la red. Esto cambia por completo las reglas del juego.
Una llamada puede fallar aunque el código sea correcto. Puede tardar más de lo esperado. Puede devolver datos incompletos. Puede no responder.
Esto obliga a diseñar con una mentalidad distinta. Ya no se puede asumir que todas las operaciones son instantáneas ni que el sistema está siempre disponible en su totalidad.
Este cambio no es accidental ni negativo. Es el precio que se paga por una independencia real.
Servicios independientes como límites de dominio
Un criterio clave para definir servicios independientes es el dominio de negocio. Un servicio no representa una tabla ni una entidad aislada, sino un conjunto coherente de reglas y decisiones.
En un sistema de hotel, por ejemplo, tiene sentido que existan servicios separados para:
- Reservas, que entiende estados, fechas y cancelaciones
- Pagos, que gestiona transacciones, rechazos y reembolsos
- Habitaciones, que decide disponibilidad
- Notificaciones, que comunica eventos al exterior
Cada uno de estos dominios tiene motivos de cambio distintos y un impacto diferente cuando falla. Separarlos permite tratarlos de forma adecuada, sin forzar soluciones comunes para problemas distintos.
Relación con la arquitectura por capas
Es importante no confundir niveles. Los servicios independientes no sustituyen la arquitectura por capas, la reutilizan.
Cada servicio independiente bien diseñado suele estar organizado internamente por capas:
- Una capa de entrada HTTP o de mensajería
- Una capa de lógica de negocio
- Una capa de acceso a datos
La diferencia es que ahora esas capas viven dentro de una aplicación autónoma, no dentro de un gran monolito. Esto hace que las buenas prácticas aprendidas anteriormente sigan siendo válidas y necesarias.
Un servicio independiente sin capas internas no deja de ser un monolito pequeño y difícil de mantener.
Ejemplo conceptual aplicado
Volvamos al servicio de reservas que ya has trabajado.
En una arquitectura por capas, ese servicio podría encargarse de todo: validar reservas, comprobar disponibilidad, procesar pagos y enviar correos. Funciona, pero concentra demasiadas responsabilidades.
Al separar en servicios independientes, el sistema cambia de forma cualitativa. El servicio de reservas se centra en su dominio. El servicio de pagos decide si una transacción es válida. El de notificaciones puede fallar sin bloquear el flujo principal.
La complejidad no desaparece, pero se redistribuye de una forma que permite evolucionar el sistema sin romperlo continuamente.
Qué no pretende resolver este patrón
Conviene ser claro en este punto. Los servicios independientes no hacen el sistema más simple ni reducen el número total de problemas. De hecho, introducen nuevos retos: más procesos, más despliegues, más observabilidad necesaria.
Su valor está en otro sitio. Permiten que el sistema crezca, cambie y falle de forma controlada, sin que todo dependa de una única pieza central.
Ejercicio práctico: Servicios independientes de reservas y pagos
En este ejercicio vamos a implementar dos servicios backend separados, cada uno con su propia responsabilidad, su propio proceso y su propia base de datos.
No habrá API Gateway todavía. El objetivo es que veas con claridad:
- Que cada servicio se ejecuta de forma autónoma
- Que cada servicio tiene su propio modelo de datos
- Que la comunicación se hace por HTTP
- Que un servicio puede fallar sin que el otro desaparezca
Escenario funcional
Implementaremos un sistema mínimo de reservas con pago simulado:
- El servicio de reservas crea y gestiona reservas
- El servicio de pagos procesa pagos asociados a una reserva
El flujo será explícito y manual, para que se entienda bien:
- El cliente crea una reserva en el servicio de reservas
- El cliente usa el
reserva_idpara pagar en el servicio de pagos - El servicio de pagos no accede a la base de datos de reservas
- La relación entre ambos se hace solo por identificadores y HTTP
Esto es clave desde el punto de vista arquitectónico.
Estructura del proyecto
Usamos dos proyectos completamente independientes, aunque vivan en la misma carpeta padre para el laboratorio.
patron-03-servicios-independientes/
├── servicio-reservas/
│ ├── package.json
│ └── src/
│ ├── server.mjs
│ ├── db/
│ │ └── sqlite.mjs
│ ├── repositories/
│ │ └── reservas.repository.mjs
│ └── services/
│ └── reservas.service.mjs
│
├── servicio-pagos/
│ ├── package.json
│ └── src/
│ ├── server.mjs
│ ├── db/
│ │ └── sqlite.mjs
│ ├── repositories/
│ │ └── pagos.repository.mjs
│ └── services/
│ └── pagos.service.mjs
│
└── pruebas.http
Cada servicio es un mini sistema completo, organizado internamente por capas, pero sin compartir nada.
Servicio 1: Reservas
package.json
servicio-reservas/package.json
{
"name": "servicio-reservas",
"version": "1.0.0",
"type": "module",
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.19.2"
}
}
Base de datos
servicio-reservas/src/db/sqlite.mjs
import Database from "better-sqlite3";
import path from "node:path";
import { fileURLToPath } from "node:url";
/*
Base de datos EXCLUSIVA del servicio de reservas.
Este servicio:
- No sabe nada de pagos
- No consulta ningún servicio externo
- Es dueño absoluto del ciclo de vida de una reserva
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_PATH = path.join(__dirname, "reservas.db");
const db = new Database(DB_PATH);
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS reservas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reserva_id TEXT NOT NULL UNIQUE,
cliente TEXT NOT NULL,
email TEXT NOT NULL,
estado TEXT NOT NULL CHECK(estado IN ('pendiente','pagada')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export { db, DB_PATH };
Repositorio
servicio-reservas/src/repositories/reservas.repository.mjs
import { db } from "../db/sqlite.mjs";
/*
Capa de acceso a datos.
SQL solo vive aquí.
*/
const insertStmt = db.prepare(`
INSERT INTO reservas (reserva_id, cliente, email, estado)
VALUES (?, ?, ?, ?)
`);
const getByReservaIdStmt = db.prepare(`
SELECT *
FROM reservas
WHERE reserva_id = ?
`);
const updateEstadoStmt = db.prepare(`
UPDATE reservas
SET estado = ?
WHERE reserva_id = ?
`);
export function insertReserva(row) {
insertStmt.run(
row.reserva_id,
row.cliente,
row.email,
row.estado
);
}
export function getReservaByReservaId(reservaId) {
return getByReservaIdStmt.get(reservaId);
}
export function updateEstado(reservaId, estado) {
return updateEstadoStmt.run(estado, reservaId).changes;
}
Servicio de dominio
servicio-reservas/src/services/reservas.service.mjs
import crypto from "node:crypto";
import {
insertReserva,
getReservaByReservaId,
updateEstado
} from "../repositories/reservas.repository.mjs";
/*
Lógica de negocio del servicio de reservas.
Este servicio:
- Crea reservas
- Marca reservas como pagadas
- NO procesa pagos
*/
function generarReservaId() {
return `RES-${Date.now()}-${crypto.randomBytes(2).toString("hex").toUpperCase()}`;
}
export function crearReserva({ cliente, email }) {
if (!cliente || !email) {
throw new Error("cliente y email son obligatorios");
}
const reservaId = generarReservaId();
insertReserva({
reserva_id: reservaId,
cliente,
email,
estado: "pendiente"
});
return getReservaByReservaId(reservaId);
}
export function marcarComoPagada(reservaId) {
const reserva = getReservaByReservaId(reservaId);
if (!reserva) throw new Error("Reserva no encontrada");
if (reserva.estado === "pagada") return reserva;
updateEstado(reservaId, "pagada");
return getReservaByReservaId(reservaId);
}
export function obtenerReserva(reservaId) {
const reserva = getReservaByReservaId(reservaId);
if (!reserva) throw new Error("Reserva no encontrada");
return reserva;
}
Servidor HTTP
servicio-reservas/src/server.mjs
import express from "express";
import {
crearReserva,
marcarComoPagada,
obtenerReserva
} from "./services/reservas.service.mjs";
import { DB_PATH } from "./db/sqlite.mjs";
/*
Servicio de reservas.
Puerto independiente.
*/
const app = express();
const PORT = 5001;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "reservas",
status: "healthy",
db: DB_PATH
});
});
app.post("/reservas", (req, res) => {
try {
const reserva = crearReserva(req.body);
res.status(201).json(reserva);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.get("/reservas/:reservaId", (req, res) => {
try {
res.json(obtenerReserva(req.params.reservaId));
} catch (err) {
res.status(404).json({ error: err.message });
}
});
app.patch("/reservas/:reservaId/pagar", (req, res) => {
try {
res.json(marcarComoPagada(req.params.reservaId));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`Servicio reservas en http://localhost:${PORT}`);
});
Servicio 2: Pagos
Este servicio no accede a reservas.db ni conoce su esquema.
package.json
servicio-pagos/package.json
{
"name": "servicio-pagos",
"version": "1.0.0",
"type": "module",
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.19.2"
}
}
Base de datos
servicio-pagos/src/db/sqlite.mjs
import Database from "better-sqlite3";
import path from "node:path";
import { fileURLToPath } from "node:url";
/*
Base de datos EXCLUSIVA del servicio de pagos.
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_PATH = path.join(__dirname, "pagos.db");
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS pagos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reserva_id TEXT NOT NULL,
monto REAL NOT NULL,
estado TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export { db, DB_PATH };
Servicio de dominio
servicio-pagos/src/services/pagos.service.mjs
import { db } from "../db/sqlite.mjs";
/*
Este servicio:
- Registra pagos
- No valida reservas
- No conoce el estado real de una reserva
*/
export function procesarPago({ reserva_id, monto }) {
if (!reserva_id || !monto) {
throw new Error("reserva_id y monto son obligatorios");
}
const estado = Math.random() > 0.1 ? "aceptado" : "rechazado";
db.prepare(`
INSERT INTO pagos (reserva_id, monto, estado)
VALUES (?, ?, ?)
`).run(reserva_id, monto, estado);
return { reserva_id, monto, estado };
}
Servidor HTTP
servicio-pagos/src/server.mjs
import express from "express";
import { procesarPago } from "./services/pagos.service.mjs";
import { DB_PATH } from "./db/sqlite.mjs";
const app = express();
const PORT = 5002;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "pagos",
status: "healthy",
db: DB_PATH
});
});
app.post("/pagos", (req, res) => {
try {
res.json(procesarPago(req.body));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`Servicio pagos en http://localhost:${PORT}`);
});
Pruebas desde VSCode con REST Client
patron-03-servicios-independientes/pruebas.http
### Crear reserva
POST http://localhost:5001/reservas
Content-Type: application/json
{
"cliente": "Ana Torres",
"email": "ana@demo.local"
}
### Obtener reserva
GET http://localhost:5001/reservas/RES-XXXX
### Pagar reserva
POST http://localhost:5002/pagos
Content-Type: application/json
{
"reserva_id": "RES-XXXX",
"monto": 180
}
### Marcar reserva como pagada
PATCH http://localhost:5001/reservas/RES-XXXX/pagar
Cómo ejecutarlo en Windows desde VSCode
En dos terminales distintas:
cd servicio-reservas
npm install
node .\src\server.mjs
cd servicio-pagos
npm install
node .\src\server.mjs
Qué debes observar para fijar el patrón
- Cada servicio tiene su propio proceso
- Cada servicio tiene su propia base de datos
- No hay acceso cruzado a datos
- La coordinación se hace manual y explícita
- Si el servicio de pagos cae, el de reservas sigue funcionando
- La complejidad empieza a moverse hacia la comunicación
4. API Gateway
El API Gateway aparece cuando una arquitectura basada en servicios independientes empieza a mostrar un nuevo tipo de problema. Ya no es un problema interno del backend, ni un problema de separación de responsabilidades dentro de un servicio. Es un problema de cómo se consume el sistema desde fuera.
Después del bloque anterior, el sistema ya no es una única aplicación. Ahora hay varios servicios autónomos, cada uno con su propio puerto, sus propios endpoints y su propia lógica. Desde el punto de vista del backend, esto es una mejora clara. Desde el punto de vista del cliente, no necesariamente.
Aquí es donde entra el API Gateway.
El problema que introduce la independencia
En el ejercicio de servicios independientes, el cliente debía saber varias cosas:
- Qué servicio crear una reserva
- Qué servicio procesar un pago
- En qué orden hacer las llamadas
- Qué hacer si una de ellas fallaba
Ese conocimiento, aunque funcional, no debería vivir en el cliente. El cliente no debería conocer la topología interna del backend ni asumir responsabilidades de coordinación.
En sistemas reales, esto genera varios problemas claros:
- El frontend queda fuertemente acoplado a la arquitectura interna
- Cambiar un servicio implica cambiar todos los clientes
- La lógica de reintentos, errores parciales y validaciones se duplica
- La seguridad se fragmenta servicio a servicio
El API Gateway surge como respuesta directa a este escenario.
Qué es realmente un API Gateway
Un API Gateway es un punto de entrada único al sistema distribuido. Desde fuera, el backend vuelve a parecer un solo sistema, aunque internamente esté compuesto por muchos servicios.
Su responsabilidad no es implementar lógica de negocio, sino orquestar, proteger y exponer los servicios existentes.
De forma conceptual, el gateway:
- Recibe las peticiones de los clientes
- Decide a qué servicios llamar
- Coordina varias llamadas si es necesario
- Unifica respuestas y errores
- Aplica políticas transversales
Es importante remarcar que el gateway no sustituye a los servicios, ni debería duplicar su lógica interna. Es una capa más, situada en el borde del sistema.
La frontera clara entre cliente y backend
Con un API Gateway, el cliente deja de conocer los servicios internos. Para el cliente:
- Existe una única URL base
- Existe un conjunto estable de endpoints
- El backend se comporta como una unidad coherente
Esto permite que la arquitectura interna evolucione sin romper a los consumidores. Un servicio puede cambiar de puerto, dividirse en dos o incluso desaparecer, siempre que el gateway mantenga el contrato externo.
Aquí aparece una idea clave en arquitecturas distribuidas: estabilidad hacia fuera, flexibilidad hacia dentro.
Responsabilidades típicas del API Gateway
Aunque cada implementación puede variar, hay responsabilidades que aparecen de forma recurrente.
Por un lado, están las responsabilidades de orquestación. El gateway puede recibir una petición y traducirla en varias llamadas internas, como ya viste en el sistema de hotel con la reserva completa.
Por otro lado, están las responsabilidades transversales, que no pertenecen a ningún dominio concreto:
- Autenticación y autorización
- Rate limiting
- Validación básica de entrada
- Logging y métricas
- Normalización de errores
Centralizar estas responsabilidades evita que se dupliquen en cada servicio y reduce inconsistencias.
Relación con los servicios independientes
El API Gateway no elimina la independencia de los servicios. De hecho, la refuerza.
Cada servicio sigue siendo autónomo, con su propia base de datos y su propio ciclo de vida. El gateway simplemente actúa como intermediario. Si un servicio cae, el gateway puede decidir cómo responder: degradar funcionalidad, devolver un error controlado o activar una ruta alternativa.
Desde el punto de vista del diseño, esto introduce una jerarquía clara:
- Los servicios implementan reglas de negocio
- El gateway implementa reglas de exposición
Confundir estos niveles suele llevar a gateways demasiado grandes y difíciles de mantener.
Ejemplo conceptual aplicado
Volvamos al ejemplo de reservas y pagos.
Sin gateway, el cliente tiene que crear una reserva, luego pagarla, luego marcarla como pagada. Con un gateway, el cliente puede llamar a un único endpoint como /reservas/completar.
Internamente, el gateway:
- Llama al servicio de reservas
- Llama al servicio de pagos
- Decide qué hacer si el pago falla
- Devuelve una respuesta única al cliente
El cliente no sabe cuántos servicios existen ni en qué orden se llaman. Solo conoce el contrato expuesto por el gateway.
Qué no es un API Gateway
Es importante aclarar qué no debería ser un gateway.
No debería convertirse en un monolito con toda la lógica del sistema. Tampoco debería contener reglas de negocio complejas que pertenecen a los servicios. Si el gateway empieza a decidir estados internos o a replicar validaciones profundas, se está rompiendo la separación de responsabilidades.
Un buen gateway es delgado, claro y orientado a coordinación, no a dominio.
Ejercicio práctico: API Gateway que unifica reservas y pagos
En este ejercicio vas a practicar el rol real de un API Gateway: ofrecer una entrada única al cliente, mientras por detrás coordina llamadas a varios servicios independientes. El objetivo no es “meter lógica de negocio en el gateway”, sino asumir responsabilidades de borde: traducción de contratos, orquestación sencilla, normalización de errores y visibilidad del estado del sistema.
Vamos a reutilizar el escenario del bloque anterior, con estos servicios ya funcionando:
- Servicio de reservas en
http://localhost:5001 - Servicio de pagos en
http://localhost:5002
El gateway vivirá en http://localhost:5000 y expondrá un endpoint de “operación compuesta”:
POST /api/reservas/completar
Ese endpoint hará internamente:
- Crear la reserva en el servicio de reservas
- Procesar el pago en el servicio de pagos
- Si el pago es aceptado, marcar la reserva como pagada en el servicio de reservas
- Devolver una respuesta unificada al cliente
Estructura del mini proyecto
patron-04-api-gateway/
├── api-gateway/
│ ├── package.json
│ └── src/
│ └── server.mjs
└── gateway.http
Importante: este ejercicio no requiere base de datos en el gateway. Su función no es persistir, es coordinar.
4.1 Código del gateway
api-gateway/package.json
{
"name": "api-gateway",
"version": "1.0.0",
"type": "module",
"dependencies": {
"axios": "^1.7.9",
"express": "^4.19.2"
}
}
api-gateway/src/server.mjs
import express from "express";
import axios from "axios";
/*
API Gateway mínimo pero realista.
Qué hace un gateway aquí:
- Expone un contrato estable al cliente
- Conoce la topología interna (URLs de servicios)
- Coordina varias llamadas
- Normaliza errores para el cliente
- Ofrece un health agregado
Qué NO debe hacer:
- No debe duplicar reglas de negocio profundas
- No debe acceder a bases de datos de servicios
- No debe compartir modelos internos de los servicios
*/
const app = express();
const PORT = 5000;
app.use(express.json());
app.use((req, res, next) => {
/*
CORS mínimo para poder consumir el gateway desde un frontend
o desde herramientas externas sin fricción.
*/
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") return res.status(200).end();
next();
});
/*
Topología interna.
En un entorno real esto vendría de variables de entorno, service discovery, etc.
*/
const SERVICES = {
reservas: "http://localhost:5001",
pagos: "http://localhost:5002"
};
/*
Cliente HTTP interno.
Usamos timeout para mostrar que la red es frontera real.
*/
const http = axios.create({
timeout: 4000
});
function isAxiosError(err) {
return Boolean(err && err.isAxiosError);
}
function mapUpstreamError(err) {
/*
Normalización de errores hacia el cliente.
- Si un servicio respondió con status, lo reflejamos.
- Si no hubo respuesta (timeout, conexión), devolvemos 502.
- Evitamos filtrar detalles internos innecesarios.
*/
if (isAxiosError(err)) {
const status = err.response?.status;
if (typeof status === "number") {
return {
status,
body: {
error: "Error en servicio interno",
upstream_status: status,
upstream_data: err.response?.data ?? null
}
};
}
return {
status: 502,
body: {
error: "No se pudo contactar con un servicio interno",
details: err.message
}
};
}
return {
status: 500,
body: {
error: "Error interno en el gateway"
}
};
}
/*
Health agregado.
- El gateway consulta /health de cada servicio.
- Devuelve estado global: healthy o degraded.
*/
app.get("/health", async (req, res) => {
const report = {};
let allHealthy = true;
for (const [name, baseUrl] of Object.entries(SERVICES)) {
try {
const r = await http.get(`${baseUrl}/health`);
report[name] = { status: "healthy", data: r.data };
} catch (err) {
allHealthy = false;
report[name] = {
status: "unhealthy",
error: isAxiosError(err) ? err.message : String(err)
};
}
}
res.json({
gateway: "api-gateway",
status: allHealthy ? "healthy" : "degraded",
services: report,
timestamp: new Date().toISOString()
});
});
/*
Endpoint compuesto: completar una reserva.
Contrato de entrada (cliente -> gateway):
- cliente
- email
- monto
Observa:
- El cliente NO conoce /reservas ni /pagos ni sus puertos.
- El cliente habla solo con el gateway.
Estrategia de consistencia del ejercicio:
- Si el pago es rechazado, la reserva queda "pendiente".
- No hacemos compensación automática aquí (cancelación), para mantener el foco.
- En el bloque de patrones más avanzados podrías introducir SAGA/compensaciones.
*/
app.post("/api/reservas/completar", async (req, res) => {
const { cliente, email, monto } = req.body ?? {};
if (typeof cliente !== "string" || cliente.trim().length < 3) {
return res.status(400).json({ error: "cliente inválido" });
}
if (typeof email !== "string" || !email.includes("@")) {
return res.status(400).json({ error: "email inválido" });
}
const montoNum = Number(monto);
if (!Number.isFinite(montoNum) || montoNum <= 0) {
return res.status(400).json({ error: "monto inválido" });
}
try {
/*
Paso 1: crear reserva.
El gateway delega el dominio al servicio de reservas.
*/
const reservaResp = await http.post(`${SERVICES.reservas}/reservas`, {
cliente: cliente.trim(),
email: email.trim().toLowerCase()
});
const reserva = reservaResp.data;
/*
Paso 2: procesar pago.
El gateway no valida reglas financieras; solo coordina.
*/
const pagoResp = await http.post(`${SERVICES.pagos}/pagos`, {
reserva_id: reserva.reserva_id,
monto: montoNum
});
const pago = pagoResp.data;
/*
Paso 3: aplicar resultado del pago sobre reservas.
Aquí se ve bien el rol de orquestación:
el gateway conecta dos dominios sin que uno conozca al otro.
*/
if (pago.estado === "aceptado") {
const pagadaResp = await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reserva.reserva_id)}/pagar`
);
return res.status(200).json({
ok: true,
mensaje: "Reserva completada",
reserva: pagadaResp.data,
pago
});
}
/*
Si el pago fue rechazado:
- devolvemos una respuesta clara
- la reserva se mantiene pendiente
*/
return res.status(402).json({
ok: false,
mensaje: "Pago rechazado, la reserva queda pendiente",
reserva,
pago
});
} catch (err) {
const mapped = mapUpstreamError(err);
return res.status(mapped.status).json(mapped.body);
}
});
/*
Endpoint informativo.
Permite ver qué expone el gateway, sin revelar demasiado detalle interno.
*/
app.get("/", (req, res) => {
res.json({
gateway: "api-gateway",
endpoints: {
health: "GET /health",
completar_reserva: "POST /api/reservas/completar"
}
});
});
app.listen(PORT, () => {
console.log(`API Gateway en http://localhost:${PORT}`);
console.log("Health: GET /health");
console.log("Completar reserva: POST /api/reservas/completar");
});
4.2 Pruebas con REST Client en VSCode
Crea el archivo patron-04-api-gateway/gateway.http:
### Health del sistema (gateway + servicios)
GET http://localhost:5000/health
### Completar reserva (operación compuesta)
POST http://localhost:5000/api/reservas/completar
Content-Type: application/json
{
"cliente": "Ana Torres",
"email": "ana@demo.local",
"monto": 180
}
Qué debes observar en las respuestas:
- A veces el pago será aceptado y la reserva saldrá como pagada
- A veces el pago será rechazado y la reserva quedará pendiente
- El cliente no ha tenido que hablar con
:5001ni:5002
4.3 Ejecución desde VSCode en Windows
Requisitos previos: deben estar levantados los dos servicios del bloque 3.
- Terminal 1, servicio reservas:
cd ..\patron-03-servicios-independientes\servicio-reservas
node .\src\server.mjs
- Terminal 2, servicio pagos:
cd ..\patron-03-servicios-independientes\servicio-pagos
node .\src\server.mjs
- Terminal 3, gateway:
cd ..\patron-04-api-gateway\api-gateway
npm install
node .\src\server.mjs
- En VSCode, abre
gateway.httpy ejecuta las peticiones.
Reto práctico breve
Sin tocar los servicios internos, añade en el gateway un endpoint:
GET /api/reservas/:reservaId
Comportamiento esperado:
- El cliente llama al gateway
- El gateway consulta al servicio de reservas
GET /reservas/:reservaId - Devuelve la reserva o un 404 normalizado
5. Patrón CRUD distribuido
El patrón CRUD distribuido aparece cuando un sistema ya ha dado varios pasos importantes: el backend está dividido en servicios independientes, existe un API Gateway que actúa como frontera única, y el sistema empieza a exponer operaciones de creación, lectura, actualización y eliminación que ya no pertenecen a un único lugar.
Aquí es donde muchas arquitecturas distribuidas se vuelven difíciles de entender si no se explica bien el concepto.
El problema que introduce la distribución del CRUD
En una arquitectura monolítica o incluso en una arquitectura por capas, una operación CRUD suele tener un recorrido relativamente simple. Crear una entidad implica validar datos, persistirlos y devolver un resultado. Leer implica consultar una base de datos. Todo ocurre dentro del mismo contexto.
En un sistema distribuido, esa simplicidad desaparece. Una sola operación lógica puede implicar varios servicios, cada uno con su propio almacenamiento y sus propias reglas. El “objeto” que el cliente percibe ya no existe como una fila en una tabla, sino como una composición de estados repartidos.
Este es el punto clave del patrón.
Qué significa realmente CRUD distribuido
CRUD distribuido no significa “poner un CRUD en cada servicio”. Eso ya ocurre de forma natural. El patrón se refiere a cómo el sistema presenta operaciones CRUD coherentes hacia el exterior, aunque internamente los datos estén repartidos.
Desde el punto de vista del cliente:
- Existe una operación de creación
- Existe una operación de lectura
- Existe una operación de actualización
- Existe una operación de eliminación
Desde el punto de vista interno, cada una de esas operaciones puede implicar varios pasos, varios servicios y varios estados intermedios.
El API Gateway suele ser la pieza que expone este CRUD unificado, aunque no siempre es la única forma de hacerlo.
Crear en un sistema distribuido
La operación de creación es la más compleja en un CRUD distribuido. Crear algo suele implicar coordinar varios servicios.
En el ejemplo de reservas, crear una “reserva completa” implica:
- Crear la reserva como entidad de dominio
- Procesar un pago
- Cambiar el estado de la reserva
- Opcionalmente, emitir notificaciones
Desde fuera, todo esto se percibe como un único POST. Internamente, es una secuencia de operaciones que pueden fallar de forma parcial.
Aquí aparece un concepto importante: la creación ya no es atómica en el sentido clásico. El sistema debe decidir cómo manejar fallos intermedios y qué estados son aceptables.
Leer cuando los datos están repartidos
La lectura en un CRUD distribuido también cambia de naturaleza. Leer una entidad puede implicar consultar varios servicios y combinar sus respuestas.
Por ejemplo, al pedir una reserva, el cliente puede esperar ver:
- Datos básicos de la reserva
- Estado del pago
- Información adicional derivada
Ningún servicio tiene toda esa información por sí solo. El sistema debe componerla en tiempo real o mantener vistas materializadas.
Este tipo de lectura introduce decisiones de diseño: consistencia frente a rendimiento, tiempo real frente a caché, dependencia fuerte frente a eventual.
Actualizar y eliminar en un entorno distribuido
Las operaciones de actualización y eliminación suelen ser las más delicadas. Cambiar o eliminar algo puede tener consecuencias en cadena.
Cancelar una reserva, por ejemplo, puede implicar:
- Cambiar el estado de la reserva
- Emitir un reembolso
- Liberar recursos asociados
- Notificar al usuario
En un sistema monolítico, todo esto podría ser una transacción. En un sistema distribuido, ya no lo es. El sistema debe diseñarse para tolerar estados intermedios y aplicar compensaciones si es necesario.
Aquí es donde aparecen patrones complementarios como SAGA, eventos y compensación, que no eliminan el problema, pero lo hacen manejable.
Qué aporta realmente este patrón
El patrón CRUD distribuido no busca replicar la comodidad de un CRUD monolítico. Busca algo más realista: ofrecer una interfaz comprensible al cliente, sin ocultar completamente la complejidad interna.
Este patrón fuerza a pensar en términos de procesos, estados y flujos, en lugar de simples operaciones de base de datos.
Es una transición importante en la forma de diseñar sistemas.
Relación con los bloques anteriores
Todo lo visto hasta ahora converge aquí:
- La arquitectura por capas organiza cada servicio internamente
- Los servicios independientes separan dominios y responsabilidades
- El API Gateway unifica el acceso y coordina operaciones
El CRUD distribuido es la forma en que todo eso se manifiesta hacia fuera.
Ejercicio práctico final: CRUD distribuido con servicios independientes y API Gateway
En este ejercicio vas a implementar un CRUD distribuido real. La idea es que el cliente vea un CRUD coherente, pero por detrás haya varios servicios, cada uno con su base de datos y sus responsabilidades. El API Gateway será la única puerta de entrada y será quien coordine las operaciones que atraviesan servicios.
El dominio será un sistema mínimo de reservas con pagos. No es un “hotel completo”, pero sí tiene la complejidad justa para demostrar:
- Servicios independientes, cada uno con su proceso y su base de datos
- Un API Gateway que expone un contrato estable hacia el cliente
- Operaciones CRUD que, por dentro, implican más de un servicio
Qué expone el cliente (a través del gateway):
- Create:
POST /api/reservas - Read:
GET /api/reservas/:reservaId - Update:
PATCH /api/reservas/:reservaId - Delete:
DELETE /api/reservas/:reservaId
Qué pasa por dentro:
- Create atraviesa reservas y pagos
- Read compone datos de reservas y pagos
- Update actualiza reserva y, si procede, reintenta pago
- Delete cancela reserva y, si estaba pagada, inicia reembolso
Estructura del proyecto
patron-05-crud-distribuido/
├── servicio-reservas/
│ ├── package.json
│ └── src/
│ ├── server.mjs
│ ├── db/
│ │ └── sqlite.mjs
│ ├── repositories/
│ │ └── reservas.repository.mjs
│ └── services/
│ └── reservas.service.mjs
├── servicio-pagos/
│ ├── package.json
│ └── src/
│ ├── server.mjs
│ ├── db/
│ │ └── sqlite.mjs
│ ├── repositories/
│ │ └── pagos.repository.mjs
│ └── services/
│ └── pagos.service.mjs
├── api-gateway/
│ ├── package.json
│ └── src/
│ └── server.mjs
└── crud-distribuido.http
1) Servicio de reservas
servicio-reservas/package.json
{
"name": "servicio-reservas",
"version": "1.0.0",
"type": "module",
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.19.2"
}
}
servicio-reservas/src/db/sqlite.mjs
import Database from "better-sqlite3";
import path from "node:path";
import { fileURLToPath } from "node:url";
/*
Base de datos exclusiva del servicio de reservas.
En un sistema distribuido, la propiedad clave es esta:
- Este servicio es dueño de los datos de "reserva".
- Otros servicios no deben leer ni escribir esta base de datos.
- La coordinación con pagos se hace por red y por identificadores.
Decisión didáctica:
- Inicializamos esquema automáticamente para ejecutar desde VSCode sin pasos extra.
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_PATH = path.join(__dirname, "reservas.db");
const db = new Database(DB_PATH);
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS reservas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reserva_id TEXT NOT NULL UNIQUE,
habitacion_tipo TEXT NOT NULL,
fecha_entrada TEXT NOT NULL,
fecha_salida TEXT NOT NULL,
huespedes INTEGER NOT NULL,
cliente TEXT NOT NULL,
email TEXT NOT NULL,
estado TEXT NOT NULL CHECK(estado IN ('pendiente','pagada','cancelada')),
monto_total REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
cancel_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_reservas_email ON reservas(email);
CREATE INDEX IF NOT EXISTS idx_reservas_estado ON reservas(estado);
`);
export { db, DB_PATH };
servicio-reservas/src/repositories/reservas.repository.mjs
import { db } from "../db/sqlite.mjs";
/*
Repositorio: SQL centralizado aquí.
Regla de oro:
- El repositorio no decide negocio.
- El repositorio no sabe nada de HTTP.
- El repositorio devuelve filas o resultados técnicos.
*/
const stmtInsert = db.prepare(`
INSERT INTO reservas (
reserva_id, habitacion_tipo, fecha_entrada, fecha_salida,
huespedes, cliente, email, estado, monto_total
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const stmtGetByReservaId = db.prepare(`
SELECT *
FROM reservas
WHERE reserva_id = ?
`);
const stmtPatchDatos = db.prepare(`
UPDATE reservas
SET cliente = ?,
email = ?,
updated_at = datetime('now')
WHERE reserva_id = ?
`);
const stmtSetEstado = db.prepare(`
UPDATE reservas
SET estado = ?,
updated_at = datetime('now')
WHERE reserva_id = ?
`);
const stmtCancelar = db.prepare(`
UPDATE reservas
SET estado = 'cancelada',
cancel_reason = ?,
updated_at = datetime('now')
WHERE reserva_id = ?
`);
const stmtDelete = db.prepare(`
DELETE FROM reservas
WHERE reserva_id = ?
`);
export function insertReserva(row) {
stmtInsert.run(
row.reserva_id,
row.habitacion_tipo,
row.fecha_entrada,
row.fecha_salida,
row.huespedes,
row.cliente,
row.email,
row.estado,
row.monto_total
);
}
export function getReservaByReservaId(reservaId) {
return stmtGetByReservaId.get(reservaId);
}
export function patchDatosContacto(reservaId, cliente, email) {
return stmtPatchDatos.run(cliente, email, reservaId).changes;
}
export function setEstado(reservaId, estado) {
return stmtSetEstado.run(estado, reservaId).changes;
}
export function cancelarReserva(reservaId, reason) {
return stmtCancelar.run(reason ?? null, reservaId).changes;
}
export function deleteReserva(reservaId) {
return stmtDelete.run(reservaId).changes;
}
servicio-reservas/src/services/reservas.service.mjs
import crypto from "node:crypto";
import {
insertReserva,
getReservaByReservaId,
patchDatosContacto,
setEstado,
cancelarReserva,
deleteReserva
} from "../repositories/reservas.repository.mjs";
/*
Capa de negocio del servicio de reservas.
Responsabilidad de este servicio:
- Validar y crear reservas
- Mantener el estado de la reserva (pendiente, pagada, cancelada)
- Calcular monto_total (reglas locales)
- No procesa pagos ni consulta pagos
*/
function createError(code, message, details = null) {
const err = new Error(message);
err.code = code;
err.details = details;
return err;
}
function isISODate(s) {
return typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function calcularNoches(fechaEntrada, fechaSalida) {
const a = new Date(`${fechaEntrada}T00:00:00.000Z`);
const b = new Date(`${fechaSalida}T00:00:00.000Z`);
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null;
const ms = b.getTime() - a.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
function calcularMonto({ habitacion_tipo, noches, huespedes }) {
/*
Reglas simplificadas pero realistas.
La clave es que el cálculo vive en el servicio dueño del dominio "reserva".
*/
const base = { standard: 95, deluxe: 150, suite: 230 }[habitacion_tipo];
let precioNoche = base;
if (huespedes > 2) {
precioNoche += (huespedes - 2) * 18;
}
const subtotal = precioNoche * noches;
const descuento = noches >= 7 ? subtotal * 0.1 : 0;
return Math.round((subtotal - descuento) * 100) / 100;
}
function generarReservaId() {
return `RES-${Date.now()}-${crypto.randomBytes(2).toString("hex").toUpperCase()}`;
}
export function crearReserva(input) {
const tiposValidos = ["standard", "deluxe", "suite"];
const habitacion_tipo = typeof input.habitacion_tipo === "string" ? input.habitacion_tipo.trim() : "";
const fecha_entrada = input.fecha_entrada;
const fecha_salida = input.fecha_salida;
const huespedes = Number(input.huespedes);
const cliente = typeof input.cliente === "string" ? input.cliente.trim() : "";
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
if (!tiposValidos.includes(habitacion_tipo)) {
throw createError("VALIDATION_ERROR", "habitacion_tipo inválido", { allowed: tiposValidos });
}
if (!isISODate(fecha_entrada) || !isISODate(fecha_salida)) {
throw createError("VALIDATION_ERROR", "fechas inválidas, usa YYYY-MM-DD");
}
const noches = calcularNoches(fecha_entrada, fecha_salida);
if (noches === null || noches <= 0) {
throw createError("VALIDATION_ERROR", "fecha_salida debe ser posterior a fecha_entrada");
}
if (!Number.isInteger(huespedes) || huespedes < 1 || huespedes > 6) {
throw createError("VALIDATION_ERROR", "huespedes inválido, entero entre 1 y 6");
}
if (cliente.length < 3) {
throw createError("VALIDATION_ERROR", "cliente inválido");
}
if (!email.includes("@") || email.length < 6) {
throw createError("VALIDATION_ERROR", "email inválido");
}
const monto_total = calcularMonto({ habitacion_tipo, noches, huespedes });
const reserva_id = generarReservaId();
insertReserva({
reserva_id,
habitacion_tipo,
fecha_entrada,
fecha_salida,
huespedes,
cliente,
email,
estado: "pendiente",
monto_total
});
return getReservaByReservaId(reserva_id);
}
export function obtenerReserva(reservaId) {
const row = getReservaByReservaId(reservaId);
if (!row) throw createError("NOT_FOUND", "reserva no encontrada");
return row;
}
export function actualizarContacto(reservaId, { cliente, email }) {
const row = getReservaByReservaId(reservaId);
if (!row) throw createError("NOT_FOUND", "reserva no encontrada");
/*
Regla de negocio importante:
- Permitimos modificar datos de contacto solo si no está cancelada.
- En un caso real podrías restringir más.
*/
if (row.estado === "cancelada") {
throw createError("CONFLICT", "no se puede modificar una reserva cancelada");
}
const nextCliente = typeof cliente === "string" ? cliente.trim() : row.cliente;
const nextEmail = typeof email === "string" ? email.trim().toLowerCase() : row.email;
if (nextCliente.length < 3) throw createError("VALIDATION_ERROR", "cliente inválido");
if (!nextEmail.includes("@") || nextEmail.length < 6) throw createError("VALIDATION_ERROR", "email inválido");
patchDatosContacto(reservaId, nextCliente, nextEmail);
return getReservaByReservaId(reservaId);
}
export function marcarPagada(reservaId) {
const row = getReservaByReservaId(reservaId);
if (!row) throw createError("NOT_FOUND", "reserva no encontrada");
if (row.estado === "cancelada") throw createError("CONFLICT", "reserva cancelada no puede pagarse");
if (row.estado === "pagada") return row;
setEstado(reservaId, "pagada");
return getReservaByReservaId(reservaId);
}
export function cancelar(reservaId, reason) {
const row = getReservaByReservaId(reservaId);
if (!row) throw createError("NOT_FOUND", "reserva no encontrada");
if (row.estado === "cancelada") return row;
const r = typeof reason === "string" && reason.trim().length > 0 ? reason.trim() : "CANCELACION_USUARIO";
cancelarReserva(reservaId, r);
return getReservaByReservaId(reservaId);
}
export function eliminar(reservaId) {
/*
En sistemas reales se usa mucho borrado lógico.
Aquí hacemos borrado físico para mostrar el DELETE distribuido claramente.
*/
const row = getReservaByReservaId(reservaId);
if (!row) throw createError("NOT_FOUND", "reserva no encontrada");
deleteReserva(reservaId);
return { deleted: true, reserva_id: reservaId };
}
export function mapServiceError(err) {
const code = err?.code;
if (code === "VALIDATION_ERROR") return { status: 400, body: { error: err.message, details: err.details ?? null } };
if (code === "NOT_FOUND") return { status: 404, body: { error: err.message } };
if (code === "CONFLICT") return { status: 409, body: { error: err.message } };
return { status: 500, body: { error: "error interno" } };
}
servicio-reservas/src/server.mjs
import express from "express";
import { DB_PATH } from "./db/sqlite.mjs";
import {
crearReserva,
obtenerReserva,
actualizarContacto,
marcarPagada,
cancelar,
eliminar,
mapServiceError
} from "./services/reservas.service.mjs";
/*
API HTTP del servicio de reservas.
Observa el punto arquitectónico:
- Este servicio no sabe que existe un API Gateway.
- Este servicio expone su propio contrato interno.
- El gateway será quien traduzca y coordine hacia fuera.
*/
const app = express();
const PORT = 5101;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "reservas",
status: "healthy",
port: PORT,
db: DB_PATH
});
});
app.post("/reservas", (req, res) => {
try {
const r = crearReserva(req.body);
res.status(201).json(r);
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.get("/reservas/:reservaId", (req, res) => {
try {
res.json(obtenerReserva(req.params.reservaId));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.patch("/reservas/:reservaId/contacto", (req, res) => {
try {
res.json(actualizarContacto(req.params.reservaId, req.body ?? {}));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.patch("/reservas/:reservaId/pagar", (req, res) => {
try {
res.json(marcarPagada(req.params.reservaId));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.patch("/reservas/:reservaId/cancelar", (req, res) => {
try {
const { reason } = req.body ?? {};
res.json(cancelar(req.params.reservaId, reason));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.delete("/reservas/:reservaId", (req, res) => {
try {
res.json(eliminar(req.params.reservaId));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.listen(PORT, () => {
console.log(`Servicio reservas en http://localhost:${PORT}`);
});
2) Servicio de pagos
servicio-pagos/package.json
{
"name": "servicio-pagos",
"version": "1.0.0",
"type": "module",
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.19.2"
}
}
servicio-pagos/src/db/sqlite.mjs
import Database from "better-sqlite3";
import path from "node:path";
import { fileURLToPath } from "node:url";
/*
Base de datos exclusiva del servicio de pagos.
Punto clave:
- Pagos no consulta la base de reservas.
- Pagos solo conoce reserva_id como un identificador externo.
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_PATH = path.join(__dirname, "pagos.db");
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS pagos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaccion_id TEXT NOT NULL UNIQUE,
reserva_id TEXT NOT NULL,
monto REAL NOT NULL,
metodo TEXT NOT NULL,
estado TEXT NOT NULL CHECK(estado IN ('aceptado','rechazado','reembolsado')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_pagos_reserva ON pagos(reserva_id);
`);
export { db, DB_PATH };
servicio-pagos/src/repositories/pagos.repository.mjs
import { db } from "../db/sqlite.mjs";
/*
Repositorio del servicio de pagos.
SQL centralizado aquí.
*/
const stmtInsert = db.prepare(`
INSERT INTO pagos (transaccion_id, reserva_id, monto, metodo, estado, metadata)
VALUES (?, ?, ?, ?, ?, ?)
`);
const stmtGetByTx = db.prepare(`
SELECT *
FROM pagos
WHERE transaccion_id = ?
`);
const stmtGetLatestByReserva = db.prepare(`
SELECT *
FROM pagos
WHERE reserva_id = ?
ORDER BY id DESC
LIMIT 1
`);
const stmtSetReembolsado = db.prepare(`
UPDATE pagos
SET estado = 'reembolsado',
metadata = ?
WHERE transaccion_id = ?
`);
export function insertPago(row) {
stmtInsert.run(
row.transaccion_id,
row.reserva_id,
row.monto,
row.metodo,
row.estado,
row.metadata ?? null
);
}
export function getPagoByTransaccionId(txId) {
return stmtGetByTx.get(txId);
}
export function getLatestPagoByReservaId(reservaId) {
return stmtGetLatestByReserva.get(reservaId);
}
export function setReembolsado(txId, metadata) {
return stmtSetReembolsado.run(metadata ?? null, txId).changes;
}
servicio-pagos/src/services/pagos.service.mjs
import crypto from "node:crypto";
import {
insertPago,
getPagoByTransaccionId,
getLatestPagoByReservaId,
setReembolsado
} from "../repositories/pagos.repository.mjs";
/*
Lógica del dominio pagos.
Responsabilidad de pagos:
- Autorizar un pago (simulado)
- Consultar estado de pago
- Reembolsar un pago aceptado (simulado)
Importante:
- Pagos no valida si la reserva existe.
- Esa coordinación la hará el API Gateway en el CRUD distribuido.
*/
function createError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
function generarTxId() {
return `TX-${Date.now()}-${crypto.randomBytes(3).toString("hex").toUpperCase()}`;
}
export function autorizarPago({ reserva_id, monto, metodo }) {
const r = typeof reserva_id === "string" ? reserva_id.trim() : "";
const m = Number(monto);
const met = typeof metodo === "string" ? metodo.trim() : "";
if (!r) throw createError("VALIDATION_ERROR", "reserva_id requerido");
if (!Number.isFinite(m) || m <= 0) throw createError("VALIDATION_ERROR", "monto inválido");
if (!met) throw createError("VALIDATION_ERROR", "metodo requerido");
/*
Simulación controlada:
- 90% aceptado
- 10% rechazado
Esto sirve para ver estados intermedios en el CRUD distribuido.
*/
const estado = Math.random() > 0.1 ? "aceptado" : "rechazado";
const tx = generarTxId();
insertPago({
transaccion_id: tx,
reserva_id: r,
monto: m,
metodo: met,
estado,
metadata: JSON.stringify({ simulated: true })
});
return getPagoByTransaccionId(tx);
}
export function obtenerPagoPorReserva(reservaId) {
const r = typeof reservaId === "string" ? reservaId.trim() : "";
if (!r) throw createError("VALIDATION_ERROR", "reserva_id requerido");
const pago = getLatestPagoByReservaId(r);
return pago ?? null;
}
export function reembolsar(transaccion_id) {
const tx = typeof transaccion_id === "string" ? transaccion_id.trim() : "";
if (!tx) throw createError("VALIDATION_ERROR", "transaccion_id requerido");
const pago = getPagoByTransaccionId(tx);
if (!pago) throw createError("NOT_FOUND", "pago no encontrado");
if (pago.estado !== "aceptado") {
throw createError("CONFLICT", "solo se reembolsan pagos aceptados");
}
setReembolsado(tx, JSON.stringify({ refunded_at: new Date().toISOString(), simulated: true }));
return getPagoByTransaccionId(tx);
}
export function mapServiceError(err) {
const code = err?.code;
if (code === "VALIDATION_ERROR") return { status: 400, body: { error: err.message } };
if (code === "NOT_FOUND") return { status: 404, body: { error: err.message } };
if (code === "CONFLICT") return { status: 409, body: { error: err.message } };
return { status: 500, body: { error: "error interno" } };
}
servicio-pagos/src/server.mjs
import express from "express";
import { DB_PATH } from "./db/sqlite.mjs";
import { autorizarPago, obtenerPagoPorReserva, reembolsar, mapServiceError } from "./services/pagos.service.mjs";
/*
API HTTP del servicio de pagos.
Observa:
- Este servicio no conoce al gateway.
- Expone endpoints internos y simples.
*/
const app = express();
const PORT = 5102;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "pagos",
status: "healthy",
port: PORT,
db: DB_PATH
});
});
app.post("/pagos/autorizar", (req, res) => {
try {
res.status(201).json(autorizarPago(req.body ?? {}));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.get("/pagos/por-reserva/:reservaId", (req, res) => {
try {
res.json({ pago: obtenerPagoPorReserva(req.params.reservaId) });
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.post("/pagos/:transaccionId/reembolsar", (req, res) => {
try {
res.json(reembolsar(req.params.transaccionId));
} catch (err) {
const mapped = mapServiceError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.listen(PORT, () => {
console.log(`Servicio pagos en http://localhost:${PORT}`);
});
3) API Gateway
api-gateway/package.json
{
"name": "api-gateway",
"version": "1.0.0",
"type": "module",
"dependencies": {
"axios": "^1.7.9",
"express": "^4.19.2"
}
}
api-gateway/src/server.mjs
import express from "express";
import axios from "axios";
/*
CRUD distribuido: el gateway expone un CRUD "bonito" hacia fuera,
pero por dentro coordina servicios.
En este ejercicio, el gateway hace cuatro cosas importantes:
- Crea una reserva y autoriza un pago (Create distribuido)
- Compone lectura de reserva + pago (Read distribuido)
- Actualiza contacto y, si se pide, reintenta pago (Update distribuido)
- Cancela y, si estaba pagada, reembolsa (Delete distribuido)
Nota pedagógica:
- En un sistema real, "Delete" suele ser borrado lógico.
- Aquí hacemos un flujo que deja claro el cruce entre servicios.
*/
const app = express();
const PORT = 5100;
app.use(express.json());
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") return res.status(200).end();
next();
});
const SERVICES = {
reservas: "http://localhost:5101",
pagos: "http://localhost:5102"
};
const http = axios.create({ timeout: 4000 });
function isAxiosError(err) {
return Boolean(err && err.isAxiosError);
}
function normalizeUpstreamError(err) {
/*
Normalizamos errores internos para que el cliente no tenga que conocer
detalles de cada servicio.
Regla:
- Si un servicio respondió con status, propagamos ese status y adjuntamos contexto.
- Si no hay respuesta (caída, timeout), devolvemos 502.
*/
if (isAxiosError(err)) {
const status = err.response?.status;
if (typeof status === "number") {
return {
status,
body: {
error: "error en servicio interno",
upstream_status: status,
upstream_data: err.response?.data ?? null
}
};
}
return {
status: 502,
body: { error: "no se pudo contactar con un servicio interno", details: err.message }
};
}
return { status: 500, body: { error: "error interno en gateway" } };
}
app.get("/health", async (req, res) => {
const report = {};
let allHealthy = true;
for (const [name, base] of Object.entries(SERVICES)) {
try {
const r = await http.get(`${base}/health`);
report[name] = { status: "healthy", data: r.data };
} catch (err) {
allHealthy = false;
report[name] = { status: "unhealthy", error: isAxiosError(err) ? err.message : String(err) };
}
}
res.json({
gateway: "crud-distribuido",
status: allHealthy ? "healthy" : "degraded",
services: report,
timestamp: new Date().toISOString()
});
});
/*
CREATE distribuido
POST /api/reservas
Entrada esperada:
- habitacion_tipo, fecha_entrada, fecha_salida, huespedes, cliente, email, metodo_pago
Flujo interno:
1) Crear reserva en servicio-reservas (queda pendiente, con monto_total calculado)
2) Autorizar pago en servicio-pagos usando monto_total y reserva_id
3) Si aceptado, marcar reserva como pagada en servicio-reservas
4) Responder con un único objeto compuesto (reserva + pago)
*/
app.post("/api/reservas", async (req, res) => {
const { metodo_pago, ...datosReserva } = req.body ?? {};
if (typeof metodo_pago !== "string" || metodo_pago.trim().length === 0) {
return res.status(400).json({ error: "metodo_pago requerido" });
}
try {
const reservaResp = await http.post(`${SERVICES.reservas}/reservas`, datosReserva);
const reserva = reservaResp.data;
const pagoResp = await http.post(`${SERVICES.pagos}/pagos/autorizar`, {
reserva_id: reserva.reserva_id,
monto: reserva.monto_total,
metodo: metodo_pago
});
const pago = pagoResp.data;
if (pago.estado === "aceptado") {
const pagadaResp = await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reserva.reserva_id)}/pagar`
);
return res.status(201).json({
ok: true,
mensaje: "reserva creada y pagada",
reserva: pagadaResp.data,
pago
});
}
return res.status(402).json({
ok: false,
mensaje: "reserva creada, pago rechazado, queda pendiente",
reserva,
pago
});
} catch (err) {
const mapped = normalizeUpstreamError(err);
res.status(mapped.status).json(mapped.body);
}
});
/*
READ distribuido
GET /api/reservas/:reservaId
Flujo interno:
1) Leer reserva en servicio-reservas
2) Leer último pago en servicio-pagos (puede ser null)
3) Devolver un objeto compuesto
*/
app.get("/api/reservas/:reservaId", async (req, res) => {
const reservaId = req.params.reservaId;
try {
const reservaResp = await http.get(`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`);
const reserva = reservaResp.data;
const pagoResp = await http.get(`${SERVICES.pagos}/pagos/por-reserva/${encodeURIComponent(reservaId)}`);
const pago = pagoResp.data?.pago ?? null;
res.json({ reserva, pago });
} catch (err) {
const mapped = normalizeUpstreamError(err);
res.status(mapped.status).json(mapped.body);
}
});
/*
UPDATE distribuido
PATCH /api/reservas/:reservaId
Objetivo didáctico:
- Un PATCH puede implicar más de un servicio.
- Aquí permitimos:
- actualizar cliente/email (servicio-reservas)
- si la reserva sigue pendiente, reintentar pago con un metodo_pago nuevo (servicio-pagos + servicio-reservas)
Entrada permitida:
- cliente (opcional)
- email (opcional)
- reintentar_pago (boolean opcional)
- metodo_pago (si reintentar_pago=true)
*/
app.patch("/api/reservas/:reservaId", async (req, res) => {
const reservaId = req.params.reservaId;
const { cliente, email, reintentar_pago, metodo_pago } = req.body ?? {};
try {
// 1) Actualizar contacto si viene algún campo
if (typeof cliente === "string" || typeof email === "string") {
await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}/contacto`,
{ cliente, email }
);
}
// 2) Leer estado actual para decidir si tiene sentido reintentar pago
const reservaResp = await http.get(`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`);
let reserva = reservaResp.data;
if (reintentar_pago === true) {
if (typeof metodo_pago !== "string" || metodo_pago.trim().length === 0) {
return res.status(400).json({ error: "metodo_pago requerido para reintentar_pago" });
}
if (reserva.estado === "cancelada") {
return res.status(409).json({ error: "no se puede pagar una reserva cancelada" });
}
if (reserva.estado === "pagada") {
return res.status(409).json({ error: "la reserva ya está pagada" });
}
// 3) Autorizar pago nuevamente
const pagoResp = await http.post(`${SERVICES.pagos}/pagos/autorizar`, {
reserva_id: reserva.reserva_id,
monto: reserva.monto_total,
metodo: metodo_pago
});
const pago = pagoResp.data;
if (pago.estado === "aceptado") {
const pagadaResp = await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reserva.reserva_id)}/pagar`
);
reserva = pagadaResp.data;
return res.json({
ok: true,
mensaje: "contacto actualizado y pago aceptado",
reserva,
pago
});
}
return res.status(402).json({
ok: false,
mensaje: "contacto actualizado, pago rechazado, sigue pendiente",
reserva,
pago
});
}
// Si no hay reintento, devolvemos el recurso actualizado
const pagoResp = await http.get(`${SERVICES.pagos}/pagos/por-reserva/${encodeURIComponent(reservaId)}`);
const pago = pagoResp.data?.pago ?? null;
res.json({
ok: true,
mensaje: "reserva actualizada",
reserva,
pago
});
} catch (err) {
const mapped = normalizeUpstreamError(err);
res.status(mapped.status).json(mapped.body);
}
});
/*
DELETE distribuido
DELETE /api/reservas/:reservaId
En un sistema real, esto sería cancelación + borrado lógico.
Aquí hacemos un flujo claro para ver el cruce entre servicios:
Flujo:
1) Leer reserva
2) Si tiene pago aceptado, reembolsar el pago más reciente
3) Cancelar la reserva en servicio-reservas
4) (opcional) Borrar físicamente la reserva, solo si se pide con ?hard=true
Por defecto:
- Cancelamos, pero no borramos físicamente.
*/
app.delete("/api/reservas/:reservaId", async (req, res) => {
const reservaId = req.params.reservaId;
const hard = String(req.query.hard ?? "false").toLowerCase() === "true";
try {
// 1) Leer reserva
const reservaResp = await http.get(`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`);
const reserva = reservaResp.data;
// 2) Consultar último pago
const pagoResp = await http.get(`${SERVICES.pagos}/pagos/por-reserva/${encodeURIComponent(reservaId)}`);
const pago = pagoResp.data?.pago ?? null;
// 3) Si el último pago está aceptado, intentar reembolso
let reembolso = null;
if (pago && pago.estado === "aceptado") {
const reembolsoResp = await http.post(
`${SERVICES.pagos}/pagos/${encodeURIComponent(pago.transaccion_id)}/reembolsar`
);
reembolso = reembolsoResp.data;
}
// 4) Cancelar reserva
const cancelResp = await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}/cancelar`,
{ reason: "CANCELADA_DESDE_GATEWAY" }
);
// 5) Borrado físico opcional (solo para demostrar DELETE “fuerte”)
let hardDelete = null;
if (hard === true) {
const delResp = await http.delete(`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`);
hardDelete = delResp.data;
}
res.json({
ok: true,
mensaje: hard ? "reserva cancelada y eliminada" : "reserva cancelada",
reserva: cancelResp.data,
pago_ultimo: pago,
reembolso,
hard_delete: hardDelete
});
} catch (err) {
const mapped = normalizeUpstreamError(err);
res.status(mapped.status).json(mapped.body);
}
});
app.listen(PORT, () => {
console.log(`API Gateway en http://localhost:${PORT}`);
});
4) Pruebas con REST Client
Crea el archivo patron-05-crud-distribuido/crud-distribuido.http:
### Health completo (gateway + servicios)
GET http://localhost:5100/health
### CREATE distribuido (crea reserva y autoriza pago)
POST http://localhost:5100/api/reservas
Content-Type: application/json
{
"habitacion_tipo": "deluxe",
"fecha_entrada": "2025-12-20",
"fecha_salida": "2025-12-23",
"huespedes": 3,
"cliente": "Ana Torres",
"email": "ana@demo.local",
"metodo_pago": "tarjeta"
}
### READ distribuido (compone reserva + pago)
GET http://localhost:5100/api/reservas/RES-PEGA_AQUI
### UPDATE distribuido (cambia contacto)
PATCH http://localhost:5100/api/reservas/RES-PEGA_AQUI
Content-Type: application/json
{
"cliente": "Ana Torres Actualizada",
"email": "ana.actualizada@demo.local"
}
### UPDATE distribuido (reintentar pago si quedó pendiente)
PATCH http://localhost:5100/api/reservas/RES-PEGA_AQUI
Content-Type: application/json
{
"reintentar_pago": true,
"metodo_pago": "paypal"
}
### DELETE distribuido (cancelar y, si estaba pagada, reembolsar)
DELETE http://localhost:5100/api/reservas/RES-PEGA_AQUI
### DELETE distribuido fuerte (cancelar y borrar físicamente)
DELETE http://localhost:5100/api/reservas/RES-PEGA_AQUI?hard=true
5) Ejecución desde VSCode en Windows
Abre la carpeta patron-05-crud-distribuido/ en VSCode y usa tres terminales.
Terminal 1, servicio reservas:
cd .\servicio-reservas
npm install
node .\src\server.mjs
Terminal 2, servicio pagos:
cd ..\servicio-pagos
npm install
node .\src\server.mjs
Terminal 3, API Gateway:
cd ..\api-gateway
npm install
node .\src\server.mjs
Ahora abre crud-distribuido.http y ejecuta:
- Primero
GET /health - Luego
POST /api/reservas - Copia el
reserva_iddevuelto y pégalo en las rutas de READ/UPDATE/DELETE
Qué debes fijar para que el aprendizaje sea completo
En este ejercicio hay tres ideas que conviene observar con intención:
- El cliente solo habla con el gateway, pero el estado real está repartido
- El Read no “lee una tabla”, compone información de dos servicios
- El Delete no es un simple borrado: puede implicar cancelación y reembolso
Extensión del ejercicio
Continuación del ejercicio: endurecer el CRUD distribuido para que se parezca a un sistema real
Hasta ahora tienes un CRUD distribuido funcional. A partir de aquí lo vamos a mejorar con tres ideas que, en cuanto hay más de un servicio, dejan de ser opcionales:
- Identificadores de correlación para seguir una operación entre servicios
- Reintentos controlados en el gateway cuando un servicio falla de forma transitoria
- Idempotencia en el Create distribuido para evitar duplicados cuando el cliente reintenta
No vamos a convertir esto en un framework. Lo haremos con cambios pequeños, explícitos y didácticos.
Qué problema vamos a resolver exactamente
En distribuido, el caso típico es este:
El cliente hace POST /api/reservas, el gateway crea la reserva, intenta el pago… y justo ahí hay un timeout. El cliente no sabe si se cobró o no. Reintenta el POST y crea una segunda reserva. El sistema queda sucio.
Esto no se arregla con “tener cuidado”. Se arregla con idempotencia.
Paso 1: añadir Request ID y propagarlo entre servicios
Queremos que cada petición que entra al gateway tenga un x-request-id y que ese valor se reenvíe a los servicios internos. Así, cuando un alumno mire consola y logs, podrá seguir el mismo identificador en los tres procesos.
Cambios en el gateway
Sustituye el archivo completo:
patron-05-crud-distribuido/api-gateway/src/server.mjs
import express from "express";
import axios from "axios";
import crypto from "node:crypto";
/*
Mejoras de esta versión del gateway:
1) Correlación:
- Si el cliente no envía x-request-id, lo generamos.
- Propagamos x-request-id a todos los servicios internos.
2) Reintento controlado:
- Algunos fallos (timeouts, 502, 503) pueden ser transitorios.
- Reintentamos un número pequeño de veces con un pequeño backoff.
3) Idempotencia en CREATE:
- Si el cliente reintenta POST /api/reservas, no queremos duplicar reservas.
- Usamos x-idempotency-key para recordar el resultado del create.
Importante:
- La idempotencia aquí es un cache en memoria para laboratorio.
- En producción se almacena en Redis o en una tabla de idempotency_keys.
*/
const app = express();
const PORT = 5100;
app.use(express.json());
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type,x-request-id,x-idempotency-key");
if (req.method === "OPTIONS") return res.status(200).end();
next();
});
const SERVICES = {
reservas: "http://localhost:5101",
pagos: "http://localhost:5102"
};
/*
Cliente HTTP.
El timeout obliga a asumir que la red falla.
*/
const http = axios.create({ timeout: 2500 });
/*
Memoria de idempotencia (laboratorio).
key: string (x-idempotency-key)
value: { status, body, createdAtMs }
Política:
- Guardamos solo para el endpoint CREATE
- TTL corto para no crecer indefinidamente
*/
const IDEMPOTENCY_TTL_MS = 10 * 60 * 1000;
const idempotencyStore = new Map();
function nowMs() {
return Date.now();
}
function cleanupIdempotencyStore() {
const cutoff = nowMs() - IDEMPOTENCY_TTL_MS;
for (const [key, value] of idempotencyStore.entries()) {
if (value.createdAtMs < cutoff) idempotencyStore.delete(key);
}
}
function getOrCreateRequestId(req) {
const incoming = req.headers["x-request-id"];
if (typeof incoming === "string" && incoming.trim().length > 0) return incoming.trim();
return `req-${crypto.randomBytes(6).toString("hex")}`;
}
function buildUpstreamHeaders(requestId) {
return {
"x-request-id": requestId
};
}
function isAxiosError(err) {
return Boolean(err && err.isAxiosError);
}
function normalizeUpstreamError(err, requestId) {
/*
Normalizamos para el cliente y añadimos request_id
para que pueda reportar o buscar en logs.
*/
if (isAxiosError(err)) {
const status = err.response?.status;
if (typeof status === "number") {
return {
status,
body: {
error: "error en servicio interno",
request_id: requestId,
upstream_status: status,
upstream_data: err.response?.data ?? null
}
};
}
return {
status: 502,
body: {
error: "no se pudo contactar con un servicio interno",
request_id: requestId,
details: err.message
}
};
}
return { status: 500, body: { error: "error interno en gateway", request_id: requestId } };
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function isRetryable(err) {
/*
Qué consideramos reintentable en este laboratorio:
- Sin respuesta (timeout, conexión) => 502 por nuestra normalización
- Respuestas 502, 503, 504
*/
if (!isAxiosError(err)) return false;
const status = err.response?.status;
if (typeof status === "number") {
return [502, 503, 504].includes(status);
}
return true;
}
async function requestWithRetry(fn, { retries, baseDelayMs }) {
/*
Reintento simple con backoff incremental.
- Intento 1: sin delay
- Intento 2: baseDelayMs
- Intento 3: baseDelayMs * 2
*/
let lastErr = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
if (attempt > 0) await sleep(baseDelayMs * attempt);
return await fn();
} catch (err) {
lastErr = err;
if (!isRetryable(err) || attempt === retries) throw err;
}
}
throw lastErr;
}
app.use((req, res, next) => {
/*
Middleware de correlación:
- Genera request_id si falta
- Lo expone en la respuesta
*/
const requestId = getOrCreateRequestId(req);
req.requestId = requestId;
res.setHeader("x-request-id", requestId);
next();
});
app.get("/health", async (req, res) => {
const requestId = req.requestId;
const report = {};
let allHealthy = true;
for (const [name, base] of Object.entries(SERVICES)) {
try {
const r = await http.get(`${base}/health`, {
headers: buildUpstreamHeaders(requestId)
});
report[name] = { status: "healthy", data: r.data };
} catch (err) {
allHealthy = false;
report[name] = { status: "unhealthy", error: isAxiosError(err) ? err.message : String(err) };
}
}
res.json({
gateway: "crud-distribuido",
status: allHealthy ? "healthy" : "degraded",
request_id: requestId,
services: report,
timestamp: new Date().toISOString()
});
});
/*
CREATE distribuido con idempotencia.
Reglas:
- Exigimos x-idempotency-key para POST /api/reservas
- Si llega la misma key, devolvemos exactamente la misma respuesta
- Así el cliente puede reintentar sin crear duplicados
Esto enseña una idea importante:
- La idempotencia se controla en el borde (gateway) si el contrato externo lo requiere
*/
app.post("/api/reservas", async (req, res) => {
const requestId = req.requestId;
cleanupIdempotencyStore();
const idempotencyKeyRaw = req.headers["x-idempotency-key"];
const idempotencyKey =
typeof idempotencyKeyRaw === "string" ? idempotencyKeyRaw.trim() : "";
if (!idempotencyKey) {
return res.status(400).json({
error: "x-idempotency-key requerido en POST /api/reservas",
request_id: requestId
});
}
const stored = idempotencyStore.get(idempotencyKey);
if (stored) {
/*
Esto es el corazón del patrón.
Si el cliente reintenta, no creamos nada.
Respondemos con lo mismo para que el cliente pueda continuar el flujo.
*/
return res.status(stored.status).json(stored.body);
}
const { metodo_pago, ...datosReserva } = req.body ?? {};
if (typeof metodo_pago !== "string" || metodo_pago.trim().length === 0) {
return res.status(400).json({ error: "metodo_pago requerido", request_id: requestId });
}
try {
/*
Creamos la reserva primero.
Si algo falla después, la reserva puede quedar pendiente.
Eso es normal en distribuido: habrá estados intermedios.
*/
const reservaResp = await requestWithRetry(
() =>
http.post(`${SERVICES.reservas}/reservas`, datosReserva, {
headers: buildUpstreamHeaders(requestId)
}),
{ retries: 1, baseDelayMs: 150 }
);
const reserva = reservaResp.data;
/*
Autorizamos el pago.
Lo reintentamos solo en fallos transitorios.
*/
const pagoResp = await requestWithRetry(
() =>
http.post(
`${SERVICES.pagos}/pagos/autorizar`,
{
reserva_id: reserva.reserva_id,
monto: reserva.monto_total,
metodo: metodo_pago
},
{ headers: buildUpstreamHeaders(requestId) }
),
{ retries: 2, baseDelayMs: 200 }
);
const pago = pagoResp.data;
let responseStatus;
let responseBody;
if (pago.estado === "aceptado") {
const pagadaResp = await requestWithRetry(
() =>
http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reserva.reserva_id)}/pagar`,
{},
{ headers: buildUpstreamHeaders(requestId) }
),
{ retries: 1, baseDelayMs: 150 }
);
responseStatus = 201;
responseBody = {
ok: true,
mensaje: "reserva creada y pagada",
request_id: requestId,
reserva: pagadaResp.data,
pago
};
} else {
responseStatus = 402;
responseBody = {
ok: false,
mensaje: "reserva creada, pago rechazado, queda pendiente",
request_id: requestId,
reserva,
pago
};
}
/*
Guardamos el resultado completo por idempotencyKey.
Si el cliente reintenta con la misma key, verá lo mismo.
*/
idempotencyStore.set(idempotencyKey, {
status: responseStatus,
body: responseBody,
createdAtMs: nowMs()
});
return res.status(responseStatus).json(responseBody);
} catch (err) {
const mapped = normalizeUpstreamError(err, requestId);
return res.status(mapped.status).json(mapped.body);
}
});
/*
READ distribuido con correlación.
*/
app.get("/api/reservas/:reservaId", async (req, res) => {
const requestId = req.requestId;
const reservaId = req.params.reservaId;
try {
const reservaResp = await http.get(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
const reserva = reservaResp.data;
const pagoResp = await http.get(
`${SERVICES.pagos}/pagos/por-reserva/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
const pago = pagoResp.data?.pago ?? null;
res.json({ request_id: requestId, reserva, pago });
} catch (err) {
const mapped = normalizeUpstreamError(err, requestId);
res.status(mapped.status).json(mapped.body);
}
});
/*
UPDATE distribuido (igual que antes, pero propagando request-id).
*/
app.patch("/api/reservas/:reservaId", async (req, res) => {
const requestId = req.requestId;
const reservaId = req.params.reservaId;
const { cliente, email, reintentar_pago, metodo_pago } = req.body ?? {};
try {
if (typeof cliente === "string" || typeof email === "string") {
await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}/contacto`,
{ cliente, email },
{ headers: buildUpstreamHeaders(requestId) }
);
}
const reservaResp = await http.get(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
let reserva = reservaResp.data;
if (reintentar_pago === true) {
if (typeof metodo_pago !== "string" || metodo_pago.trim().length === 0) {
return res.status(400).json({ error: "metodo_pago requerido", request_id: requestId });
}
if (reserva.estado === "cancelada") {
return res.status(409).json({ error: "reserva cancelada no puede pagarse", request_id: requestId });
}
if (reserva.estado === "pagada") {
return res.status(409).json({ error: "la reserva ya está pagada", request_id: requestId });
}
const pagoResp = await requestWithRetry(
() =>
http.post(
`${SERVICES.pagos}/pagos/autorizar`,
{
reserva_id: reserva.reserva_id,
monto: reserva.monto_total,
metodo: metodo_pago
},
{ headers: buildUpstreamHeaders(requestId) }
),
{ retries: 2, baseDelayMs: 200 }
);
const pago = pagoResp.data;
if (pago.estado === "aceptado") {
const pagadaResp = await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reserva.reserva_id)}/pagar`,
{},
{ headers: buildUpstreamHeaders(requestId) }
);
reserva = pagadaResp.data;
return res.json({
ok: true,
mensaje: "pago aceptado",
request_id: requestId,
reserva,
pago
});
}
return res.status(402).json({
ok: false,
mensaje: "pago rechazado, sigue pendiente",
request_id: requestId,
reserva,
pago
});
}
const pagoResp = await http.get(
`${SERVICES.pagos}/pagos/por-reserva/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
res.json({
ok: true,
mensaje: "reserva actualizada",
request_id: requestId,
reserva,
pago: pagoResp.data?.pago ?? null
});
} catch (err) {
const mapped = normalizeUpstreamError(err, requestId);
res.status(mapped.status).json(mapped.body);
}
});
/*
DELETE distribuido con request-id propagado.
*/
app.delete("/api/reservas/:reservaId", async (req, res) => {
const requestId = req.requestId;
const reservaId = req.params.reservaId;
const hard = String(req.query.hard ?? "false").toLowerCase() === "true";
try {
const reservaResp = await http.get(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
const reserva = reservaResp.data;
const pagoResp = await http.get(
`${SERVICES.pagos}/pagos/por-reserva/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
const pago = pagoResp.data?.pago ?? null;
let reembolso = null;
if (pago && pago.estado === "aceptado") {
const reembolsoResp = await requestWithRetry(
() =>
http.post(
`${SERVICES.pagos}/pagos/${encodeURIComponent(pago.transaccion_id)}/reembolsar`,
{},
{ headers: buildUpstreamHeaders(requestId) }
),
{ retries: 1, baseDelayMs: 150 }
);
reembolso = reembolsoResp.data;
}
const cancelResp = await http.patch(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}/cancelar`,
{ reason: "CANCELADA_DESDE_GATEWAY" },
{ headers: buildUpstreamHeaders(requestId) }
);
let hardDelete = null;
if (hard === true) {
const delResp = await http.delete(
`${SERVICES.reservas}/reservas/${encodeURIComponent(reservaId)}`,
{ headers: buildUpstreamHeaders(requestId) }
);
hardDelete = delResp.data;
}
res.json({
ok: true,
mensaje: hard ? "reserva cancelada y eliminada" : "reserva cancelada",
request_id: requestId,
reserva: cancelResp.data,
pago_ultimo: pago,
reembolso,
hard_delete: hardDelete
});
} catch (err) {
const mapped = normalizeUpstreamError(err, requestId);
res.status(mapped.status).json(mapped.body);
}
});
app.listen(PORT, () => {
console.log(`API Gateway en http://localhost:${PORT}`);
console.log("Health: GET /health");
});
Paso 2: actualizar el archivo de pruebas para ejercitar idempotencia y correlación
Sustituye el archivo:
patron-05-crud-distribuido/crud-distribuido.http
### Health completo (mira x-request-id en respuesta)
GET http://localhost:5100/health
### CREATE distribuido con idempotency key
POST http://localhost:5100/api/reservas
Content-Type: application/json
x-idempotency-key: demo-key-001
{
"habitacion_tipo": "deluxe",
"fecha_entrada": "2025-12-20",
"fecha_salida": "2025-12-23",
"huespedes": 3,
"cliente": "Ana Torres",
"email": "ana@demo.local",
"metodo_pago": "tarjeta"
}
### Reintento del mismo CREATE con la misma key
### Debe devolver exactamente lo mismo, sin crear duplicados
POST http://localhost:5100/api/reservas
Content-Type: application/json
x-idempotency-key: demo-key-001
{
"habitacion_tipo": "deluxe",
"fecha_entrada": "2025-12-20",
"fecha_salida": "2025-12-23",
"huespedes": 3,
"cliente": "Ana Torres",
"email": "ana@demo.local",
"metodo_pago": "tarjeta"
}
### READ distribuido (pega el reserva_id)
GET http://localhost:5100/api/reservas/RES-PEGA_AQUI
### UPDATE contacto
PATCH http://localhost:5100/api/reservas/RES-PEGA_AQUI
Content-Type: application/json
{
"cliente": "Ana Torres Actualizada",
"email": "ana.actualizada@demo.local"
}
### UPDATE reintentar pago
PATCH http://localhost:5100/api/reservas/RES-PEGA_AQUI
Content-Type: application/json
{
"reintentar_pago": true,
"metodo_pago": "paypal"
}
### DELETE distribuido (cancelar y posible reembolso)
DELETE http://localhost:5100/api/reservas/RES-PEGA_AQUI
Qué debes mirar en las respuestas:
- En todas verás
request_idy, en cabecera,x-request-id - Si repites el CREATE con la misma
x-idempotency-key, no cambia nada - Si cambias la key, crearás una reserva nueva, como es lógico
Paso 3: prueba de fallo parcial controlado
Este paso no necesita código, solo intención.
- Haz un CREATE con una
x-idempotency-keynueva - Detén el servicio de pagos (la terminal de pagos)
- Repite el mismo CREATE con la misma key
Qué debería pasar:
- El gateway fallará con 502 (o similar) porque pagos no responde
- Cuando vuelvas a levantar pagos, repites el mismo CREATE con la misma key
- El gateway podrá completar sin duplicar reservas si el primer intento ya había guardado resultado
- Si el fallo fue antes de guardar resultado, la idempotencia no te salva, y eso es parte del aprendizaje: la idempotencia debe pensarse como contrato completo, no como parche
En un sistema real, para cerrar bien ese hueco, el gateway persistiría la intención de operación y se aplicaría un flujo más robusto (por ejemplo, SAGA y estados), pero para este documento ya has visto la esencia.
Ejecución desde VSCode en Windows
No cambia el orden, solo recuerda que ahora el gateway exige x-idempotency-key en el CREATE.
Terminal 1:
cd .\servicio-reservas
node .\src\server.mjs
Terminal 2:
cd ..\servicio-pagos
node .\src\server.mjs
Terminal 3:
cd ..\api-gateway
node .\src\server.mjs
Cierre del ejercicio: observabilidad mínima y checklist de aprendizaje
En este punto ya has construido un CRUD distribuido que enseña lo esencial: composición de lecturas, escritura que atraviesa servicios, estados intermedios, reintentos, idempotencia y un gateway que actúa como frontera. Lo que falta para cerrarlo con un sabor “real” es añadir una observabilidad mínima que permita depurar sin adivinar.
No vamos a introducir herramientas externas. Solo consola bien usada y consistente.
Objetivo del cierre
Queremos que, al ejecutar una operación desde crud-distribuido.http, puedas:
- Ver el mismo
request_idaparecer en el gateway y en ambos servicios - Entender en qué paso estás dentro de una operación compuesta
- Distinguir claramente error de validación, error de dominio y error de red
- Tener un checklist claro para confirmar que el patrón CRUD distribuido se ha entendido
1 Añadir logs estructurados por request_id en servicios
1.1 Servicio de reservas: log de entrada y log de respuesta
Edita este archivo:
patron-05-crud-distribuido/servicio-reservas/src/server.mjs
Añade este middleware justo después de app.use(express.json());
app.use((req, res, next) => {
/*
Observabilidad mínima sin librerías.
Tomamos x-request-id si existe.
Si no existe, registramos "no-request-id" para que se note.
En un sistema real, todos los servicios deberían generar uno si falta,
pero aquí queremos que se vea el efecto de la propagación desde el gateway.
*/
const requestIdRaw = req.headers["x-request-id"];
const requestId =
typeof requestIdRaw === "string" && requestIdRaw.trim().length > 0
? requestIdRaw.trim()
: "no-request-id";
req.requestId = requestId;
const start = Date.now();
console.log(
JSON.stringify({
at: "reservas.in",
request_id: requestId,
method: req.method,
path: req.path
})
);
res.on("finish", () => {
const ms = Date.now() - start;
console.log(
JSON.stringify({
at: "reservas.out",
request_id: requestId,
method: req.method,
path: req.path,
status: res.statusCode,
ms
})
);
});
next();
});
Qué te aporta esto:
- Cada request queda registrada con entrada y salida
- Ves latencia por request
- Tienes trazabilidad por
request_idsin tocar lógica de negocio
1.2 Servicio de pagos: mismo patrón
Edita:
patron-05-crud-distribuido/servicio-pagos/src/server.mjs
Añade este middleware justo después de app.use(express.json());
app.use((req, res, next) => {
const requestIdRaw = req.headers["x-request-id"];
const requestId =
typeof requestIdRaw === "string" && requestIdRaw.trim().length > 0
? requestIdRaw.trim()
: "no-request-id";
req.requestId = requestId;
const start = Date.now();
console.log(
JSON.stringify({
at: "pagos.in",
request_id: requestId,
method: req.method,
path: req.path
})
);
res.on("finish", () => {
const ms = Date.now() - start;
console.log(
JSON.stringify({
at: "pagos.out",
request_id: requestId,
method: req.method,
path: req.path,
status: res.statusCode,
ms
})
);
});
next();
});
2 Añadir logs de pasos en el gateway para ver la orquestación
Edita:
patron-05-crud-distribuido/api-gateway/src/server.mjs
Busca el endpoint POST /api/reservas y añade logs breves en cada paso, usando el requestId.
Dentro del try, antes de crear la reserva:
console.log(
JSON.stringify({
at: "gateway.step",
request_id: requestId,
step: "crear_reserva.start"
})
);
Justo después de const reserva = reservaResp.data;
console.log(
JSON.stringify({
at: "gateway.step",
request_id: requestId,
step: "crear_reserva.ok",
reserva_id: reserva.reserva_id
})
);
Antes de autorizar pago:
console.log(
JSON.stringify({
at: "gateway.step",
request_id: requestId,
step: "autorizar_pago.start",
reserva_id: reserva.reserva_id
})
);
Después de obtener pago:
console.log(
JSON.stringify({
at: "gateway.step",
request_id: requestId,
step: "autorizar_pago.ok",
reserva_id: reserva.reserva_id,
estado_pago: pago.estado,
tx: pago.transaccion_id
})
);
Y si se marca como pagada, justo antes:
console.log(
JSON.stringify({
at: "gateway.step",
request_id: requestId,
step: "marcar_pagada.start",
reserva_id: reserva.reserva_id
})
);
Esto hace que, cuando ejecutes un POST, veas un rastro claro:
- crear_reserva.start
- crear_reserva.ok
- autorizar_pago.start
- autorizar_pago.ok
- marcar_pagada.start
Y además el request_id coincidirá con lo que sale en cada servicio.
3 Prueba final guiada: la demo completa en 6 peticiones
Usa crud-distribuido.http y ejecuta en orden:
GET /healthPOST /api/reservasconx-idempotency-key: demo-key-001- Repite el mismo
POSTcon la misma key GET /api/reservas/:reservaIdPATCH /api/reservas/:reservaIdpara cambiar emailDELETE /api/reservas/:reservaId
Qué deberías comprobar mirando consola:
- El mismo
request_idaparece en gateway, reservas y pagos para una misma operación - En el segundo POST con la misma key, no se repiten pasos internos de creación y pago
- El GET compone dos respuestas (reserva + pago)
- El DELETE dispara cancelación y, si procede, reembolso