Componentes de un sistema distribuido
4. Componentes de un Sistema Distribuido
4.1 Frontend como Cliente
Definición y Rol
En un sistema distribuido, el frontend actúa como un cliente inteligente que consume servicios desde múltiples fuentes. A diferencia de un monolito donde el frontend está acoplado al backend, en sistemas distribuidos el frontend es una aplicación independiente que se comunica con diversos servicios a través de APIs.
Características del Frontend Distribuido
- Independencia tecnológica: El frontend puede usar cualquier stack tecnológico (React, Vue, Angular, Svelte) sin estar atado al backend.
- Consumidor de servicios: No contiene lógica de negocio compleja; consume servicios REST, GraphQL, o WebSockets.
- Gestión de estado distribuido: Maneja estados que pueden provenir de múltiples servicios independientes.
- Resiliencia frente a fallos: Debe manejar elegantemente la indisponibilidad de servicios individuales.
Patrones Comunes para Frontend Distribuido
- API Gateway Client-Side: El frontend se comunica con un gateway que enruta a los servicios adecuados.
- BFF (Backend For Frontend): Un servicio backend específico para cada frontend que agrega y transforma datos.
- Micro-frontends: Dividir el frontend en componentes independientes desplegables por separado.
Mini-App Completa: Frontend Distribuido
Estructura del Proyecto
frontend-distribuido-ejemplo/
├── index.html # Solo estructura HTML
├── styles.css # Estilos CSS
├── app.js # Lógica JavaScript
├── mock-servicio-a.js # Servicio mock 1
├── mock-servicio-b.js # Servicio mock 2
├── mock-servicio-c.js # Servicio mock 3
├── api-gateway.js # Gateway que orquesta
└── start-all.bat # Script para iniciar
1. Servicios Mock (Backends Independientes)
mock-servicio-a.js (Servicio de Productos):
// mock-servicio-a.js
// Servicio A: Productos (Node nativo, ES Modules).
import http from "node:http";
const productos = [
{ id: 1, nombre: "Laptop Gaming", precio: 1299.99, categoria: "tecnologia" },
{ id: 2, nombre: "Smartphone Pro", precio: 899.99, categoria: "tecnologia" },
{ id: 3, nombre: "Auriculares Bluetooth", precio: 149.99, categoria: "audio" }
];
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
const server = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.url === "/health") {
return sendJson(res, 200, {
servicio: "productos",
estado: "activo",
timestamp: new Date().toISOString()
});
}
if (req.url === "/productos") {
return sendJson(res, 200, productos);
}
const match = req.url.match(/^\/productos\/(\d+)$/);
if (match) {
const id = Number(match[1]);
const producto = productos.find((p) => p.id === id);
if (!producto) return sendJson(res, 404, { error: "Producto no encontrado" });
return sendJson(res, 200, producto);
}
return sendJson(res, 404, { error: "Ruta no encontrada" });
});
server.listen(3001, () => {
console.log("Servicio A (Productos) http://localhost:3001");
});
mock-servicio-b.js (Servicio de Reseñas):
// mock-servicio-b.js
// Servicio B: Reseñas (Node nativo, ES Modules).
import http from "node:http";
const resenas = {
1: [
{ id: 1, usuario: "Ana G", rating: 5, comentario: "Excelente producto" },
{ id: 2, usuario: "Carlos L", rating: 4, comentario: "Muy bueno, pero caro" }
],
2: [{ id: 3, usuario: "María R", rating: 3, comentario: "Funciona bien" }],
3: []
};
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
const server = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.url === "/health") {
return sendJson(res, 200, {
servicio: "reseñas",
estado: "activo",
timestamp: new Date().toISOString()
});
}
const match = req.url.match(/^\/productos\/(\d+)\/reseñas$/);
if (match) {
const productoId = Number(match[1]);
const lista = resenas[productoId] || [];
const promedio =
lista.length > 0
? lista.reduce((sum, r) => sum + r.rating, 0) / lista.length
: 0;
return sendJson(res, 200, {
productoId,
reseñas: lista,
total: lista.length,
promedio: Number(promedio.toFixed(1))
});
}
return sendJson(res, 404, { error: "Ruta no encontrada" });
});
server.listen(3002, () => {
console.log("Servicio B (Reseñas) http://localhost:3002");
});
mock-servicio-c.js (Servicio de Inventario):
// mock-servicio-c.js
// Servicio C: Inventario (Node nativo, ES Modules).
import http from "node:http";
const inventario = {
1: { stock: 15, ubicacion: "Almacén A", ultimaActualizacion: "2024-01-10" },
2: { stock: 30, ubicacion: "Almacén B", ultimaActualizacion: "2024-01-09" },
3: { stock: 0, ubicacion: "Almacén C", ultimaActualizacion: "2024-01-11" }
};
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
const server = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.url === "/health") {
return sendJson(res, 200, {
servicio: "inventario",
estado: "activo",
timestamp: new Date().toISOString()
});
}
const match = req.url.match(/^\/productos\/(\d+)\/inventario$/);
if (match) {
const productoId = Number(match[1]);
const info = inventario[productoId];
if (!info) return sendJson(res, 404, { error: "Producto no encontrado en inventario" });
return sendJson(res, 200, {
productoId,
...info,
disponible: info.stock > 0
});
}
return sendJson(res, 404, { error: "Ruta no encontrada" });
});
server.listen(3003, () => {
console.log("Servicio C (Inventario) http://localhost:3003");
});
2. API Gateway (Orquestador)
api-gateway.js:
// api-gateway.js
// API Gateway (Node nativo) que orquesta 3 servicios: productos, reseñas e inventario.
//
// Cambios clave respecto al original:
// - ES Modules (import) en lugar de require.
// - Usa fetch nativo de Node 18+ (sin node-fetch).
// - Reescribe prefijos /api/* para que los servicios reciban rutas correctas.
// - Mantiene cache simple con TTL para el endpoint compuesto.
import http from "node:http";
import httpProxy from "http-proxy";
const proxy = httpProxy.createProxyServer({});
// Servicios internos (puertos de los mocks).
const servicios = {
productos: "http://localhost:3001",
resenas: "http://localhost:3002", // Nota: variable interna sin ñ
inventario: "http://localhost:3003"
};
// Cache en memoria para /productos/:id/completo
const cache = new Map();
// Utilidad: enviar JSON de forma consistente.
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload));
}
// Utilidad: CORS básico.
function setCors(res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
// Node 18+ expone fetch global. Si el entorno no lo tuviera, fallamos explícitamente.
function assertFetchAvailable() {
if (typeof fetch !== "function") {
throw new Error(
"Este gateway requiere Node.js 18+ (fetch nativo). Actualiza Node o añade un polyfill."
);
}
}
const server = http.createServer(async (req, res) => {
setCors(res);
// Preflight
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
console.log(`${req.method} ${req.url}`);
// 1) Health agregado
if (req.url === "/health" && req.method === "GET") {
try {
assertFetchAvailable();
const healthChecks = {};
for (const [nombre, url] of Object.entries(servicios)) {
try {
const r = await fetch(`${url}/health`);
const data = await r.json();
healthChecks[nombre] = { estado: "activo", data };
} catch (err) {
healthChecks[nombre] = { estado: "inactivo", error: err.message };
}
}
const todosActivos = Object.values(healthChecks).every((h) => h.estado === "activo");
return sendJson(res, 200, {
sistema: "frontend-distribuido",
estadoGeneral: todosActivos ? "activo" : "degradado",
timestamp: new Date().toISOString(),
servicios: healthChecks,
arquitectura: "distribuida"
});
} catch (err) {
return sendJson(res, 500, { error: err.message });
}
}
// 2) Endpoint compuesto: /productos/:id/completo
const matchCompleto = req.url.match(/^\/productos\/(\d+)\/completo$/);
if (matchCompleto && req.method === "GET") {
try {
assertFetchAvailable();
const productoId = Number(matchCompleto[1]);
const cacheKey = `producto-${productoId}-completo`;
// TTL 30s
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 30_000) {
return sendJson(res, 200, {
...cached.data,
cached: true,
timestamp: new Date(cached.timestamp).toISOString()
});
}
// Llamadas en paralelo a servicios.
const [productoRes, resenasRes, inventarioRes] = await Promise.allSettled([
fetch(`${servicios.productos}/productos/${productoId}`),
fetch(`${servicios.resenas}/productos/${productoId}/reseñas`),
fetch(`${servicios.inventario}/productos/${productoId}/inventario`)
]);
// Parseo tolerante a fallos.
const producto = productoRes.status === "fulfilled"
? await productoRes.value.json()
: { error: "Servicio de productos no disponible" };
const resenas = resenasRes.status === "fulfilled"
? await resenasRes.value.json()
: { error: "Servicio de reseñas no disponible" };
const inventario = inventarioRes.status === "fulfilled"
? await inventarioRes.value.json()
: { error: "Servicio de inventario no disponible" };
const resultado = {
productoId,
producto: producto.error ? null : producto,
reseñas: resenas.error ? null : resenas,
inventario: inventario.error ? null : inventario,
metadata: {
serviciosUsados: {
productos: !producto.error,
reseñas: !resenas.error,
inventario: !inventario.error
},
timestamp: new Date().toISOString()
}
};
cache.set(cacheKey, { data: resultado, timestamp: Date.now() });
return sendJson(res, 200, resultado);
} catch (err) {
return sendJson(res, 500, {
error: "Error al obtener producto completo",
detalles: err.message
});
}
}
// 3) Proxy /api/*
// Importante: los servicios esperan rutas sin el prefijo /api/...
// Por eso reescribimos req.url antes de reenviar.
if (req.url.startsWith("/api/productos")) {
req.url = req.url.replace(/^\/api\/productos/, "");
return proxy.web(req, res, { target: servicios.productos });
}
if (req.url.startsWith("/api/reseñas")) {
req.url = req.url.replace(/^\/api\/reseñas/, "");
return proxy.web(req, res, { target: servicios.resenas });
}
if (req.url.startsWith("/api/inventario")) {
req.url = req.url.replace(/^\/api\/inventario/, "");
return proxy.web(req, res, { target: servicios.inventario });
}
// 4) 404
return sendJson(res, 404, {
error: "Ruta no encontrada",
rutasDisponibles: [
"/health",
"/productos/:id/completo",
"/api/productos/*",
"/api/reseñas/*",
"/api/inventario/*"
]
});
});
// Errores del proxy (servicio caído, conexión rechazada, etc.)
proxy.on("error", (err, req, res) => {
sendJson(res, 500, {
error: "Error de proxy",
ruta: req.url,
mensaje: err.message
});
});
server.listen(3000, () => {
console.log("API Gateway en http://localhost:3000");
console.log("GET /health");
console.log("GET /productos/:id/completo");
console.log("GET /api/productos/*");
console.log("GET /api/reseñas/*");
console.log("GET /api/inventario/*");
});
3. Frontend Completo (Cliente)
index.html:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frontend Distribuido - Demo Completa</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Frontend como Cliente Distribuido</h1>
<p class="subtitle">Demo completa - Consume múltiples servicios independientes</p>
</header>
<div class="content">
<!-- Panel izquierdo: Control y resultados -->
<div class="card">
<h3>Control del Sistema</h3>
<div class="servicios" id="estado-servicios">
<!-- Estado dinámico de servicios -->
</div>
<div class="control-botones">
<button class="btn" onclick="verificarEstado()" id="btn-estado">
🔍 Verificar Estado del Sistema
</button>
<button class="btn" onclick="obtenerProductoCompleto(1)" id="btn-producto-1">
📦 Obtener Producto 1 Completo
</button>
<button class="btn" onclick="obtenerProductoCompleto(2)" id="btn-producto-2">
📦 Obtener Producto 2 Completo
</button>
<button class="btn btn-peligro" onclick="detenerServicio('resenas')" id="btn-fallo-resenas">
⚠️ Simular Fallo (Reseñas)
</button>
<button class="btn btn-peligro" onclick="detenerServicio('inventario')"
id="btn-fallo-inventario">
⚠️ Simular Fallo (Inventario)
</button>
</div>
<div class="resultado" id="resultado">
<p class="mensaje-inicial">Ejecuta una acción para ver los resultados...</p>
</div>
</div>
<!-- Panel derecho: Información y explicación -->
<div class="card">
<h3>¿Cómo funciona?</h3>
<div class="explicacion">
<h4>📡 Arquitectura Distribuida</h4>
<p>Este frontend consume <strong>3 servicios independientes</strong>:</p>
<ol>
<li><strong>Servicio A (3001):</strong> Información de productos</li>
<li><strong>Servicio B (3002):</strong> Reseñas y ratings</li>
<li><strong>Servicio C (3003):</strong> Inventario y disponibilidad</li>
</ol>
</div>
<div class="explicacion">
<h4>🔄 Flujo de Datos</h4>
<p>Cuando solicitas un "Producto Completo":</p>
<ol>
<li>Frontend llama al API Gateway (3000)</li>
<li>Gateway llama a los 3 servicios en PARALELO</li>
<li>Cada servicio responde independientemente</li>
<li>Gateway combina y cachea los resultados</li>
<li>Frontend muestra datos combinados</li>
</ol>
</div>
<div class="explicacion">
<h4>💡 Características Demostradas</h4>
<ul>
<li><strong>Comunicación por red</strong> (HTTP entre servicios)</li>
<li><strong>Fallos independientes</strong> (puedes simular fallos)</li>
<li><strong>Cache distribuido</strong> (nota "cached: true")</li>
<li><strong>Degradación elegante</strong> (servicios caídos no rompen todo)</li>
</ul>
</div>
<div class="explicacion">
<h4>🔧 Comandos de Consola</h4>
<p>Abre la consola del navegador (F12) y prueba:</p>
<code
style="display: block; background: #f5f5f5; padding: 10px; border-radius: 4px; margin-top: 10px; font-size: 0.9em;">
verificarEstado()<br>
obtenerProductoCompleto(1)<br>
detenerServicio('resenas')
</code>
</div>
</div>
</div>
<footer>
<p>Demo de Sistema Distribuido - Frontend como Cliente Independiente</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
styles.css
/* Reset y configuración base */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
line-height: 1.6;
}
/* Contenedor principal */
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: calc(100vh - 40px);
}
/* Header */
header {
background: linear-gradient(135deg, #4a6ee0 0%, #6a4ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
h1 {
font-size: 2.5em;
margin-bottom: 10px;
font-weight: 700;
}
.subtitle {
opacity: 0.9;
font-size: 1.1em;
font-weight: 300;
}
/* Contenido principal */
.content {
padding: 30px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
flex: 1;
}
/* Tarjetas */
.card {
background: #f8f9fa;
border-radius: 15px;
padding: 25px;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.card:hover {
border-color: #4a6ee0;
box-shadow: 0 10px 30px rgba(74, 110, 224, 0.1);
}
.card h3 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #dee2e6;
font-size: 1.5em;
}
/* Botones */
.control-botones {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
}
.btn {
background: linear-gradient(135deg, #4a6ee0 0%, #6a4ba2 100%);
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
flex: 1;
min-width: 200px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(74, 110, 224, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-peligro {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
}
.btn-peligro:hover {
box-shadow: 0 10px 20px rgba(220, 53, 69, 0.3);
}
/* Resultados */
.resultado {
background: white;
border-radius: 10px;
padding: 20px;
margin-top: 20px;
border: 2px solid #dee2e6;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 0.9em;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.mensaje-inicial {
text-align: center;
color: #6c757d;
padding: 40px;
font-style: italic;
}
/* Estado de servicios */
.servicios {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-top: 10px;
}
.servicio {
background: white;
padding: 15px;
border-radius: 10px;
text-align: center;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.servicio.activo {
border-color: #28a745;
background: rgba(40, 167, 69, 0.05);
}
.servicio.inactivo {
border-color: #dc3545;
background: rgba(220, 53, 69, 0.05);
opacity: 0.7;
}
.servicio h4 {
margin-bottom: 5px;
color: #333;
font-size: 1em;
}
.servicio p {
font-size: 0.85em;
color: #6c757d;
margin-bottom: 8px;
}
.estado {
font-size: 0.8em;
padding: 4px 12px;
border-radius: 20px;
display: inline-block;
font-weight: 600;
}
.estado.activo {
background: #d4edda;
color: #155724;
}
.estado.inactivo {
background: #f8d7da;
color: #721c24;
}
/* Explicaciones */
.explicacion {
background: #e3f2fd;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
border-left: 4px solid #2196f3;
}
.explicacion:last-child {
margin-bottom: 0;
}
.explicacion h4 {
color: #1976d2;
margin-bottom: 10px;
font-size: 1.1em;
}
.explicacion ol,
.explicacion ul {
margin-left: 20px;
margin-top: 10px;
}
.explicacion li {
margin-bottom: 5px;
font-size: 0.95em;
}
.explicacion code {
font-family: 'Monaco', 'Menlo', monospace;
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Estados y mensajes */
.cargando {
text-align: center;
padding: 40px;
}
.cargando-icono {
font-size: 3em;
margin-bottom: 20px;
display: block;
}
.error {
color: #dc3545;
background: rgba(220, 53, 69, 0.1);
padding: 15px;
border-radius: 5px;
margin-top: 10px;
border-left: 4px solid #dc3545;
}
.error h4 {
margin-bottom: 10px;
color: #dc3545;
}
.cache {
color: #6c757d;
font-style: italic;
font-size: 0.9em;
margin-top: 10px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #6c757d;
}
/* Footer */
footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
border-top: 2px solid #e9ecef;
color: #6c757d;
font-size: 0.9em;
}
/* Responsive */
@media (max-width: 1024px) {
.content {
grid-template-columns: 1fr;
gap: 20px;
}
.servicios {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.servicios {
grid-template-columns: 1fr;
}
.control-botones {
flex-direction: column;
}
.btn {
min-width: 100%;
}
h1 {
font-size: 2em;
}
.content {
padding: 20px;
}
}
@media (max-width: 480px) {
body {
padding: 10px;
}
header {
padding: 20px;
}
.card {
padding: 15px;
}
}
app.js
// app.js (frontend)
// Controla la demo desde el navegador consumiendo SIEMPRE el API Gateway.
// No se conecta a los servicios directamente.
//
// Nota: "detenerServicio" solo simula el estado en la UI. No detiene procesos reales.
const API_GATEWAY = "http://localhost:3000";
// Estado local de simulación (UI)
const estadoServicios = {
productos: true,
reseñas: true,
inventario: true
};
document.addEventListener("DOMContentLoaded", () => {
console.log("FRONTEND DISTRIBUIDO - DEMO");
console.log("API Gateway:", API_GATEWAY);
console.log("Funciones disponibles: verificarEstado(), obtenerProductoCompleto(id), detenerServicio(nombre)");
configurarEventListeners();
verificarEstado();
});
function configurarEventListeners() {
// Los botones pueden tener onclick en HTML.
// Aquí solo añadimos un efecto visual simple sin tocar el HTML.
document.querySelectorAll(".btn").forEach((btn) => {
btn.addEventListener("mouseenter", function () {
this.style.transform = "translateY(-2px)";
});
btn.addEventListener("mouseleave", function () {
this.style.transform = "translateY(0)";
});
});
}
async function verificarEstado() {
mostrarCargando("Verificando estado del sistema distribuido...");
try {
const inicio = performance.now();
const response = await fetch(`${API_GATEWAY}/health`);
const fin = performance.now();
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const estado = await response.json();
estado.tiempoRespuesta = `${(fin - inicio).toFixed(0)}ms`;
// Sincroniza el estado local con lo que reporta el gateway.
if (estado.servicios) {
Object.keys(estadoServicios).forEach((servicio) => {
if (estado.servicios[servicio]) {
estadoServicios[servicio] = estado.servicios[servicio].estado === "activo";
}
});
}
mostrarEstadoServicios(estado.servicios);
mostrarResultado(estado, "Estado del Sistema Distribuido");
} catch (error) {
mostrarError(`No se pudo conectar al API Gateway: ${error.message}`);
console.error(error);
}
}
async function obtenerProductoCompleto(productoId) {
if (!productoId || productoId < 1 || productoId > 3) {
mostrarError("ID de producto inválido. Usa 1, 2 o 3.");
return;
}
mostrarCargando(`Obteniendo producto ${productoId} desde servicios distribuidos...`);
try {
const inicio = performance.now();
const response = await fetch(`${API_GATEWAY}/productos/${productoId}/completo`);
const fin = performance.now();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
data.tiempoRespuesta = `${(fin - inicio).toFixed(0)}ms`;
data.diagnostico = {
serviciosActivos: contarServiciosActivos(),
cacheUsado: Boolean(data.cached),
timestamp: new Date().toISOString()
};
mostrarResultado(data, `Producto ${productoId} - Datos Combinados`);
actualizarUIbasadoEnRespuesta(data);
} catch (error) {
mostrarError(`Error obteniendo producto completo: ${error.message}`);
console.error(error);
}
}
// Simulación local: cambia estado UI, no detiene el backend real.
function detenerServicio(nombreServicio) {
if (!Object.prototype.hasOwnProperty.call(estadoServicios, nombreServicio)) {
mostrarError(`Servicio "${nombreServicio}" no encontrado. Usa: productos, reseñas o inventario`);
return;
}
estadoServicios[nombreServicio] = !estadoServicios[nombreServicio];
const nuevoEstado = estadoServicios[nombreServicio] ? "activado" : "desactivado";
const estadoElement = document.querySelector(`.servicio[data-servicio="${nombreServicio}"]`);
if (estadoElement) {
estadoElement.className = `servicio ${estadoServicios[nombreServicio] ? "activo" : "inactivo"}`;
estadoElement.querySelector(".estado").textContent = estadoServicios[nombreServicio] ? "ACTIVO" : "INACTIVO";
estadoElement.querySelector(".estado").className = `estado ${estadoServicios[nombreServicio] ? "activo" : "inactivo"}`;
}
mostrarResultado(
{
simulacion: `Servicio de ${nombreServicio} ${nuevoEstado}`,
nota: estadoServicios[nombreServicio]
? "El servicio ha sido marcado como activo en la UI."
: "Ahora prueba obtener un producto completo. Verás respuesta degradada si el backend falla.",
serviciosActivos: contarServiciosActivos(),
timestamp: new Date().toISOString()
},
`Simulación de ${nuevoEstado.toUpperCase()}`
);
const boton = document.getElementById(`btn-fallo-${nombreServicio}`);
if (boton) {
boton.textContent = estadoServicios[nombreServicio]
? `Simular fallo (${nombreServicio})`
: `Reactivar (${nombreServicio})`;
}
}
function mostrarEstadoServicios(serviciosInfo) {
const contenedor = document.getElementById("estado-servicios");
if (!contenedor) return;
let html = "";
html += crearTarjetaServicio("productos", "Productos", "3001", serviciosInfo);
html += crearTarjetaServicio("reseñas", "Reseñas", "3002", serviciosInfo);
html += crearTarjetaServicio("inventario", "Inventario", "3003", serviciosInfo);
contenedor.innerHTML = html;
}
function crearTarjetaServicio(id, nombre, puerto, serviciosInfo) {
const activo =
estadoServicios[id] &&
(!serviciosInfo || !serviciosInfo[id] || serviciosInfo[id].estado === "activo");
return `
<div class="servicio ${activo ? "activo" : "inactivo"}" data-servicio="${id}">
<h4>${nombre}</h4>
<p>Puerto: ${puerto}</p>
<span class="estado ${activo ? "activo" : "inactivo"}">
${activo ? "ACTIVO" : "INACTIVO"}
</span>
</div>
`;
}
function mostrarResultado(datos, titulo = "Resultado") {
const contenedor = document.getElementById("resultado");
if (!contenedor) return;
let jsonStr;
try {
jsonStr = JSON.stringify(datos, null, 2);
} catch (error) {
jsonStr = `Error formateando datos: ${error.message}`;
}
let html = `<h4 style="margin-bottom: 15px;">${titulo}</h4>`;
html += `<pre style="background: #f8f9fa; padding: 15px; border-radius: 8px; overflow-x: auto;">${escapeHtml(jsonStr)}</pre>`;
if (datos.cached) html += `<p class="cache">Datos servidos desde cache</p>`;
if (datos.tiempoRespuesta) html += `<p class="cache">Tiempo de respuesta: ${datos.tiempoRespuesta}</p>`;
if (datos.metadata?.serviciosUsados) {
const total = Object.keys(datos.metadata.serviciosUsados).length;
const ok = Object.values(datos.metadata.serviciosUsados).filter(Boolean).length;
html += `<p class="cache">Servicios usados: ${ok}/${total}</p>`;
}
if (datos.diagnostico) {
html += `<p class="cache">Diagnóstico: ${datos.diagnostico.serviciosActivos}/3 servicios activos</p>`;
}
contenedor.innerHTML = html;
contenedor.scrollTop = 0;
}
function mostrarCargando(mensaje) {
const contenedor = document.getElementById("resultado");
if (!contenedor) return;
contenedor.innerHTML = `
<div class="cargando">
<p style="font-weight: 600; margin-bottom: 10px;">${mensaje}</p>
<p style="color: #6c757d; font-size: 0.9em;">
Consultando servicios distribuidos en paralelo...
</p>
</div>
`;
}
function mostrarError(mensaje) {
const contenedor = document.getElementById("resultado");
if (!contenedor) return;
contenedor.innerHTML = `
<div class="error">
<h4>Error</h4>
<p>${mensaje}</p>
<p style="margin-top: 15px; font-size: 0.9em;">
En un sistema distribuido, un fallo puede degradar una parte sin tumbar el resto.
</p>
</div>
`;
}
function contarServiciosActivos() {
return Object.values(estadoServicios).filter(Boolean).length;
}
function actualizarUIbasadoEnRespuesta(respuesta) {
if (!respuesta?.metadata?.serviciosUsados) return;
const s = respuesta.metadata.serviciosUsados;
if (!s.productos) console.warn("Servicio de productos no disponible en la última respuesta");
if (!s["reseñas"]) console.warn("Servicio de reseñas no disponible en la última respuesta");
if (!s.inventario) console.warn("Servicio de inventario no disponible en la última respuesta");
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
window.verificarEstado = verificarEstado;
window.obtenerProductoCompleto = obtenerProductoCompleto;
window.detenerServicio = detenerServicio;
window.estadoServicios = estadoServicios;
4. Script para Iniciar Todo
start-all.sh (para Linux/Mac) o start-all.bat (para Windows):
start-all.bat (Windows):
@echo off
setlocal enabledelayedexpansion
rem ==========================================================
rem start-all.bat
rem Arranca el sistema distribuido (ES Modules + Node nativo).
rem Requisitos:
rem - Node.js 18+ (fetch nativo).
rem - npm disponible.
rem - Ejecutar este .bat desde cualquier sitio (se autoubica).
rem ==========================================================
cls
echo ===========================================
echo INICIANDO SISTEMA DISTRIBUIDO COMPLETO
echo ===========================================
rem 0) Ir a la carpeta del .bat (evita "MODULE_NOT_FOUND" por rutas)
cd /d "%~dp0"
rem 1) Comprobar Node
where node >nul 2>nul
if errorlevel 1 (
echo ERROR: Node.js no esta instalado o no esta en PATH.
pause
exit /b 1
)
rem 2) Comprobar version de Node (mayor >= 18)
for /f "delims=." %%a in ('node -p "process.versions.node"') do set NODE_MAJOR=%%a
if not defined NODE_MAJOR (
echo ERROR: No se pudo leer la version de Node.
pause
exit /b 1
)
if %NODE_MAJOR% LSS 18 (
echo ERROR: Node.js %NODE_MAJOR% detectado. Se requiere Node.js 18 o superior.
echo Motivo: el API Gateway usa fetch nativo.
pause
exit /b 1
)
rem 3) Comprobar npm
where npm >nul 2>nul
if errorlevel 1 (
echo ERROR: npm no esta disponible en PATH.
pause
exit /b 1
)
rem 4) Instalar dependencias si faltan (solo http-proxy)
rem Si tu proyecto tiene mas deps, npm install las cubre igual.
if not exist "node_modules\http-proxy\package.json" (
echo.
echo Instalando dependencias (npm install)...
call npm install
if errorlevel 1 (
echo ERROR: npm install fallo.
pause
exit /b 1
)
)
rem 5) Comprobar puertos ocupados (informativo)
echo.
echo Comprobando puertos 3000-3003...
for %%P in (3000 3001 3002 3003) do (
netstat -ano | findstr /r /c:":%%P .*LISTENING" >nul
if not errorlevel 1 (
echo ADVERTENCIA: El puerto %%P ya esta en uso. Puede fallar el servicio correspondiente.
)
)
echo.
echo 1. Iniciando Servicio A (Productos - puerto 3001)...
start "svc-productos-3001" cmd /k "cd /d ""%~dp0"" && node mock-servicio-a.js"
timeout /t 2 /nobreak >nul
echo.
echo 2. Iniciando Servicio B (Resenas - puerto 3002)...
start "svc-resenas-3002" cmd /k "cd /d ""%~dp0"" && node mock-servicio-b.js"
timeout /t 2 /nobreak >nul
echo.
echo 3. Iniciando Servicio C (Inventario - puerto 3003)...
start "svc-inventario-3003" cmd /k "cd /d ""%~dp0"" && node mock-servicio-c.js"
timeout /t 2 /nobreak >nul
echo.
echo 4. Iniciando API Gateway (puerto 3000)...
start "api-gateway-3000" cmd /k "cd /d ""%~dp0"" && node api-gateway.js"
timeout /t 3 /nobreak >nul
echo.
echo ===========================================
echo SISTEMA ARRANCADO
echo ===========================================
echo.
echo Servicios:
echo - Productos: http://localhost:3001/health
echo - Resenas: http://localhost:3002/health
echo - Inventario: http://localhost:3003/health
echo - Gateway: http://localhost:3000/health
echo.
echo Pruebas por Gateway:
echo - http://localhost:3000/health
echo - http://localhost:3000/productos/1/completo
echo - http://localhost:3000/api/productos/productos
echo - http://localhost:3000/api/rese^ñas/productos/1/rese^ñas
echo - http://localhost:3000/api/inventario/productos/1/inventario
echo.
echo Frontend: abre index.html en el navegador.
echo.
pause
package.json (para instalar dependencias):
{
"name": "frontend-distribuido-ejemplo",
"version": "1.0.0",
"description": "Ejemplo completo de frontend en sistema distribuido (Node nativo + ES Modules)",
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"start:a": "node mock-servicio-a.js",
"start:b": "node mock-servicio-b.js",
"start:c": "node mock-servicio-c.js",
"start:gateway": "node api-gateway.js",
"start:all:cmd": "start \"svc-a\" node mock-servicio-a.js && start \"svc-b\" node mock-servicio-b.js && start \"svc-c\" node mock-servicio-c.js && start \"gateway\" node api-gateway.js"
},
"dependencies": {
"http-proxy": "^1.18.1"
}
}
Instrucciones de Ejecución Paso a Paso
Paso 1: Crear la estructura
mkdir frontend-distribuido-ejemplo
cd frontend-distribuido-ejemplo
Paso 2: Crear todos los archivos anteriores
Paso 3: Instalar dependencias
npm install http-proxy node-fetch
Paso 4: Iniciar el sistema
# En 4 terminales diferentes:
# Terminal 1:
node mock-servicio-a.js
# Terminal 2:
node mock-servicio-b.js
# Terminal 3:
node mock-servicio-c.js
# Terminal 4:
node api-gateway.js
Crea tests-rest-client.http (en la raíz del proyecto) y pega esto.
@base = http://localhost:3000
@json = application/json
### 1) Gateway: raíz informativa
GET {{base}}/
Accept: {{json}}
### 2) Gateway: health agregado de servicios
GET {{base}}/health
Accept: {{json}}
### 3) Gateway: producto completo (orquestación de 3 servicios)
GET {{base}}/productos/1/completo
Accept: {{json}}
### 4) Gateway: producto completo (otro id)
GET {{base}}/productos/2/completo
Accept: {{json}}
### 5) Gateway: producto completo (id sin datos -> 404 o respuesta degradada según implementación)
GET {{base}}/productos/999/completo
Accept: {{json}}
### 6) Proxy: listar productos (Servicio A vía gateway)
GET {{base}}/api/productos/productos
Accept: {{json}}
### 7) Proxy: detalle producto (Servicio A vía gateway)
GET {{base}}/api/productos/productos/1
Accept: {{json}}
### 8) Proxy: reseñas de un producto (Servicio B vía gateway)
# Nota: la ruta del servicio usa "reseñas" en el path.
GET {{base}}/api/reseñas/productos/1/reseñas
Accept: {{json}}
### 9) Proxy: inventario de un producto (Servicio C vía gateway)
GET {{base}}/api/inventario/productos/1/inventario
Accept: {{json}}
### 10) Repetición para comprobar caché del endpoint compuesto (si está activado)
GET {{base}}/productos/1/completo
Accept: {{json}}
Si tu terminal o Windows te da problemas con la ñ en la URL de REST Client, usa encoding en esa línea:
GET {{base}}/api/rese%C3%B1as/productos/1/rese%C3%B1as
Accept: {{json}}
Paso 5: Abrir el frontend
Abrir index.html en el navegador.
¿Qué Demuestra Esta Mini-App Completa?
- Frontend real que consume múltiples servicios.
- Servicios independientes con responsabilidades únicas.
- API Gateway que orquesta llamadas.
- Comunicación HTTP real entre componentes.
- Manejo de errores cuando servicios fallan.
- Cache distribuido implementado.
- Toda la arquitectura ejecutable con un comando.
Pruebas que Puedes Hacer:
- Ver estado del sistema: Click en "Verificar Estado"
- Obtener datos combinados: Click en "Obtener Producto 1 Completo"
- Simular fallo: Click en "Simular Fallo (Reseñas)"
- Ver degradación elegante: Obtener producto después del fallo
- Ver cache en acción: Solicitar el mismo producto dos veces
Este ejemplo es 100% ejecutable, autocontenido y pedagógicamente completo.
4.2 Backend como Proveedor de Servicios
Evolución del Backend
En arquitecturas distribuidas, el backend deja de ser un monolito para convertirse en un ecosistema de servicios especializados. Cada servicio es una aplicación independiente con responsabilidades específicas.
Características de los Servicios Backend
- Autonomía: Cada servicio puede desplegarse, escalar y fallar independientemente.
- APIs bien definidas: Comunicación mediante contratos claros (OpenAPI, gRPC protobufs).
- Base de datos propia: Cada servicio gestiona su propio almacenamiento de datos.
- Observabilidad independiente: Cada servicio tiene sus métricas, logs y trazas.
Tipos de Servicios Backend
- Servicios de Dominio: Contienen lógica de negocio específica (pagos, usuarios, inventario).
- Servicios de Infraestructura: Proporcionan capacidades transversales (autenticación, logging, caché).
- Servicios de Proceso: Manejan flujos de trabajo y orquestación.
- Servicios de Integración: Conectan con sistemas externos o legacy.
Mini app Backend como Proveedor de Servicios
Vas a montar una mini arquitectura distribuida con tres procesos Node.js independientes:
- Un servicio de dominio de productos con su propia base de datos SQLite
- Un servicio de dominio de reseñas con su propia base de datos SQLite
- Un servicio de proceso tipo API Gateway que orquesta llamadas a los otros dos y expone un contrato unificado para el frontend
- El frontend se queda “tonto”: solo consume el Gateway. Eso ejemplifica el backend como proveedor de servicios en un ecosistema.
Estructura de carpetas
Crea esta estructura tal cual:
.
└── mini-backend-servicios/
├── gateway/
│ ├── package.json
│ └── src/
│ ├── server.mjs
│ └── http-client.http
├── servicios/
│ ├── productos/
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── db.mjs
│ │ │ └── server.mjs
│ │ ├── data/
│ │ │ └── productos.db
│ │ └── http-client.http
│ └── resenas/
│ ├── package.json
│ ├── src/
│ │ ├── db.mjs
│ │ └── server.mjs
│ ├── data/
│ │ └── resenas.db
│ └── http-client.http
└── frontend/
├── index.html
├── styles.css
└── app.js
Nota: los .db se crearán solos al arrancar cada servicio, aunque las carpetas data deben existir.
Preparación en VSCode
- Abre la carpeta mini-backend-servicios en VSCode.
- Crea las carpetas y archivos exactamente como en la estructura.
- Ahora instala dependencias por cada proceso (sin depender de scripts).
- En PowerShell, desde cada carpeta, ejecuta:
Gateway
cd .\gateway
npm init -y
npm pkg set type=module
npm i express http-proxy-middleware
Servicio Productos
cd ..\servicios\productos
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio Reseñas
cd ..\resenas
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Vuelve a la raíz cuando termines:
cd ..\..\..
Servicio Productos
Archivo gateway de base de datos: servicios/productos/src/db.mjs
// servicios/productos/src/db.mjs
// Este módulo encapsula TODO lo relacionado con SQLite para el servicio de productos.
//
// Idea clave de arquitectura:
// - Cada servicio es dueño de su base de datos.
// - Nadie más debe tocar este fichero .db.
// - El resto del servicio (rutas/controladores) pide operaciones a este módulo,
// en lugar de hacer SQL por todas partes.
//
// Para un ejemplo didáctico usamos better-sqlite3 (sincrónico) para reducir complejidad.
// En producción podrías usar un driver async, pool, migraciones, etc.
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
// Resolvemos rutas de forma robusta, independientemente del directorio actual.
const dataDir = path.resolve("data");
const dbPath = path.join(dataDir, "productos.db");
// Aseguramos que exista la carpeta data.
// Si no existe, la creamos para poder guardar el archivo .db.
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Abrimos (o creamos si no existe) la base de datos.
export const db = new Database(dbPath);
// Buen hábito: activar foreign keys en SQLite.
db.pragma("foreign_keys = ON");
// Creamos tablas si no existen.
// Esto actúa como “mini migración” para el laboratorio.
db.exec(`
CREATE TABLE IF NOT EXISTS productos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
precio REAL NOT NULL CHECK(precio >= 0),
creado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Semilla mínima: insertamos algunos productos si la tabla está vacía.
// En un entorno real esto sería una migración/seed aparte.
const row = db.prepare(`SELECT COUNT(*) AS total FROM productos`).get();
if (row.total === 0) {
const insert = db.prepare(`INSERT INTO productos (nombre, precio) VALUES (?, ?)`);
const seed = [
["Teclado mecánico", 79.99],
["Ratón inalámbrico", 29.5],
["Monitor 27 pulgadas", 199.0]
];
const tx = db.transaction(() => {
for (const [nombre, precio] of seed) insert.run(nombre, precio);
});
tx();
}
// API del módulo: funciones pequeñas, claras, testeables.
export function listarProductos() {
return db.prepare(`SELECT id, nombre, precio, creado_en FROM productos ORDER BY id`).all();
}
export function obtenerProductoPorId(id) {
return db
.prepare(`SELECT id, nombre, precio, creado_en FROM productos WHERE id = ?`)
.get(id);
}
export function crearProducto({ nombre, precio }) {
const result = db
.prepare(`INSERT INTO productos (nombre, precio) VALUES (?, ?)`)
.run(nombre, precio);
return obtenerProductoPorId(result.lastInsertRowid);
}
Este archivo es la capa de persistencia del servicio de productos dentro del sistema distribuido. Su función es centralizar y aislar todo el acceso a la base de datos SQLite que pertenece exclusivamente a este servicio.
En un sistema distribuido donde el backend actúa como proveedor, este módulo garantiza que el servicio de productos sea dueño absoluto de sus datos: abre y configura la base de datos, define el esquema, inicializa datos de ejemplo y expone una API interna clara para listar, consultar y crear productos. El resto del backend no ejecuta SQL directamente, sino que delega en este archivo, manteniendo una separación estricta entre lógica de negocio y almacenamiento.
API HTTP: servicios/productos/src/server.mjs
// servicios/productos/src/server.mjs
// Servicio de dominio: Productos
//
// Responsabilidad:
// - Exponer endpoints CRUD básicos (aquí solo GET/POST para mantenerlo simple).
// - Hablar con SU base de datos (productos.db) mediante el módulo db.mjs.
// - No sabe nada del gateway, ni del frontend.
// - Puede desplegarse, escalarse y versionarse por separado.
import express from "express";
import { listarProductos, obtenerProductoPorId, crearProducto } from "./db.mjs";
const app = express();
const PORT = 3001;
// Middleware para parsear JSON.
// Sin esto, req.body sería undefined en peticiones con application/json.
app.use(express.json());
// Endpoint de salud: útil para observabilidad y para el gateway.
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "productos", timestamp: new Date().toISOString() });
});
// GET /productos
// Devuelve lista completa.
app.get("/productos", (req, res) => {
const productos = listarProductos();
res.json(productos);
});
// GET /productos/:id
// Devuelve un producto o 404.
app.get("/productos/:id", (req, res) => {
// Convertimos a número de forma defensiva.
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id debe ser un entero positivo" });
}
const producto = obtenerProductoPorId(id);
if (!producto) {
return res.status(404).json({ error: "producto no encontrado" });
}
res.json(producto);
});
// POST /productos
// Crea un producto nuevo.
// Body JSON: { "nombre": "X", "precio": 12.34 }
app.post("/productos", (req, res) => {
const { nombre, precio } = req.body;
// Validaciones mínimas para el laboratorio.
if (typeof nombre !== "string" || nombre.trim().length < 2) {
return res.status(400).json({ error: "nombre debe ser un string con al menos 2 caracteres" });
}
const precioNum = Number(precio);
if (!Number.isFinite(precioNum) || precioNum < 0) {
return res.status(400).json({ error: "precio debe ser un número >= 0" });
}
const nuevo = crearProducto({ nombre: nombre.trim(), precio: precioNum });
// 201 Created, típico para POST de creación.
res.status(201).json(nuevo);
});
app.listen(PORT, () => {
console.log(`Servicio PRODUCTOS escuchando en http://localhost:${PORT}`);
});
Este archivo es el punto de entrada HTTP del servicio de productos dentro del sistema distribuido. Su función es exponer una API propia para gestionar productos y actuar como proveedor de datos para otros componentes del sistema.
En un backend distribuido, este archivo define los endpoints del dominio de productos, valida las entradas y delega el acceso a datos en su capa de persistencia (db.mjs). No conoce ni depende del frontend ni del API Gateway: se limita a ofrecer un contrato HTTP estable que puede ser consumido por otros servicios o por el gateway, manteniendo al servicio de productos como una unidad autónoma, desplegable y versionable de forma independiente.
Archivo REST Client: servicios/productos/http-client.http
### Health productos
GET http://localhost:3001/health
### Listar productos
GET http://localhost:3001/productos
### Obtener producto por id
GET http://localhost:3001/productos/1
### Crear producto
POST http://localhost:3001/productos
Content-Type: application/json
{
"nombre": "Webcam Full HD",
"precio": 49.99
}
Este bloque define pruebas directas contra el servicio de productos, sin pasar por el API Gateway. Su objetivo es validar que el microservicio funciona de forma autónoma como proveedor de backend.
El endpoint /health comprueba que el proceso está activo y listo para recibir peticiones.
GET /productos verifica la lectura de datos desde la base de datos propia del servicio.
GET /productos/1 valida el acceso a un recurso concreto y el manejo de errores si no existe.
POST /productos prueba la creación de un nuevo recurso, incluyendo validación básica y persistencia.
Estas pruebas confirman que el servicio cumple su contrato HTTP de forma independiente, condición necesaria antes de integrarlo en un sistema distribuido mediante un API Gateway.
Servicio Reseñas
Base de datos: servicios/resenas/src/db.mjs
// servicios/resenas/src/db.mjs
// Servicio de dominio: Reseñas
//
// Puntos didácticos:
// - Tiene SU base de datos independiente.
// - No hace foreign key a productos porque sería acoplar bases de datos.
// En microservicios reales, las relaciones entre dominios suelen resolverse
// por referencia (guardar product_id) y validación a través de APIs, no por FK cross-db.
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
const dataDir = path.resolve("data");
const dbPath = path.join(dataDir, "resenas.db");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
export const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS resenas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
autor TEXT NOT NULL,
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
comentario TEXT,
creado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_resenas_product_id ON resenas(product_id);
`);
const row = db.prepare(`SELECT COUNT(*) AS total FROM resenas`).get();
if (row.total === 0) {
const insert = db.prepare(`
INSERT INTO resenas (product_id, autor, rating, comentario)
VALUES (?, ?, ?, ?)
`);
const seed = [
[1, "Ana", 5, "Muy buen teclado, suena genial."],
[1, "Luis", 4, "Cómodo, pero algo pesado."],
[2, "María", 4, "Buen ratón para oficina."],
[3, "Carlos", 3, "Imagen correcta, brillo mejorable."]
];
const tx = db.transaction(() => {
for (const item of seed) insert.run(...item);
});
tx();
}
export function listarResenasPorProducto(productId) {
return db
.prepare(`
SELECT id, product_id, autor, rating, comentario, creado_en
FROM resenas
WHERE product_id = ?
ORDER BY id
`)
.all(productId);
}
export function crearResena({ product_id, autor, rating, comentario }) {
const result = db
.prepare(`
INSERT INTO resenas (product_id, autor, rating, comentario)
VALUES (?, ?, ?, ?)
`)
.run(product_id, autor, rating, comentario ?? null);
return db
.prepare(`
SELECT id, product_id, autor, rating, comentario, creado_en
FROM resenas
WHERE id = ?
`)
.get(result.lastInsertRowid);
}
Este archivo es la capa de persistencia del servicio de reseñas dentro del sistema distribuido. Su función es gestionar de forma autónoma la base de datos de reseñas, incluyendo la creación del esquema, la carga de datos iniciales y el acceso a la información.
En un backend distribuido, este módulo deja claro que las reseñas no dependen directamente del servicio de productos a nivel de base de datos. Las relaciones se mantienen por referencia (product_id), no mediante claves foráneas entre servicios. De este modo, el servicio de reseñas controla sus propios datos y expone operaciones claras para listar y crear reseñas, manteniendo el desacoplamiento entre dominios y reforzando la independencia de cada servicio proveedor.
API HTTP: servicios/resenas/src/server.mjs
// servicios/resenas/src/server.mjs
// Servicio de dominio: Reseñas
import express from "express";
import { listarResenasPorProducto, crearResena } from "./db.mjs";
const app = express();
const PORT = 3002;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "resenas", timestamp: new Date().toISOString() });
});
// GET /resenas?product_id=1
// Devuelve reseñas de un producto.
app.get("/resenas", (req, res) => {
const productId = Number(req.query.product_id);
if (!Number.isInteger(productId) || productId <= 0) {
return res.status(400).json({ error: "product_id es obligatorio y debe ser entero positivo" });
}
const resenas = listarResenasPorProducto(productId);
res.json(resenas);
});
// POST /resenas
// Crea reseña: { product_id, autor, rating, comentario }
app.post("/resenas", (req, res) => {
const { product_id, autor, rating, comentario } = req.body;
const productId = Number(product_id);
const ratingNum = Number(rating);
if (!Number.isInteger(productId) || productId <= 0) {
return res.status(400).json({ error: "product_id debe ser entero positivo" });
}
if (typeof autor !== "string" || autor.trim().length < 2) {
return res.status(400).json({ error: "autor debe ser un string con al menos 2 caracteres" });
}
if (!Number.isInteger(ratingNum) || ratingNum < 1 || ratingNum > 5) {
return res.status(400).json({ error: "rating debe ser entero entre 1 y 5" });
}
if (comentario !== undefined && comentario !== null && typeof comentario !== "string") {
return res.status(400).json({ error: "comentario debe ser string si se envía" });
}
const nueva = crearResena({
product_id: productId,
autor: autor.trim(),
rating: ratingNum,
comentario: comentario?.trim()
});
res.status(201).json(nueva);
});
app.listen(PORT, () => {
console.log(`Servicio RESEÑAS escuchando en http://localhost:${PORT}`);
});
Este archivo es el punto de entrada HTTP del servicio de reseñas dentro del sistema distribuido. Su función es exponer una API propia para consultar y crear reseñas asociadas a productos.
En un backend distribuido, este servidor define el contrato HTTP del dominio de reseñas, valida las entradas recibidas y delega toda la persistencia en su capa de datos (db.mjs). No consulta al servicio de productos ni conoce su implementación interna: trabaja únicamente con identificadores (product_id). Así, el servicio de reseñas actúa como proveedor independiente, desplegable y escalable por separado, que puede integrarse posteriormente a través de un API Gateway u otros consumidores.
Archivo REST Client: servicios/resenas/http-client.http
### Health reseñas
GET http://localhost:3002/health
### Listar reseñas por producto
GET http://localhost:3002/resenas?product_id=1
### Crear reseña
POST http://localhost:3002/resenas
Content-Type: application/json
{
"product_id": 2,
"autor": "Sofía",
"rating": 5,
"comentario": "Perfecto para uso diario."
}
API Gateway
Este proceso representa un servicio de proceso u orquestación: ofrece un contrato pensado para el cliente y resuelve internamente la coordinación.
Código: gateway/src/server.mjs
// gateway/src/server.mjs
// API Gateway (servicio de proceso / orquestación)
//
// Funciones en este laboratorio:
// - Exponer una API pública y estable para el frontend.
// - Llamar a servicios internos (productos y reseñas).
// - Componer respuestas agregadas.
// - Aplicar CORS para permitir que el frontend (otro origen) consuma la API.
// - Servir el frontend estático para que todo quede fácil de probar.
//
// Nota pedagógica:
// En un entorno real, un gateway suele manejar autenticación, rate limiting,
// caching, versionado, tracing, etc. Aquí lo mantenemos simple y claro.
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const app = express();
const PORT = 3000;
// URLs internas de servicios.
// En un despliegue real serían DNS internos, service discovery, etc.
const SERVICIO_PRODUCTOS = "http://localhost:3001";
const SERVICIO_RESENAS = "http://localhost:3002";
// Middleware JSON por si en el futuro agregas POST en el gateway.
app.use(express.json());
// CORS simple para el laboratorio.
// Si sirves el frontend desde Live Server (otro puerto), esto es imprescindible.
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.sendStatus(200);
next();
});
// Health del ecosistema.
// Importante: el gateway no solo dice “yo estoy bien”, sino “mis dependencias responden”.
app.get("/health", async (req, res) => {
const resultado = {
ok: true,
gateway: true,
productos: false,
resenas: false,
timestamp: new Date().toISOString()
};
// Pequeña función helper para consultar salud de un servicio.
async function ping(url) {
const r = await fetch(url);
return r.ok;
}
try {
resultado.productos = await ping(`${SERVICIO_PRODUCTOS}/health`);
} catch {
resultado.productos = false;
resultado.ok = false;
}
try {
resultado.resenas = await ping(`${SERVICIO_RESENAS}/health`);
} catch {
resultado.resenas = false;
resultado.ok = false;
}
res.status(resultado.ok ? 200 : 503).json(resultado);
});
// Endpoint público: catálogo para el frontend.
// GET /api/catalogo
// Respuesta: productos con un resumen de rating calculado desde reseñas.
//
// Punto clave:
// - El frontend no hace 2-3 llamadas ni entiende dominios internos.
// - El gateway traduce esa complejidad en una sola respuesta pensada para UI.
app.get("/api/catalogo", async (req, res) => {
try {
// 1) Pedimos productos
const productosResp = await fetch(`${SERVICIO_PRODUCTOS}/productos`);
if (!productosResp.ok) {
return res.status(502).json({ error: "fallo consultando servicio productos" });
}
const productos = await productosResp.json();
// 2) Por cada producto pedimos reseñas y calculamos media
// Para el laboratorio lo hacemos en paralelo con Promise.all.
// En un caso real, podrías cachear, paginar, o usar una consulta agregada,
// o incluso un servicio de lectura optimizado (CQRS).
const productosEnriquecidos = await Promise.all(
productos.map(async (p) => {
const r = await fetch(`${SERVICIO_RESENAS}/resenas?product_id=${p.id}`);
if (!r.ok) {
// Si falla reseñas, degradamos: devolvemos producto sin resumen.
return { ...p, rating_medio: null, total_resenas: 0 };
}
const resenas = await r.json();
const total = resenas.length;
const suma = resenas.reduce((acc, item) => acc + Number(item.rating), 0);
const media = total > 0 ? Math.round((suma / total) * 10) / 10 : null;
return { ...p, rating_medio: media, total_resenas: total };
})
);
res.json(productosEnriquecidos);
} catch (err) {
res.status(500).json({ error: "error inesperado en gateway", detalle: String(err) });
}
});
// Endpoint público: detalle de producto agregado.
// GET /api/productos/:id
// Respuesta: { producto, resenas }
app.get("/api/productos/:id", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
try {
const productoResp = await fetch(`${SERVICIO_PRODUCTOS}/productos/${id}`);
if (productoResp.status === 404) return res.status(404).json({ error: "producto no encontrado" });
if (!productoResp.ok) return res.status(502).json({ error: "fallo consultando productos" });
const producto = await productoResp.json();
const resenasResp = await fetch(`${SERVICIO_RESENAS}/resenas?product_id=${id}`);
const resenas = resenasResp.ok ? await resenasResp.json() : [];
res.json({ producto, resenas });
} catch (err) {
res.status(500).json({ error: "error inesperado en gateway", detalle: String(err) });
}
});
// Endpoint público: crear reseña “desde el punto de vista del cliente”.
// POST /api/productos/:id/resenas
// Body: { autor, rating, comentario }
// El gateway traduce esto a la API interna de reseñas.
app.post("/api/productos/:id/resenas", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
const { autor, rating, comentario } = req.body;
try {
// Validación de existencia del producto consultando al servicio de productos.
// Observa el patrón: no hay FK, hay verificación vía API.
const productoResp = await fetch(`${SERVICIO_PRODUCTOS}/productos/${id}`);
if (productoResp.status === 404) return res.status(404).json({ error: "producto no encontrado" });
if (!productoResp.ok) return res.status(502).json({ error: "fallo consultando productos" });
// Reenviamos al servicio de reseñas.
const crearResp = await fetch(`${SERVICIO_RESENAS}/resenas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ product_id: id, autor, rating, comentario })
});
const payload = await crearResp.json();
// Propagamos códigos: si reseñas devuelve 400, el cliente lo verá igual.
res.status(crearResp.status).json(payload);
} catch (err) {
res.status(500).json({ error: "error inesperado en gateway", detalle: String(err) });
}
});
// Proxy opcional para demostrar “pasarela” hacia servicios internos.
// Esto permite exponer /internal/productos/* que redirige a 3001.
// En producción, esto se haría con más controles de seguridad.
app.use(
"/internal/productos",
createProxyMiddleware({
target: SERVICIO_PRODUCTOS,
changeOrigin: true,
pathRewrite: { "^/internal/productos": "" }
})
);
app.use(
"/internal/resenas",
createProxyMiddleware({
target: SERVICIO_RESENAS,
changeOrigin: true,
pathRewrite: { "^/internal/resenas": "" }
})
);
app.listen(PORT, () => {
console.log(`API GATEWAY escuchando en http://localhost:${PORT}`);
});
Este archivo es el orquestador HTTP del sistema: el punto de entrada que consume el frontend y que convierte varios servicios internos (productos y reseñas) en una API pública única.
En un sistema distribuido donde el backend es el proveedor, aquí se definen endpoints pensados para la UI (/api/catalogo, /api/productos/:id) que componen datos de varios dominios, aplican CORS y gestionan degradación si una dependencia falla. Además, actúa como traductor entre “lo que el cliente necesita” y “cómo están diseñados los servicios internos”, evitando que el frontend conozca puertos, rutas o contratos internos.
Archivo REST Client: gateway/src/http-client.http
### Health ecosistema
GET http://localhost:3000/health
### Catálogo agregado para UI
GET http://localhost:3000/api/catalogo
### Detalle agregado
GET http://localhost:3000/api/productos/1
### Crear reseña vía gateway
POST http://localhost:3000/api/productos/1/resenas
Content-Type: application/json
{
"autor": "Óscar",
"rating": 5,
"comentario": "El gateway simplifica mucho el consumo desde frontend."
}
### Proxy interno a productos
GET http://localhost:3000/internal/productos/productos
### Proxy interno a reseñas
GET http://localhost:3000/internal/resenas/resenas?product_id=1
Frontend estático
La UI solo conoce el Gateway. No sabe que existen productos o reseñas como servicios separados.
HTML: frontend/index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Catálogo con Backend como Proveedor de Servicios</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link rel="stylesheet" href="./styles.css" />
</head>
<body class="bg-light">
<main class="container py-4">
<h1 class="mb-3">Catálogo</h1>
<p class="text-muted">
Esta página consume un único backend público (API Gateway). El gateway orquesta servicios internos.
</p>
<section class="card p-3 mb-4">
<div class="d-flex gap-2 align-items-end flex-wrap">
<div class="flex-grow-1">
<label class="form-label" for="gatewayUrl">URL del Gateway</label>
<input id="gatewayUrl" class="form-control" type="text" value="http://localhost:3000" />
</div>
<button id="btnCargar" class="btn btn-primary">Cargar catálogo</button>
</div>
<div class="mt-3">
<div id="estado" class="small text-muted"></div>
</div>
</section>
<section class="row g-3" id="grid"></section>
<section class="mt-4">
<h2 class="h4">Detalle</h2>
<div class="card p-3" id="detalle">
<div class="text-muted">Selecciona un producto para ver detalle y reseñas.</div>
</div>
</section>
<section class="mt-4">
<h2 class="h4">Crear reseña</h2>
<div class="card p-3">
<form id="formResena" class="row g-2">
<div class="col-md-2">
<label class="form-label" for="productId">Product ID</label>
<input id="productId" class="form-control" type="number" min="1" required />
</div>
<div class="col-md-3">
<label class="form-label" for="autor">Autor</label>
<input id="autor" class="form-control" type="text" minlength="2" required />
</div>
<div class="col-md-2">
<label class="form-label" for="rating">Rating</label>
<input id="rating" class="form-control" type="number" min="1" max="5" required />
</div>
<div class="col-12">
<label class="form-label" for="comentario">Comentario</label>
<textarea id="comentario" class="form-control" rows="2"></textarea>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-success" type="submit">Enviar reseña</button>
<button class="btn btn-outline-secondary" id="btnRefrescarDetalle" type="button">
Refrescar detalle
</button>
</div>
<div class="col-12">
<div id="estadoResena" class="small text-muted"></div>
</div>
</form>
</div>
</section>
</main>
<script src="./app.js" type="module"></script>
</body>
</html>
CSS: frontend/styles.css
/* frontend/styles.css */
/* CSS mínimo: Bootstrap ya resuelve la mayor parte del layout. */
#grid .card {
height: 100%;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
JavaScript: frontend/app.js
// frontend/app.js
// Frontend “tonto”:
// - Solo conoce el Gateway.
// - No conoce microservicios internos.
// - No hace composición: la composición la hace el backend (gateway).
//
// Este patrón es el objetivo didáctico del ejercicio.
const $ = (sel) => document.querySelector(sel);
const gatewayUrlInput = $("#gatewayUrl");
const btnCargar = $("#btnCargar");
const estado = $("#estado");
const grid = $("#grid");
const detalle = $("#detalle");
const formResena = $("#formResena");
const estadoResena = $("#estadoResena");
const btnRefrescarDetalle = $("#btnRefrescarDetalle");
let ultimoProductIdSeleccionado = null;
function gatewayBase() {
return gatewayUrlInput.value.trim().replace(/\/$/, "");
}
function setEstado(texto) {
estado.textContent = texto;
}
function setEstadoResena(texto) {
estadoResena.textContent = texto;
}
function renderCatalogo(items) {
grid.innerHTML = "";
for (const p of items) {
const col = document.createElement("div");
col.className = "col-12 col-md-6 col-lg-4";
const ratingTxt =
p.rating_medio === null
? "Sin rating"
: `Rating medio: ${p.rating_medio} (${p.total_resenas} reseñas)`;
col.innerHTML = `
<div class="card p-3">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">${escapeHtml(p.nombre)}</div>
<div class="text-muted small">ID: ${p.id}</div>
</div>
<div class="fw-semibold">${Number(p.precio).toFixed(2)} €</div>
</div>
<div class="mt-2 small text-muted">${ratingTxt}</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-primary btn-sm" data-action="ver" data-id="${p.id}">
Ver detalle
</button>
<button class="btn btn-outline-secondary btn-sm" data-action="rellenar" data-id="${p.id}">
Rellenar en formulario
</button>
</div>
</div>
`;
grid.appendChild(col);
}
// Delegación de eventos: un solo listener para todos los botones.
grid.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const action = btn.dataset.action;
const id = Number(btn.dataset.id);
if (action === "ver") {
await cargarDetalle(id);
}
if (action === "rellenar") {
$("#productId").value = String(id);
$("#productId").focus();
}
}, { once: true });
}
async function cargarCatalogo() {
setEstado("Cargando catálogo desde el gateway...");
detalle.innerHTML = `<div class="text-muted">Selecciona un producto para ver detalle y reseñas.</div>`;
try {
const url = `${gatewayBase()}/api/catalogo`;
const resp = await fetch(url);
if (!resp.ok) {
setEstado(`Error HTTP ${resp.status} al cargar catálogo`);
return;
}
const items = await resp.json();
renderCatalogo(items);
setEstado(`Catálogo cargado. Productos: ${items.length}`);
} catch (err) {
setEstado(`Error de red: ${String(err)}`);
}
}
async function cargarDetalle(productId) {
ultimoProductIdSeleccionado = productId;
detalle.innerHTML = `<div class="text-muted">Cargando detalle...</div>`;
try {
const url = `${gatewayBase()}/api/productos/${productId}`;
const resp = await fetch(url);
if (resp.status === 404) {
detalle.innerHTML = `<div class="text-danger">Producto no encontrado.</div>`;
return;
}
if (!resp.ok) {
detalle.innerHTML = `<div class="text-danger">Error HTTP ${resp.status} cargando detalle.</div>`;
return;
}
const data = await resp.json();
const producto = data.producto;
const resenas = data.resenas;
detalle.innerHTML = `
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<div>
<div class="fw-semibold">${escapeHtml(producto.nombre)}</div>
<div class="text-muted small">ID: ${producto.id}</div>
</div>
<div class="fw-semibold">${Number(producto.precio).toFixed(2)} €</div>
</div>
<div class="mt-3">
<div class="fw-semibold mb-2">Reseñas</div>
${renderResenas(resenas)}
</div>
`;
} catch (err) {
detalle.innerHTML = `<div class="text-danger">Error de red: ${escapeHtml(String(err))}</div>`;
}
}
function renderResenas(resenas) {
if (!resenas || resenas.length === 0) {
return `<div class="text-muted small">No hay reseñas todavía.</div>`;
}
const items = resenas
.map((r) => {
const comentario = r.comentario ? escapeHtml(r.comentario) : "<span class='text-muted'>Sin comentario</span>";
return `
<div class="border rounded p-2 mb-2 bg-white">
<div class="d-flex justify-content-between align-items-start">
<div class="fw-semibold">${escapeHtml(r.autor)}</div>
<div class="small text-muted">Rating: ${r.rating}</div>
</div>
<div class="small mt-1">${comentario}</div>
</div>
`;
})
.join("");
return items;
}
function escapeHtml(str) {
return String(str)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
btnCargar.addEventListener("click", cargarCatalogo);
formResena.addEventListener("submit", async (e) => {
e.preventDefault();
setEstadoResena("Enviando reseña...");
const productId = Number($("#productId").value);
const autor = $("#autor").value.trim();
const rating = Number($("#rating").value);
const comentario = $("#comentario").value.trim();
try {
const url = `${gatewayBase()}/api/productos/${productId}/resenas`;
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
autor,
rating,
comentario: comentario.length ? comentario : null
})
});
const payload = await resp.json();
if (!resp.ok) {
setEstadoResena(`Error HTTP ${resp.status}: ${payload.error ?? "error"}`);
return;
}
setEstadoResena("Reseña creada correctamente.");
// Si estamos viendo ese producto, refrescamos el detalle.
if (ultimoProductIdSeleccionado === productId) {
await cargarDetalle(productId);
}
} catch (err) {
setEstadoResena(`Error de red: ${String(err)}`);
}
});
btnRefrescarDetalle.addEventListener("click", async () => {
const productId = Number($("#productId").value);
if (Number.isInteger(productId) && productId > 0) {
await cargarDetalle(productId);
}
});
Este archivo es el cliente del sistema distribuido: el JavaScript del navegador que consume la API pública del gateway y construye la interfaz con los datos ya compuestos por el backend.
Su papel es deliberadamente “tonto”: solo sabe llamar a /api/catalogo, /api/productos/:id y /api/productos/:id/resenas en el gateway, renderizar la respuesta y enviar formularios. No conoce microservicios, puertos internos ni lógica de agregación. Eso fuerza el diseño correcto: la complejidad de integración vive en el backend proveedor (gateway), y el frontend se limita a presentar datos y capturar acciones del usuario.
Cómo ejecutarlo todo desde VSCode
Arrancar los tres procesos Node.js sin scripts
Abre tres terminales en VSCode.
Terminal 1: servicio productos
cd .\servicios\productos
node .\src\server.mjs
Terminal 2: servicio reseñas
cd ..\resenas
node .\src\server.mjs
Terminal 3: gateway
cd ..\..\..\gateway
node .\src\server.mjs
Comprueba salud del ecosistema con REST Client:
- Abre gateway/src/http-client.http
- Ejecuta la petición GET /health
- Si todo va bien, verás productos:true y resenas:true.
Ejecutar el frontend
- Opción recomendada en VSCode: Live Server
- Abre frontend/index.html
- Clic derecho
- Open with Live Server
- Te abrirá un puerto tipo http://127.0.0.1:5500
- Pulsa Cargar catálogo y debería funcionar gracias a CORS en el gateway.
Qué demuestra este laboratorio
Autonomía
Puedes apagar solo reseñas (CTRL+C en su terminal) y el catálogo seguirá devolviendo productos, degradando el rating a null o 0. El fallo es parcial y aislado.
APIs bien definidas
Cada servicio tiene sus endpoints simples. El gateway expone endpoints de cara al cliente que no tienen por qué coincidir con los internos.
Base de datos propia
Productos y reseñas tienen SQLite separado. No hay JOIN entre bases. La relación se hace por referencia product_id y coordinación por API.
Servicio de proceso
El gateway compone respuestas, valida existencia de producto antes de crear reseñas y simplifica el consumo del frontend.
4.3 Base de Datos como Sistema Independiente
Desacoplamiento de Almacenamiento
En sistemas distribuidos, cada servicio gestiona sus propios datos. Esto contrasta con el monolito donde todas las tablas comparten una base de datos única.
Patrones de Gestión de Datos Distribuidos
- Database-per-Service: Cada servicio tiene su propia base de datos, posiblemente con tecnología diferente.
- Políglota Persistence: Usar el sistema de almacenamiento más adecuado para cada caso de uso.
- Event Sourcing: Almacenar eventos en lugar de estado actual, permitiendo reconstrucción.
- CQRS (Command Query Responsibility Segregation): Separar modelos para escritura y lectura.
Consideraciones para Bases de Datos Distribuidas
- Consistencia: Trade-off entre consistencia fuerte y disponibilidad (teorema CAP).
- Transacciones distribuidas: Patrones como SAGA para mantener consistencia eventual.
- Replicación y sharding: Estrategias para escalar horizontalmente.
- Migración de datos: Técnicas para mover datos entre servicios sin downtime.
4.4 Separación de Responsabilidades
Principio de Responsabilidad Única Aplicado
La separación de responsabilidades en sistemas distribuidos va más allá del código: cada servicio debe tener un propósito único y bien definido.
Criterios para Separar Responsabilidades
- Límites del dominio: Basados en subdominios del negocio (DDD - Domain Driven Design).
- Frecuencia de cambio: Servicios que cambian por diferentes razones o en diferentes momentos.
- Requisitos de escalado: Separar componentes con diferentes necesidades de escalabilidad.
- Requisitos de disponibilidad: Aislar componentes críticos de los menos críticos.
Beneficios de la Separación Clara
- Desarrollo independiente: Equipos pueden trabajar en paralelo sin conflictos.
- Despliegue independiente: Actualizaciones sin afectar todo el sistema.
- Escalado independiente: Escalar solo los servicios que lo necesitan.
- Resiliencia: Fallos contenidos dentro de cada servicio.
- Mejor mantenibilidad: Códbase más pequeñas y enfocadas.
Mini app Separación de Responsabilidades
Vas a crear una mini app distinta a la anterior para ver el Principio de Responsabilidad Única aplicado en una arquitectura distribuida. La idea es que cada servicio tenga un propósito único y, por tanto, cambie por motivos diferentes, escale distinto y tenga distinta criticidad.
La temática será un “checkout” mínimo de una tienda:
- Servicio Catálogo: publica productos disponibles para vender
- Servicio Precios: calcula el precio final aplicando reglas y cupones
- Servicio Inventario: valida y reserva stock de forma consistente
- Servicio Pedidos: registra el pedido y orquesta el flujo de compra
- Servicio Notificaciones: envía una confirmación (no crítico, si falla el pedido sigue)
Qué demuestra esta app
- Límites del dominio
- Catálogo, precios, inventario, pedidos y notificaciones son subdominios claros
- Frecuencia de cambio
- Precios cambia a menudo (promos, cupones) sin tocar inventario ni pedidos
- Requisitos de escalado
- Inventario suele ser el punto caliente y puede escalarse sin escalar notificaciones
- Requisitos de disponibilidad
- Pedidos e inventario son críticos; notificaciones es “best-effort”
Estructura de carpetas
Crea esta estructura:
.
└── mini-srp-checkout/
├── servicios/
│ ├── catalogo/
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── db.mjs
│ │ │ └── server.mjs
│ │ ├── data/
│ │ │ └── catalogo.db
│ │ └── http-client.http
│ ├── precios/
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── server.mjs
│ │ └── http-client.http
│ ├── inventario/
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── db.mjs
│ │ │ └── server.mjs
│ │ ├── data/
│ │ │ └── inventario.db
│ │ └── http-client.http
│ ├── pedidos/
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── db.mjs
│ │ │ └── server.mjs
│ │ ├── data/
│ │ │ └── pedidos.db
│ │ └── http-client.http
│ └── notificaciones/
│ ├── package.json
│ ├── src/
│ │ └── server.mjs
│ └── http-client.http
└── frontend/
├── index.html
├── styles.css
└── app.js
Nota importante
- Las carpetas data deben existir
- Los .db se crearán automáticamente al arrancar cada servicio
Requisitos en VSCode
- Node.js 18 o superior
- Extensión REST Client (para ejecutar .http)
- Extensión Live Server (para servir el frontend estático)
Instalación de dependencias sin depender de scripts
Abre la carpeta mini-srp-checkout en VSCode.
En PowerShell, entra en cada servicio y ejecuta los comandos.
Servicio catálogo
cd .\servicios\catalogo
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio precios
cd ..\precios
npm init -y
npm pkg set type=module
npm i express
Servicio inventario
cd ..\inventario
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio pedidos
cd ..\pedidos
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio notificaciones
cd ..\notificaciones
npm init -y
npm pkg set type=module
npm i express
Vuelve a la raíz:
cd ..\..\..
Servicio Catálogo
Responsabilidad única
- Publicar productos y sus datos básicos
- No calcula precios finales
- No sabe nada de stock real (eso es inventario)
- No registra pedidos
servicios/catalogo/src/db.mjs
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
// Base de datos propia del servicio Catálogo.
// Nadie más debe escribir aquí.
const dataDir = path.resolve("data");
const dbPath = path.join(dataDir, "catalogo.db");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
export const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
// Tabla simple de productos.
// Observa que el catálogo NO gestiona stock ni reglas de precio final.
db.exec(`
CREATE TABLE IF NOT EXISTS productos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
nombre TEXT NOT NULL,
precio_base REAL NOT NULL CHECK(precio_base >= 0),
activo INTEGER NOT NULL DEFAULT 1,
creado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const total = db.prepare(`SELECT COUNT(*) AS n FROM productos`).get().n;
if (total === 0) {
const insert = db.prepare(
`INSERT INTO productos (sku, nombre, precio_base, activo) VALUES (?, ?, ?, ?)`
);
const seed = [
["SKU-CHA-001", "Silla ergonómica", 149.0, 1],
["SKU-MES-002", "Mesa escritorio", 199.0, 1],
["SKU-LAM-003", "Lámpara LED", 29.9, 1]
];
const tx = db.transaction(() => {
for (const row of seed) insert.run(...row);
});
tx();
}
export function listarProductosActivos() {
return db
.prepare(
`SELECT id, sku, nombre, precio_base FROM productos WHERE activo = 1 ORDER BY id`
)
.all();
}
export function obtenerProductoPorId(id) {
return db
.prepare(
`SELECT id, sku, nombre, precio_base, activo FROM productos WHERE id = ?`
)
.get(id);
}
Este archivo es la capa de persistencia del servicio de catálogo dentro del sistema distribuido. Su función es gestionar la base de datos propia del catálogo y exponer operaciones de lectura sobre los productos que están disponibles para ser mostrados o consumidos por otros servicios.
En un backend proveedor, este módulo define qué es un producto desde el punto de vista del catálogo (SKU, nombre, precio base y estado), inicializa el esquema y los datos mínimos, y ofrece funciones claras para listar productos activos o consultar un producto concreto. No conoce stock, descuentos ni reglas de negocio avanzadas: se limita a ser la fuente de verdad del catálogo, manteniendo el dominio acotado y desacoplado del resto del sistema.
servicios/catalogo/src/server.mjs
import express from "express";
import { listarProductosActivos, obtenerProductoPorId } from "./db.mjs";
const app = express();
const PORT = 3101;
app.use(express.json());
// Health check del servicio.
// Útil para ver disponibilidad individual.
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "catalogo", timestamp: new Date().toISOString() });
});
// GET /productos
// Lista para UI.
app.get("/productos", (req, res) => {
res.json(listarProductosActivos());
});
// GET /productos/:id
app.get("/productos/:id", (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
const p = obtenerProductoPorId(id);
if (!p || p.activo !== 1) {
return res.status(404).json({ error: "producto no encontrado" });
}
res.json({ id: p.id, sku: p.sku, nombre: p.nombre, precio_base: p.precio_base });
});
app.listen(PORT, () => {
console.log(`Catálogo en http://localhost:${PORT}`);
});
Este archivo es el servidor HTTP del servicio de catálogo dentro del sistema distribuido. Su función es exponer una API simple y estable para consultar los productos que forman parte del catálogo.
En un backend proveedor, este servicio define el contrato público del dominio catálogo: ofrece endpoints de lectura (/productos, /productos/:id), valida entradas básicas y delega toda la persistencia en su capa de datos (db.mjs). No gestiona stock, precios finales ni lógica de otros dominios. Actúa como un servicio autónomo, desplegable de forma independiente, que proporciona información base de productos para que otros servicios o el API Gateway la consuman y la compongan según sus necesidades.
servicios/catalogo/http-client.http
### Health catálogo
GET http://localhost:3101/health
### Listar productos
GET http://localhost:3101/productos
### Detalle producto
GET http://localhost:3101/productos/1
Servicio Precios
Responsabilidad única
- Calcular precio final a partir de precio_base, cantidad y cupón
- Cambia con frecuencia: nuevas promos y reglas
- No toca bases de datos en este ejemplo para enfatizar “cambia rápido”
servicios/precios/src/server.mjs
import express from "express";
const app = express();
const PORT = 3102;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "precios", timestamp: new Date().toISOString() });
});
// POST /calcular
// Body esperado:
// {
// "precio_base": 199.0,
// "cantidad": 2,
// "cupon": "SAVE10"
// }
//
// Este servicio no valida si el producto existe ni reserva stock.
// Su propósito es SOLO calcular precio.
app.post("/calcular", (req, res) => {
const precioBase = Number(req.body.precio_base);
const cantidad = Number(req.body.cantidad);
const cupon = typeof req.body.cupon === "string" ? req.body.cupon.trim().toUpperCase() : "";
if (!Number.isFinite(precioBase) || precioBase < 0) {
return res.status(400).json({ error: "precio_base debe ser número >= 0" });
}
if (!Number.isInteger(cantidad) || cantidad <= 0) {
return res.status(400).json({ error: "cantidad debe ser entero > 0" });
}
// Subtotal puro.
const subtotal = precioBase * cantidad;
// Reglas de descuento.
// Estas reglas son el típico punto de cambio frecuente:
// - campañas
// - cupones
// - descuentos por volumen
// - descuentos por categoría
let descuento = 0;
// Descuento por volumen simple.
if (cantidad >= 3) {
descuento += subtotal * 0.05;
}
// Cupón fijo.
if (cupon === "SAVE10") {
descuento += subtotal * 0.10;
}
// Cupón de envío gratis simulado como descuento fijo.
if (cupon === "SHIPFREE") {
descuento += 5;
}
// Redondeo típico de moneda a 2 decimales.
const total = Math.round((subtotal - descuento) * 100) / 100;
res.json({
subtotal: Math.round(subtotal * 100) / 100,
descuento: Math.round(descuento * 100) / 100,
total
});
});
app.listen(PORT, () => {
console.log(`Precios en http://localhost:${PORT}`);
});
Este archivo es el servicio de cálculo de precios dentro del sistema distribuido. Su función es aplicar reglas de negocio relacionadas con precios, descuentos y cupones, y devolver un total calculado a partir de los datos recibidos.
En un backend proveedor, este servicio está intencionalmente aislado: no conoce productos, no valida stock ni persiste datos. Solo recibe valores (precio_base, cantidad, cupon) y devuelve un resultado determinista. Esto permite que las reglas de precios evolucionen, se versionen o se sustituyan sin afectar al catálogo, al stock o al gateway, manteniendo el sistema desacoplado y flexible.
servicios/precios/http-client.http
### Health precios
GET http://localhost:3102/health
### Calcular precio final
POST http://localhost:3102/calcular
Content-Type: application/json
{
"precio_base": 199.0,
"cantidad": 2,
"cupon": "SAVE10"
}
Servicio Inventario
Responsabilidad única
- Gestionar stock y reservas
- Debe ser consistente
- Aquí sí usamos SQLite y transacción para reservar stock
servicios/inventario/src/db.mjs
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
const dataDir = path.resolve("data");
const dbPath = path.join(dataDir, "inventario.db");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
export const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
// Tabla de stock por product_id.
// Observa que este servicio no guarda nombre, sku, etc.
// Solo lo que le compete: cantidades.
db.exec(`
CREATE TABLE IF NOT EXISTS stock (
product_id INTEGER PRIMARY KEY,
unidades INTEGER NOT NULL CHECK(unidades >= 0),
actualizado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS reservas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
cantidad INTEGER NOT NULL CHECK(cantidad > 0),
estado TEXT NOT NULL CHECK(estado IN ('activa','liberada','consumida')),
creado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_reservas_product_id ON reservas(product_id);
`);
const total = db.prepare(`SELECT COUNT(*) AS n FROM stock`).get().n;
if (total === 0) {
const insert = db.prepare(`INSERT INTO stock (product_id, unidades) VALUES (?, ?)`);
const seed = [
[1, 5],
[2, 2],
[3, 10]
];
const tx = db.transaction(() => {
for (const row of seed) insert.run(...row);
});
tx();
}
export function verStock(productId) {
return db.prepare(`SELECT product_id, unidades FROM stock WHERE product_id = ?`).get(productId);
}
// Reservar stock debe ser atómico.
// Si no hay unidades suficientes, se rechaza sin modificar nada.
export function reservarStock(productId, cantidad) {
const tx = db.transaction(() => {
const s = verStock(productId);
if (!s) {
return { ok: false, reason: "PRODUCTO_SIN_STOCK_CONFIGURADO" };
}
if (s.unidades < cantidad) {
return { ok: false, reason: "STOCK_INSUFICIENTE", disponibles: s.unidades };
}
// Decrementamos unidades.
db.prepare(
`UPDATE stock SET unidades = unidades - ?, actualizado_en = datetime('now') WHERE product_id = ?`
).run(cantidad, productId);
// Creamos la reserva.
const result = db.prepare(
`INSERT INTO reservas (product_id, cantidad, estado) VALUES (?, ?, 'activa')`
).run(productId, cantidad);
return { ok: true, reserva_id: result.lastInsertRowid };
});
return tx();
}
// Consumir reserva cuando el pedido se confirma.
export function consumirReserva(reservaId) {
const result = db.prepare(
`UPDATE reservas SET estado = 'consumida' WHERE id = ? AND estado = 'activa'`
).run(reservaId);
return result.changes === 1;
}
// Liberar reserva si el pedido falla después de reservar.
export function liberarReserva(reservaId) {
const tx = db.transaction(() => {
const r = db.prepare(
`SELECT id, product_id, cantidad, estado FROM reservas WHERE id = ?`
).get(reservaId);
if (!r || r.estado !== "activa") return false;
db.prepare(
`UPDATE reservas SET estado = 'liberada' WHERE id = ?`
).run(reservaId);
db.prepare(
`UPDATE stock SET unidades = unidades + ?, actualizado_en = datetime('now') WHERE product_id = ?`
).run(r.cantidad, r.product_id);
return true;
});
return tx();
}
Este archivo es la capa de persistencia del servicio de inventario dentro del sistema distribuido. Su responsabilidad es gestionar exclusivamente el stock y las reservas de productos, manteniendo su propia base de datos y reglas de consistencia.
En un backend proveedor, este módulo define cómo se almacenan las unidades disponibles y cómo se realizan operaciones críticas como reservar, consumir o liberar stock de forma atómica mediante transacciones. No conoce nombres, precios ni lógica comercial: se limita al dominio inventario, garantizando integridad local y permitiendo que otros servicios coordinen procesos más amplios (como pedidos) sin acoplarse a su implementación interna.
servicios/inventario/src/server.mjs
import express from "express";
import { verStock, reservarStock, consumirReserva, liberarReserva } from "./db.mjs";
const app = express();
const PORT = 3103;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "inventario", timestamp: new Date().toISOString() });
});
// GET /stock/:productId
app.get("/stock/:productId", (req, res) => {
const productId = Number(req.params.productId);
if (!Number.isInteger(productId) || productId <= 0) {
return res.status(400).json({ error: "productId debe ser entero positivo" });
}
const s = verStock(productId);
if (!s) return res.status(404).json({ error: "stock no configurado para productId" });
res.json(s);
});
// POST /reservas
// Body: { product_id, cantidad }
app.post("/reservas", (req, res) => {
const productId = Number(req.body.product_id);
const cantidad = Number(req.body.cantidad);
if (!Number.isInteger(productId) || productId <= 0) {
return res.status(400).json({ error: "product_id debe ser entero positivo" });
}
if (!Number.isInteger(cantidad) || cantidad <= 0) {
return res.status(400).json({ error: "cantidad debe ser entero > 0" });
}
const result = reservarStock(productId, cantidad);
if (!result.ok) {
return res.status(409).json(result);
}
res.status(201).json(result);
});
// POST /reservas/:id/consumir
app.post("/reservas/:id/consumir", (req, res) => {
const reservaId = Number(req.params.id);
if (!Number.isInteger(reservaId) || reservaId <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
const ok = consumirReserva(reservaId);
if (!ok) return res.status(409).json({ error: "reserva no activa o inexistente" });
res.json({ ok: true });
});
// POST /reservas/:id/liberar
app.post("/reservas/:id/liberar", (req, res) => {
const reservaId = Number(req.params.id);
if (!Number.isInteger(reservaId) || reservaId <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
const ok = liberarReserva(reservaId);
if (!ok) return res.status(409).json({ error: "reserva no activa o inexistente" });
res.json({ ok: true });
});
app.listen(PORT, () => {
console.log(`Inventario en http://localhost:${PORT}`);
});
Este archivo es el servidor HTTP del servicio de inventario dentro del sistema distribuido. Su función es exponer una API centrada en stock y reservas para que otros componentes del backend puedan coordinar procesos como pedidos sin acceder a la base de datos directamente.
En un backend proveedor, este servicio publica endpoints para consultar stock (/stock/:productId) y gestionar el ciclo de vida de una reserva (/reservas, /reservas/:id/consumir, /reservas/:id/liberar). Valida entradas básicas y delega la consistencia y atomicidad en su capa de datos (db.mjs). Así, el dominio inventario permanece autónomo, con reglas claras y desplegable de forma independiente.
servicios/inventario/http-client.http
### Health inventario
GET http://localhost:3103/health
### Ver stock producto 2
GET http://localhost:3103/stock/2
### Reservar 1 unidad del producto 2
POST http://localhost:3103/reservas
Content-Type: application/json
{
"product_id": 2,
"cantidad": 1
}
Servicio Notificaciones
Responsabilidad única
- Simular “envío” de confirmación
- No debe bloquear el checkout si falla
- Es un candidato típico para menor criticidad
servicios/notificaciones/src/server.mjs
import express from "express";
const app = express();
const PORT = 3105;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "notificaciones", timestamp: new Date().toISOString() });
});
// POST /enviar
// Body: { pedido_id, destino, mensaje }
//
// Para demostrar disponibilidad diferente, permitimos simular fallo.
// Si el body incluye "simular_fallo": true, devolvemos 503.
app.post("/enviar", (req, res) => {
const { pedido_id, destino, mensaje, simular_fallo } = req.body;
if (simular_fallo === true) {
return res.status(503).json({ error: "servicio de notificaciones temporalmente no disponible" });
}
if (!pedido_id || typeof destino !== "string" || typeof mensaje !== "string") {
return res.status(400).json({ error: "pedido_id, destino y mensaje son obligatorios" });
}
// Simulación: en vez de enviar email/SMS, lo dejamos en consola.
console.log("NOTIFICACION ENVIADA", { pedido_id, destino, mensaje });
res.json({ ok: true });
});
app.listen(PORT, () => {
console.log(`Notificaciones en http://localhost:${PORT}`);
});
Este archivo es el servicio de notificaciones dentro del sistema distribuido. Su responsabilidad es actuar como proveedor externo de comunicaciones, encapsulando el envío de mensajes asociados a eventos del sistema, como la confirmación de un pedido.
En un backend proveedor, este servicio está diseñado para ser best-effort y desacoplado: no guarda estado, no participa en transacciones y puede fallar sin bloquear el flujo principal. El endpoint /enviar simula tanto el envío correcto como la indisponibilidad temporal, permitiendo demostrar cómo otros servicios o el gateway gestionan fallos parciales y degradación controlada sin comprometer la consistencia del sistema.
servicios/notificaciones/http-client.http
### Health notificaciones
GET http://localhost:3105/health
### Enviar notificación OK
POST http://localhost:3105/enviar
Content-Type: application/json
{
"pedido_id": 123,
"destino": "cliente@demo.local",
"mensaje": "Tu pedido fue confirmado"
}
### Enviar notificación con fallo simulado
POST http://localhost:3105/enviar
Content-Type: application/json
{
"pedido_id": 123,
"destino": "cliente@demo.local",
"mensaje": "Tu pedido fue confirmado",
"simular_fallo": true
}
Servicio Pedidos
Responsabilidad única
- Orquestar el checkout y registrar el pedido
- Este es el “servicio de proceso” del flujo
- Coordina con catálogo, precios, inventario y notificaciones
- Persistencia propia del pedido en SQLite
servicios/pedidos/src/db.mjs
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
const dataDir = path.resolve("data");
const dbPath = path.join(dataDir, "pedidos.db");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
export const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
// Pedidos guarda lo necesario para su dominio.
// No guarda reglas de precios ni stock.
// Registra el resultado del proceso para auditoría.
db.exec(`
CREATE TABLE IF NOT EXISTS pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
cantidad INTEGER NOT NULL,
subtotal REAL NOT NULL,
descuento REAL NOT NULL,
total REAL NOT NULL,
reserva_id INTEGER,
estado TEXT NOT NULL CHECK(estado IN ('confirmado','fallido')),
fallo_motivo TEXT,
creado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export function crearPedidoConfirmado({ product_id, cantidad, subtotal, descuento, total, reserva_id }) {
const result = db.prepare(`
INSERT INTO pedidos (product_id, cantidad, subtotal, descuento, total, reserva_id, estado)
VALUES (?, ?, ?, ?, ?, ?, 'confirmado')
`).run(product_id, cantidad, subtotal, descuento, total, reserva_id);
return obtenerPedidoPorId(result.lastInsertRowid);
}
export function crearPedidoFallido({ product_id, cantidad, fallo_motivo }) {
const result = db.prepare(`
INSERT INTO pedidos (product_id, cantidad, subtotal, descuento, total, estado, fallo_motivo)
VALUES (?, ?, 0, 0, 0, 'fallido', ?)
`).run(product_id, cantidad, fallo_motivo);
return obtenerPedidoPorId(result.lastInsertRowid);
}
export function obtenerPedidoPorId(id) {
return db.prepare(`SELECT * FROM pedidos WHERE id = ?`).get(id);
}
export function listarPedidos() {
return db.prepare(`SELECT * FROM pedidos ORDER BY id DESC`).all();
}
Este archivo es la capa de persistencia del servicio de pedidos dentro del sistema distribuido. Su función es registrar el resultado final del proceso de pedido, tanto en casos exitosos como fallidos, manteniendo una traza auditable de lo ocurrido.
En un backend proveedor, este módulo actúa como fuente de verdad histórica del dominio pedidos: guarda qué se intentó comprar, en qué cantidad, con qué importe calculado, qué reserva de inventario se usó y si el proceso terminó confirmado o fallido. No calcula precios ni gestiona stock; solo persiste el resultado de la orquestación realizada por otros servicios, manteniendo el dominio acotado y desacoplado.
servicios/pedidos/src/server.mjs
import express from "express";
import { crearPedidoConfirmado, crearPedidoFallido, obtenerPedidoPorId, listarPedidos } from "./db.mjs";
const app = express();
const PORT = 3104;
app.use(express.json());
// Dependencias internas.
// En producción serían nombres DNS internos o service discovery.
const CATALOGO_URL = "http://localhost:3101";
const PRECIOS_URL = "http://localhost:3102";
const INVENTARIO_URL = "http://localhost:3103";
const NOTIF_URL = "http://localhost:3105";
app.get("/health", (req, res) => {
res.json({ ok: true, servicio: "pedidos", timestamp: new Date().toISOString() });
});
// GET /pedidos
app.get("/pedidos", (req, res) => {
res.json(listarPedidos());
});
// GET /pedidos/:id
app.get("/pedidos/:id", (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
const p = obtenerPedidoPorId(id);
if (!p) return res.status(404).json({ error: "pedido no encontrado" });
res.json(p);
});
// POST /checkout
// Body esperado:
// {
// "product_id": 1,
// "cantidad": 2,
// "cupon": "SAVE10",
// "email": "cliente@demo.local",
// "simular_fallo_notif": false
// }
//
// Esta ruta es el ejemplo de “servicio de proceso” que orquesta.
// Aun así, su responsabilidad única es el flujo de checkout, no el stock,
// no el cálculo de promociones y no el envío real de notificaciones.
app.post("/checkout", async (req, res) => {
const productId = Number(req.body.product_id);
const cantidad = Number(req.body.cantidad);
const cupon = typeof req.body.cupon === "string" ? req.body.cupon : "";
const email = typeof req.body.email === "string" ? req.body.email.trim() : "";
const simularFalloNotif = req.body.simular_fallo_notif === true;
if (!Number.isInteger(productId) || productId <= 0) {
return res.status(400).json({ error: "product_id debe ser entero positivo" });
}
if (!Number.isInteger(cantidad) || cantidad <= 0) {
return res.status(400).json({ error: "cantidad debe ser entero > 0" });
}
if (email.length < 3) {
return res.status(400).json({ error: "email es obligatorio para la confirmación" });
}
// Paso 1: consultar catálogo para obtener precio_base.
// Motivo didáctico: pedidos no debería copiar datos del catálogo sin necesidad.
let producto;
try {
const r = await fetch(`${CATALOGO_URL}/productos/${productId}`);
if (r.status === 404) {
const pedido = crearPedidoFallido({ product_id: productId, cantidad, fallo_motivo: "PRODUCTO_NO_EXISTE" });
return res.status(404).json({ error: "producto no existe", pedido });
}
if (!r.ok) throw new Error("CATALOGO_NO_DISPONIBLE");
producto = await r.json();
} catch (err) {
const pedido = crearPedidoFallido({ product_id: productId, cantidad, fallo_motivo: "CATALOGO_NO_DISPONIBLE" });
return res.status(503).json({ error: "catálogo no disponible", pedido, detalle: String(err) });
}
// Paso 2: reservar inventario (crítico).
let reservaId;
try {
const r = await fetch(`${INVENTARIO_URL}/reservas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ product_id: productId, cantidad })
});
const payload = await r.json();
if (r.status === 409) {
const pedido = crearPedidoFallido({
product_id: productId,
cantidad,
fallo_motivo: payload.reason || "STOCK_INSUFICIENTE"
});
return res.status(409).json({ error: "stock insuficiente", pedido, inventario: payload });
}
if (!r.ok) throw new Error("INVENTARIO_ERROR");
reservaId = payload.reserva_id;
} catch (err) {
const pedido = crearPedidoFallido({ product_id: productId, cantidad, fallo_motivo: "INVENTARIO_NO_DISPONIBLE" });
return res.status(503).json({ error: "inventario no disponible", pedido, detalle: String(err) });
}
// Paso 3: calcular precios (cambia mucho, por eso es servicio separado).
let pricing;
try {
const r = await fetch(`${PRECIOS_URL}/calcular`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ precio_base: producto.precio_base, cantidad, cupon })
});
if (!r.ok) throw new Error("PRECIOS_ERROR");
pricing = await r.json();
} catch (err) {
// Si falla precios, liberamos reserva para no “bloquear” stock.
try {
await fetch(`${INVENTARIO_URL}/reservas/${reservaId}/liberar`, { method: "POST" });
} catch {}
const pedido = crearPedidoFallido({ product_id: productId, cantidad, fallo_motivo: "PRECIOS_NO_DISPONIBLE" });
return res.status(503).json({ error: "precios no disponible", pedido, detalle: String(err) });
}
// Paso 4: confirmar pedido en nuestra DB.
const pedidoConfirmado = crearPedidoConfirmado({
product_id: productId,
cantidad,
subtotal: pricing.subtotal,
descuento: pricing.descuento,
total: pricing.total,
reserva_id: reservaId
});
// Paso 5: consumir la reserva (crítico).
// Si esto fallara en un sistema real habría que reconciliar con reintentos.
try {
await fetch(`${INVENTARIO_URL}/reservas/${reservaId}/consumir`, { method: "POST" });
} catch {
// Para el laboratorio no lo complicamos: registramos el pedido como confirmado igualmente,
// porque ya lo guardamos. En producción aquí necesitas estrategia de consistencia.
}
// Paso 6: notificación best-effort (no crítico).
// Si falla, NO revertimos el pedido.
let notifOk = false;
try {
const r = await fetch(`${NOTIF_URL}/enviar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pedido_id: pedidoConfirmado.id,
destino: email,
mensaje: `Pedido confirmado. Total: ${pedidoConfirmado.total}`,
simular_fallo: simularFalloNotif
})
});
notifOk = r.ok;
} catch {
notifOk = false;
}
res.status(201).json({
ok: true,
pedido: pedidoConfirmado,
notificacion_enviada: notifOk
});
});
app.listen(PORT, () => {
console.log(`Pedidos en http://localhost:${PORT}`);
});
Este archivo es el servicio de proceso de pedidos: el componente que orquesta un flujo completo de checkout llamando a varios servicios proveedores (catálogo, inventario, precios y notificaciones) y registrando el resultado en su propia base de datos.
En un sistema distribuido donde el backend es el proveedor, aquí vive la lógica de coordinación: valida la solicitud, consulta el producto, reserva stock, calcula el total, confirma el pedido, consume la reserva y finalmente intenta notificar al cliente sin bloquear el pedido si falla. Su valor está en convertir dependencias separadas en un flujo único y controlado, aplicando degradación y compensaciones (por ejemplo, liberar reserva si falla precios) para mantener coherencia sin acoplar dominios.
servicios/pedidos/http-client.http
### Health pedidos
GET http://localhost:3104/health
### Checkout correcto
POST http://localhost:3104/checkout
Content-Type: application/json
{
"product_id": 1,
"cantidad": 2,
"cupon": "SAVE10",
"email": "cliente@demo.local",
"simular_fallo_notif": false
}
### Checkout con stock insuficiente (producto 2 tiene 2 unidades en seed)
POST http://localhost:3104/checkout
Content-Type: application/json
{
"product_id": 2,
"cantidad": 5,
"cupon": "",
"email": "cliente@demo.local",
"simular_fallo_notif": false
}
### Checkout con fallo simulado de notificación (el pedido debe salir igualmente)
POST http://localhost:3104/checkout
Content-Type: application/json
{
"product_id": 3,
"cantidad": 1,
"cupon": "SHIPFREE",
"email": "cliente@demo.local",
"simular_fallo_notif": true
}
### Listar pedidos
GET http://localhost:3104/pedidos
Frontend estático
El frontend hablará solo con Pedidos (checkout) y Catálogo (para listar productos en pantalla). En un entorno real, podrías poner un gateway delante, pero aquí buscamos ver responsabilidades claras.
frontend/index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mini SRP Checkout</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link rel="stylesheet" href="./styles.css" />
</head>
<body class="bg-light">
<main class="container py-4">
<h1 class="mb-3">Checkout con servicios separados</h1>
<p class="text-muted">
Catálogo, precios, inventario, pedidos y notificaciones son procesos distintos.
Cada uno tiene un propósito único.
</p>
<section class="card p-3 mb-4">
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="catalogoUrl">URL Catálogo</label>
<input id="catalogoUrl" class="form-control" type="text" value="http://localhost:3101" />
</div>
<div class="col-md-6">
<label class="form-label" for="pedidosUrl">URL Pedidos</label>
<input id="pedidosUrl" class="form-control" type="text" value="http://localhost:3104" />
</div>
</div>
<div class="mt-3 d-flex gap-2 flex-wrap">
<button id="btnCargar" class="btn btn-primary">Cargar productos</button>
</div>
<div class="mt-3 small text-muted" id="estado"></div>
</section>
<section class="row g-3" id="grid"></section>
<section class="mt-4">
<h2 class="h4">Crear pedido</h2>
<div class="card p-3">
<form id="formCheckout" class="row g-2">
<div class="col-md-2">
<label class="form-label" for="productId">Product ID</label>
<input id="productId" class="form-control" type="number" min="1" required />
</div>
<div class="col-md-2">
<label class="form-label" for="cantidad">Cantidad</label>
<input id="cantidad" class="form-control" type="number" min="1" value="1" required />
</div>
<div class="col-md-3">
<label class="form-label" for="cupon">Cupón</label>
<input id="cupon" class="form-control" type="text" placeholder="SAVE10 o SHIPFREE" />
</div>
<div class="col-md-5">
<label class="form-label" for="email">Email</label>
<input id="email" class="form-control" type="email" value="cliente@demo.local" required />
</div>
<div class="col-12">
<div class="form-check">
<input id="falloNotif" class="form-check-input" type="checkbox" />
<label class="form-check-label" for="falloNotif">
Simular fallo de notificaciones
</label>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-success" type="submit">Hacer checkout</button>
<button id="btnVerPedidos" class="btn btn-outline-secondary" type="button">Ver pedidos</button>
</div>
<div class="col-12">
<pre id="resultado" class="p-2 bg-white border rounded"></pre>
</div>
</form>
</div>
</section>
</main>
<script src="./app.js" type="module"></script>
</body>
</html>
frontend/styles.css
#resultado {
min-height: 120px;
white-space: pre-wrap;
word-break: break-word;
}
frontend/app.js
const $ = (sel) => document.querySelector(sel);
const catalogoUrl = $("#catalogoUrl");
const pedidosUrl = $("#pedidosUrl");
const estado = $("#estado");
const grid = $("#grid");
const btnCargar = $("#btnCargar");
const formCheckout = $("#formCheckout");
const resultado = $("#resultado");
const btnVerPedidos = $("#btnVerPedidos");
function base(urlInput) {
return urlInput.value.trim().replace(/\/$/, "");
}
function setEstado(texto) {
estado.textContent = texto;
}
function pretty(obj) {
return JSON.stringify(obj, null, 2);
}
function escapeHtml(str) {
return String(str)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
async function cargarProductos() {
setEstado("Cargando productos del catálogo...");
grid.innerHTML = "";
try {
const resp = await fetch(`${base(catalogoUrl)}/productos`);
if (!resp.ok) {
setEstado(`Error HTTP ${resp.status} al cargar catálogo`);
return;
}
const items = await resp.json();
setEstado(`Productos cargados: ${items.length}`);
for (const p of items) {
const col = document.createElement("div");
col.className = "col-12 col-md-6 col-lg-4";
col.innerHTML = `
<div class="card p-3 h-100">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">${escapeHtml(p.nombre)}</div>
<div class="text-muted small">ID: ${p.id}</div>
<div class="text-muted small">SKU: ${escapeHtml(p.sku)}</div>
</div>
<div class="fw-semibold">${Number(p.precio_base).toFixed(2)} €</div>
</div>
<div class="mt-3">
<button class="btn btn-outline-primary btn-sm" data-id="${p.id}">
Usar este producto
</button>
</div>
</div>
`;
col.querySelector("button").addEventListener("click", () => {
$("#productId").value = String(p.id);
$("#productId").focus();
});
grid.appendChild(col);
}
} catch (err) {
setEstado(`Error de red: ${String(err)}`);
}
}
btnCargar.addEventListener("click", cargarProductos);
formCheckout.addEventListener("submit", async (e) => {
e.preventDefault();
resultado.textContent = "Enviando checkout...";
const body = {
product_id: Number($("#productId").value),
cantidad: Number($("#cantidad").value),
cupon: $("#cupon").value,
email: $("#email").value,
simular_fallo_notif: $("#falloNotif").checked
};
try {
const resp = await fetch(`${base(pedidosUrl)}/checkout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const payload = await resp.json();
resultado.textContent = pretty({ http_status: resp.status, payload });
} catch (err) {
resultado.textContent = pretty({ error: String(err) });
}
});
btnVerPedidos.addEventListener("click", async () => {
resultado.textContent = "Cargando pedidos...";
try {
const resp = await fetch(`${base(pedidosUrl)}/pedidos`);
const payload = await resp.json();
resultado.textContent = pretty(payload);
} catch (err) {
resultado.textContent = pretty({ error: String(err) });
}
});
Cómo ejecutar todo desde VSCode sin scripts
Abre 5 terminales en VSCode, una por servicio.
- Catálogo
cd .\servicios\catalogo
node .\src\server.mjs
- Precios
cd ..\precios
node .\src\server.mjs
- Inventario
cd ..\inventario
node .\src\server.mjs
- Pedidos
cd ..\pedidos
node .\src\server.mjs
- Notificaciones
cd ..\notificaciones
node .\src\server.mjs
Luego ejecuta pruebas con REST Client:
- Abre cada archivo http-client.http
- Lanza health de cada servicio
- Ejecuta el checkout desde servicios/pedidos/http-client.http
Para frontend:
- Abre frontend/index.html
- Open with Live Server
- Pulsa Cargar productos
- Haz checkout con o sin fallo simulado de notificaciones
Pruebas guiadas para ver la separación de responsabilidades
- Frecuencia de cambio
- Edita reglas de descuentos en precios/src/server.mjs
- Reinicia solo Precios
- El resto no se toca
- Escalado
- Haz muchos checkouts seguidos desde REST Client
- Observa que el punto caliente lógico es inventario (reservas)
- En un escenario real, escalarías inventario antes que notificaciones
- Disponibilidad
- Para notificaciones con fallo:
- Marca “Simular fallo de notificaciones” en el frontend
- El pedido debe salir confirmado igualmente, con notificacion_enviada false
- Para inventario caído:
- Para el servicio inventario (CTRL+C)
- El checkout debe fallar con inventario no disponible y registrar pedido fallido
- Para notificaciones con fallo:
Si quieres, el siguiente paso natural es añadir un API Gateway delante del frontend para que la UI solo consuma un endpoint público, y el gateway llame a pedidos o incluso exponga un contrato más estable.
4.5 Límites entre Componentes
Definición de Límites
Los límites en sistemas distribuidos son barreras de abstracción que definen cómo los componentes interactúan mientras mantienen su independencia.
Tipos de Límites
- Límites de comunicación: APIs, protocolos, formatos de datos.
- Límites de consistencia: Transacciones, sincronización, replicación.
- Límites de fallos: Puntos donde los errores pueden contener.
- Límites de escalado: Unidades que escalan independientemente.
Diseño de Límites Efectivos
- APIs estables: Contratos que cambian mínimamente y con versionado.
- Acoplamiento débil: Depender de interfaces, no implementaciones.
- Tolerancia a fallos: Diseñar para la indisponibilidad de componentes.
- Observabilidad cruzada: Trazar solicitudes a través de límites.
Sistema de hotel distribuido
Vamos a implementar un sistema completo de reservas de hotel que demuestre todos los componentes de un sistema distribuido.
Objetivos
- Ver un sistema distribuido realista con servicios pequeños y autónomos
- Entender quién es la fuente de verdad de cada dato
- Ver una orquestación tipo SAGA con pasos explícitos y compensaciones
- Practicar fallos parciales sin “romper” el sistema completo
- Ejecutar y probar todo desde VSCode sin depender de scripts
- Leer código backend con comentarios extensos, orientados a docencia
Responsabilidades y fuentes de verdad
- Servicio Habitaciones
- Fuente de verdad de disponibilidad por habitación y fecha
- Bloquea y libera fechas de forma atómica (transacciones)
- Servicio Reservas
- Fuente de verdad del ciclo de vida de la reserva (pendiente, confirmada, cancelada)
- Registra el histórico de estados y el contexto del proceso
- Servicio Pagos
- Fuente de verdad del estado financiero (pendiente, completado, reembolsado, fallido)
- Simula pasarela externa y reembolso como compensación
- Servicio Notificaciones
- Envío best-effort, no crítico
- Si falla, la reserva confirmada no se revierte
- API Gateway
- Única puerta de entrada para el frontend
- Orquestador del flujo de “reserva completa” con pasos y compensaciones
- Frontend
- Cliente que consume el gateway
- Sin lógica de negocio distribuida, solo UI y llamadas
Estados del flujo SAGA
La orquestación en el gateway seguirá esta secuencia:
- Verificar disponibilidad (Habitaciones)
- Crear reserva en estado pendiente (Reservas)
- Procesar pago (Pagos)
- Bloquear fechas de habitación con reserva_id (Habitaciones)
- Confirmar reserva (Reservas)
- Enviar notificación (Notificaciones) best-effort
Compensaciones principales:
- Si el pago falla, cancelar reserva en Reservas
- Si pago ok pero falla el bloqueo de habitación, reembolsar pago y cancelar reserva
- Si falla notificaciones, no se compensa nada
Estructura del proyecto
Crea esta estructura:
.
└── sistema-hotel-distribuido-limpio/
├── frontend-hotel/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── api-gateway/
│ ├── package.json
│ ├── src/
│ │ └── server.mjs
│ └── http-client.http
├── servicio-habitaciones/
│ ├── package.json
│ ├── src/
│ │ ├── db.mjs
│ │ └── server.mjs
│ ├── data/
│ └── http-client.http
├── servicio-reservas/
│ ├── package.json
│ ├── src/
│ │ ├── db.mjs
│ │ └── server.mjs
│ ├── data/
│ └── http-client.http
├── servicio-pagos/
│ ├── package.json
│ ├── src/
│ │ ├── db.mjs
│ │ └── server.mjs
│ ├── data/
│ └── http-client.http
└── servicio-notificaciones/
├── package.json
├── src/
│ └── server.mjs
└── http-client.http
Notas importantes
- Las carpetas data deben existir en los servicios con SQLite
- Los archivos .db se crean automáticamente al arrancar cada servicio
- Todo el backend usa ES modules y Express
- Para SQLite se usa better-sqlite3 por simplicidad y claridad didáctica
Instalación de dependencias desde VSCode sin scripts
Abre la carpeta raíz en VSCode.
En PowerShell, entra en cada servicio y ejecuta lo siguiente.
Servicio Habitaciones
cd .\servicio-habitaciones
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio Reservas
cd ..\servicio-reservas
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio Pagos
cd ..\servicio-pagos
npm init -y
npm pkg set type=module
npm i express better-sqlite3
Servicio Notificaciones
cd ..\servicio-notificaciones
npm init -y
npm pkg set type=module
npm i express
API Gateway
cd ..\api-gateway
npm init -y
npm pkg set type=module
npm i express
Vuelve a la raíz:
cd ..
Servicio Habitaciones
servicio-habitaciones/src/db.mjs
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
/*
SERVICIO: Habitaciones
RESPONSABILIDAD ÚNICA:
- Ser la fuente de verdad de la disponibilidad por habitación y fecha.
- Responder búsquedas de disponibilidad.
- Bloquear (reservar) fechas de forma atómica.
- Liberar fechas si la reserva se cancela.
DECISIÓN DIDÁCTICA:
- Guardamos disponibilidad por día en una tabla "calendario".
- Esto simplifica entender bloqueos y conflictos.
- En sistemas reales, se usan rangos, inventarios por tipo, overbooking controlado, etc.
CLAVE DISTRIBUIDA:
- Ningún otro servicio escribe aquí.
- Reservas no hace UPDATE de disponibilidad.
- Gateway coordina por API.
*/
const dataDir = path.resolve("data");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "habitaciones.db");
export const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
/*
Modelo:
- habitaciones: catálogo de habitaciones (id, tipo, capacidad, precio_base, activa)
- calendario: un registro por habitación y fecha, con estado libre/bloqueada y reserva_id
NOTA:
- reserva_id es una referencia externa (no FK), porque pertenece al servicio Reservas.
- Esto evita acoplar bases de datos entre servicios.
*/
db.exec(`
CREATE TABLE IF NOT EXISTS habitaciones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
numero TEXT NOT NULL UNIQUE,
tipo TEXT NOT NULL,
descripcion TEXT,
capacidad INTEGER NOT NULL CHECK(capacidad > 0),
precio_noche REAL NOT NULL CHECK(precio_noche >= 0),
activa INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS calendario (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habitacion_id INTEGER NOT NULL,
fecha TEXT NOT NULL,
estado TEXT NOT NULL CHECK(estado IN ('libre','bloqueada')),
reserva_id TEXT,
UNIQUE(habitacion_id, fecha),
FOREIGN KEY (habitacion_id) REFERENCES habitaciones(id)
);
CREATE INDEX IF NOT EXISTS idx_calendario_fecha ON calendario(fecha);
CREATE INDEX IF NOT EXISTS idx_calendario_habitacion_fecha ON calendario(habitacion_id, fecha);
`);
function seedHabitacionesSiVacio() {
const n = db.prepare(`SELECT COUNT(*) AS n FROM habitaciones`).get().n;
if (n > 0) return;
const insert = db.prepare(`
INSERT INTO habitaciones (numero, tipo, descripcion, capacidad, precio_noche, activa)
VALUES (?, ?, ?, ?, ?, 1)
`);
const habitaciones = [
["101", "Estandar", "Cama doble, baño privado", 2, 95.0],
["102", "Estandar", "Dos camas, baño privado", 2, 95.0],
["201", "Deluxe", "Vista al mar, mejor aislamiento", 2, 160.0],
["202", "Deluxe", "Suite ejecutiva con sala", 3, 190.0],
["301", "Familiar", "Ideal para familias", 4, 240.0]
];
const tx = db.transaction(() => {
for (const h of habitaciones) insert.run(...h);
});
tx();
}
/*
Semilla de calendario:
- Generamos disponibilidad "libre" para los próximos X días.
- En SQLite no usamos generate_series, para evitar incompatibilidades.
- Usamos un bucle JS claro y explícito.
*/
function seedCalendarioSiVacio(dias = 30) {
const n = db.prepare(`SELECT COUNT(*) AS n FROM calendario`).get().n;
if (n > 0) return;
const habitaciones = db.prepare(`SELECT id FROM habitaciones WHERE activa = 1`).all();
const insert = db.prepare(`
INSERT INTO calendario (habitacion_id, fecha, estado, reserva_id)
VALUES (?, ?, 'libre', NULL)
`);
const tx = db.transaction(() => {
for (const h of habitaciones) {
for (let i = 0; i <= dias; i++) {
const fecha = db.prepare(`SELECT date('now', ? ) AS d`).get(`+${i} day`).d;
insert.run(h.id, fecha);
}
}
});
tx();
}
seedHabitacionesSiVacio();
seedCalendarioSiVacio(30);
/*
Utilidad:
- Normalizamos fechas como 'YYYY-MM-DD' (texto).
- El frontend ya usa input type="date", que entrega ese formato.
*/
export function listarHabitaciones() {
return db.prepare(`
SELECT id, numero, tipo, descripcion, capacidad, precio_noche
FROM habitaciones
WHERE activa = 1
ORDER BY precio_noche ASC
`).all();
}
export function obtenerHabitacionPorId(id) {
return db.prepare(`
SELECT id, numero, tipo, descripcion, capacidad, precio_noche, activa
FROM habitaciones
WHERE id = ?
`).get(id);
}
/*
Búsqueda de disponibilidad:
- Una habitación está disponible si para cada fecha del rango (entrada..salida-1) está libre.
- Simplificamos: salida no cuenta como noche reservada.
*/
export function buscarDisponibles({ fechaEntrada, fechaSalida, huespedes }) {
return db.prepare(`
SELECT h.id, h.numero, h.tipo, h.descripcion, h.capacidad, h.precio_noche
FROM habitaciones h
WHERE h.activa = 1
AND h.capacidad >= ?
AND NOT EXISTS (
SELECT 1
FROM calendario c
WHERE c.habitacion_id = h.id
AND c.fecha >= ?
AND c.fecha < ?
AND c.estado = 'bloqueada'
)
ORDER BY h.precio_noche ASC
`).all(huespedes, fechaEntrada, fechaSalida);
}
/*
Verificar disponibilidad específica:
- Responde true/false sin hacer cambios.
- Útil para el primer paso de la SAGA.
*/
export function verificarDisponibilidad({ habitacionId, fechaEntrada, fechaSalida }) {
const row = db.prepare(`
SELECT COUNT(*) AS bloqueadas
FROM calendario
WHERE habitacion_id = ?
AND fecha >= ?
AND fecha < ?
AND estado = 'bloqueada'
`).get(habitacionId, fechaEntrada, fechaSalida);
return row.bloqueadas === 0;
}
/*
Bloquear fechas (operación crítica):
- Debe ser atómica para evitar carreras.
- Implementamos una transacción que:
1) verifica que no hay bloqueos
2) marca como bloqueada y asigna reserva_id
IMPORTANTE:
- Este patrón enseña el concepto de "sección crítica" en el servicio dueño del recurso.
*/
export function bloquearFechas({ habitacionId, fechaEntrada, fechaSalida, reservaId }) {
const tx = db.transaction(() => {
const conflicto = db.prepare(`
SELECT 1
FROM calendario
WHERE habitacion_id = ?
AND fecha >= ?
AND fecha < ?
AND estado = 'bloqueada'
LIMIT 1
`).get(habitacionId, fechaEntrada, fechaSalida);
if (conflicto) {
return { ok: false, reason: "NO_DISPONIBLE" };
}
const result = db.prepare(`
UPDATE calendario
SET estado = 'bloqueada', reserva_id = ?
WHERE habitacion_id = ?
AND fecha >= ?
AND fecha < ?
AND estado = 'libre'
`).run(reservaId, habitacionId, fechaEntrada, fechaSalida);
if (result.changes === 0) {
return { ok: false, reason: "RANGO_INVALIDO_O_SIN_CALENDARIO" };
}
return { ok: true };
});
return tx();
}
/*
Liberar fechas (compensación):
- Se usa cuando una reserva se cancela o falla la SAGA.
- Solo libera fechas asociadas a ese reserva_id.
*/
export function liberarFechas({ reservaId }) {
const result = db.prepare(`
UPDATE calendario
SET estado = 'libre', reserva_id = NULL
WHERE reserva_id = ?
`).run(reservaId);
return { ok: true, changes: result.changes };
}
servicio-habitaciones/src/server.mjs
import express from "express";
import {
listarHabitaciones,
buscarDisponibles,
obtenerHabitacionPorId,
verificarDisponibilidad,
bloquearFechas,
liberarFechas
} from "./db.mjs";
/*
API de Habitaciones
- No conoce pagos
- No conoce reservas (solo recibe reservaId como referencia)
- Solo gestiona disponibilidad y datos de habitación
*/
const app = express();
const PORT = 3001;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "habitaciones",
status: "healthy",
port: PORT,
endpoints: [
"GET /habitaciones",
"GET /habitaciones/disponibles",
"POST /habitaciones/verificar",
"POST /habitaciones/bloquear",
"POST /habitaciones/liberar"
]
});
});
app.get("/habitaciones", (req, res) => {
res.json(listarHabitaciones());
});
app.get("/habitaciones/disponibles", (req, res) => {
const { fechaEntrada, fechaSalida, huespedes } = req.query;
if (!fechaEntrada || !fechaSalida) {
return res.status(400).json({ error: "fechaEntrada y fechaSalida son requeridos" });
}
const huesp = Number(huespedes ?? 1);
if (!Number.isInteger(huesp) || huesp <= 0) {
return res.status(400).json({ error: "huespedes debe ser entero positivo" });
}
const disponibles = buscarDisponibles({ fechaEntrada, fechaSalida, huespedes: huesp });
res.json(disponibles);
});
app.post("/habitaciones/verificar", (req, res) => {
const habitacionId = Number(req.body.habitacion_id);
const { fecha_entrada, fecha_salida } = req.body;
if (!Number.isInteger(habitacionId) || habitacionId <= 0) {
return res.status(400).json({ error: "habitacion_id inválido" });
}
if (!fecha_entrada || !fecha_salida) {
return res.status(400).json({ error: "fecha_entrada y fecha_salida son requeridos" });
}
const h = obtenerHabitacionPorId(habitacionId);
if (!h || h.activa !== 1) return res.status(404).json({ error: "habitación no encontrada" });
const ok = verificarDisponibilidad({
habitacionId,
fechaEntrada: fecha_entrada,
fechaSalida: fecha_salida
});
res.json({ disponible: ok, habitacion_id: habitacionId });
});
app.post("/habitaciones/bloquear", (req, res) => {
const habitacionId = Number(req.body.habitacion_id);
const { fecha_entrada, fecha_salida, reserva_id } = req.body;
if (!Number.isInteger(habitacionId) || habitacionId <= 0) {
return res.status(400).json({ error: "habitacion_id inválido" });
}
if (!fecha_entrada || !fecha_salida || !reserva_id) {
return res.status(400).json({ error: "fecha_entrada, fecha_salida y reserva_id son requeridos" });
}
const out = bloquearFechas({
habitacionId,
fechaEntrada: fecha_entrada,
fechaSalida: fecha_salida,
reservaId: String(reserva_id)
});
if (!out.ok) return res.status(409).json(out);
res.json({ ok: true, habitacion_id: habitacionId, reserva_id });
});
app.post("/habitaciones/liberar", (req, res) => {
const { reserva_id } = req.body;
if (!reserva_id) return res.status(400).json({ error: "reserva_id requerido" });
const out = liberarFechas({ reservaId: String(reserva_id) });
res.json(out);
});
app.listen(PORT, () => {
console.log(`Servicio HABITACIONES en http://localhost:${PORT}`);
});
servicio-habitaciones/http-client.http
### Health habitaciones
GET http://localhost:3001/health
### Listar habitaciones
GET http://localhost:3001/habitaciones
### Buscar disponibles
GET http://localhost:3001/habitaciones/disponibles?fechaEntrada=2025-12-13&fechaSalida=2025-12-15&huespedes=2
### Verificar disponibilidad de una habitación
POST http://localhost:3001/habitaciones/verificar
Content-Type: application/json
{
"habitacion_id": 1,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15"
}
Servicio Reservas
servicio-reservas/src/db.mjs
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
/*
SERVICIO: Reservas
RESPONSABILIDAD ÚNICA:
- Ser la fuente de verdad del ciclo de vida de la reserva.
- Guardar datos de huésped y fechas.
- Mantener estado y trazabilidad del proceso.
IMPORTANTE:
- Reservas NO bloquea calendario de habitaciones.
- Reservas NO procesa pagos.
- Reservas guarda referencias a pago_id y habitacion_id, pero no controla sus tablas.
DIDÁCTICA:
- Guardamos también un "timeline" de eventos para ver el paso a paso de la SAGA.
*/
const dataDir = path.resolve("data");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "reservas.db");
export const db = new Database(dbPath);
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_id INTEGER 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,
monto_total REAL NOT NULL,
moneda TEXT NOT NULL DEFAULT 'EUR',
estado TEXT NOT NULL CHECK(estado IN ('pendiente','confirmada','cancelada')),
pago_id TEXT,
cancel_reason TEXT,
creado_en TEXT NOT NULL DEFAULT (datetime('now')),
actualizado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_reservas_email ON reservas(huesped_email);
CREATE INDEX IF NOT EXISTS idx_reservas_estado ON reservas(estado);
CREATE TABLE IF NOT EXISTS reserva_eventos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reserva_id TEXT NOT NULL,
evento TEXT NOT NULL,
detalle TEXT,
creado_en TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_eventos_reserva ON reserva_eventos(reserva_id);
`);
function generarReservaId() {
const rnd = Math.random().toString(36).slice(2, 6).toUpperCase();
return `RES-${Date.now()}-${rnd}`;
}
export function crearReservaPendiente(payload) {
const reservaId = generarReservaId();
db.prepare(`
INSERT INTO reservas (
reserva_id, habitacion_id, fecha_entrada, fecha_salida,
huespedes, huesped_nombre, huesped_email,
monto_total, moneda, estado
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pendiente')
`).run(
reservaId,
payload.habitacion_id,
payload.fecha_entrada,
payload.fecha_salida,
payload.huespedes,
payload.huesped_nombre,
payload.huesped_email,
payload.monto_total,
payload.moneda ?? "EUR"
);
registrarEvento(reservaId, "RESERVA_CREADA_PENDIENTE", null);
return obtenerReservaPorReservaId(reservaId);
}
export function confirmarReserva({ reserva_id, pago_id }) {
db.prepare(`
UPDATE reservas
SET estado = 'confirmada',
pago_id = ?,
actualizado_en = datetime('now')
WHERE reserva_id = ?
AND estado = 'pendiente'
`).run(pago_id ?? null, reserva_id);
registrarEvento(reserva_id, "RESERVA_CONFIRMADA", pago_id ?? null);
return obtenerReservaPorReservaId(reserva_id);
}
export function cancelarReserva({ reserva_id, reason }) {
db.prepare(`
UPDATE reservas
SET estado = 'cancelada',
cancel_reason = ?,
actualizado_en = datetime('now')
WHERE reserva_id = ?
AND estado IN ('pendiente','confirmada')
`).run(reason ?? "CANCELADA", reserva_id);
registrarEvento(reserva_id, "RESERVA_CANCELADA", reason ?? null);
return obtenerReservaPorReservaId(reserva_id);
}
export function setPagoId({ reserva_id, pago_id }) {
db.prepare(`
UPDATE reservas
SET pago_id = ?,
actualizado_en = datetime('now')
WHERE reserva_id = ?
`).run(pago_id ?? null, reserva_id);
registrarEvento(reserva_id, "PAGO_ID_ASIGNADO", pago_id ?? null);
}
export function obtenerReservaPorReservaId(reservaId) {
return db.prepare(`
SELECT * FROM reservas WHERE reserva_id = ?
`).get(reservaId);
}
export function listarReservasPorEmail(email) {
return db.prepare(`
SELECT * FROM reservas
WHERE huesped_email = ?
ORDER BY creado_en DESC
`).all(email);
}
export function eventosDeReserva(reservaId) {
return db.prepare(`
SELECT id, evento, detalle, creado_en
FROM reserva_eventos
WHERE reserva_id = ?
ORDER BY id ASC
`).all(reservaId);
}
export function registrarEvento(reservaId, evento, detalle) {
db.prepare(`
INSERT INTO reserva_eventos (reserva_id, evento, detalle)
VALUES (?, ?, ?)
`).run(reservaId, evento, detalle);
}
servicio-reservas/src/server.mjs
import express from "express";
import {
crearReservaPendiente,
confirmarReserva,
cancelarReserva,
setPagoId,
obtenerReservaPorReservaId,
listarReservasPorEmail,
eventosDeReserva
} from "./db.mjs";
/*
API de Reservas
- Ofrece endpoints CRUD simples del dominio reservas
- El gateway usa:
- POST /reservas (crear pendiente)
- POST /reservas/:reserva_id/confirmar
- POST /reservas/:reserva_id/cancelar
- POST /reservas/:reserva_id/pago
*/
const app = express();
const PORT = 3002;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "reservas",
status: "healthy",
port: PORT,
endpoints: [
"POST /reservas",
"GET /reservas?email=..",
"GET /reservas/:reserva_id",
"GET /reservas/:reserva_id/eventos",
"POST /reservas/:reserva_id/confirmar",
"POST /reservas/:reserva_id/cancelar",
"POST /reservas/:reserva_id/pago"
]
});
});
app.post("/reservas", (req, res) => {
const b = req.body;
const habitacionId = Number(b.habitacion_id);
const huespedes = Number(b.huespedes);
const monto = Number(b.monto_total);
if (!Number.isInteger(habitacionId) || habitacionId <= 0) {
return res.status(400).json({ error: "habitacion_id inválido" });
}
if (!b.fecha_entrada || !b.fecha_salida) {
return res.status(400).json({ error: "fecha_entrada y fecha_salida son requeridos" });
}
if (!Number.isInteger(huespedes) || huespedes <= 0) {
return res.status(400).json({ error: "huespedes inválido" });
}
if (typeof b.huesped_nombre !== "string" || b.huesped_nombre.trim().length < 2) {
return res.status(400).json({ error: "huesped_nombre inválido" });
}
if (typeof b.huesped_email !== "string" || b.huesped_email.trim().length < 3) {
return res.status(400).json({ error: "huesped_email inválido" });
}
if (!Number.isFinite(monto) || monto < 0) {
return res.status(400).json({ error: "monto_total inválido" });
}
const reserva = crearReservaPendiente({
habitacion_id: habitacionId,
fecha_entrada: b.fecha_entrada,
fecha_salida: b.fecha_salida,
huespedes,
huesped_nombre: b.huesped_nombre.trim(),
huesped_email: b.huesped_email.trim(),
monto_total: monto,
moneda: b.moneda ?? "EUR"
});
res.status(201).json(reserva);
});
app.get("/reservas", (req, res) => {
const email = req.query.email;
if (!email) return res.status(400).json({ error: "email requerido" });
res.json(listarReservasPorEmail(String(email)));
});
app.get("/reservas/:reserva_id", (req, res) => {
const r = obtenerReservaPorReservaId(req.params.reserva_id);
if (!r) return res.status(404).json({ error: "reserva no encontrada" });
res.json(r);
});
app.get("/reservas/:reserva_id/eventos", (req, res) => {
const r = obtenerReservaPorReservaId(req.params.reserva_id);
if (!r) return res.status(404).json({ error: "reserva no encontrada" });
res.json(eventosDeReserva(req.params.reserva_id));
});
app.post("/reservas/:reserva_id/pago", (req, res) => {
const pagoId = req.body.pago_id;
if (!pagoId) return res.status(400).json({ error: "pago_id requerido" });
const r = obtenerReservaPorReservaId(req.params.reserva_id);
if (!r) return res.status(404).json({ error: "reserva no encontrada" });
setPagoId({ reserva_id: req.params.reserva_id, pago_id: String(pagoId) });
res.json({ ok: true });
});
app.post("/reservas/:reserva_id/confirmar", (req, res) => {
const pagoId = req.body.pago_id;
const r = obtenerReservaPorReservaId(req.params.reserva_id);
if (!r) return res.status(404).json({ error: "reserva no encontrada" });
const out = confirmarReserva({ reserva_id: req.params.reserva_id, pago_id: pagoId ? String(pagoId) : null });
res.json(out);
});
app.post("/reservas/:reserva_id/cancelar", (req, res) => {
const reason = req.body.reason;
const r = obtenerReservaPorReservaId(req.params.reserva_id);
if (!r) return res.status(404).json({ error: "reserva no encontrada" });
const out = cancelarReserva({ reserva_id: req.params.reserva_id, reason: reason ? String(reason) : "CANCELADA" });
res.json(out);
});
app.listen(PORT, () => {
console.log(`Servicio RESERVAS en http://localhost:${PORT}`);
});
servicio-reservas/http-client.http
### Health reservas
GET http://localhost:3002/health
### Crear reserva pendiente
POST http://localhost:3002/reservas
Content-Type: application/json
{
"habitacion_id": 1,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"monto_total": 190.00,
"moneda": "EUR"
}
Servicio Pagos
servicio-pagos/src/db.mjs
import fs from "fs";
import path from "path";
import Database from "better-sqlite3";
/*
SERVICIO: Pagos
RESPONSABILIDAD ÚNICA:
- Ser la fuente de verdad de transacciones y su estado.
- Proveer operaciones de procesar pago y reembolsar como compensación.
DIDÁCTICA:
- Simulamos la pasarela externa:
- A veces falla
- A veces funciona
- Permitimos forzar fallo con un flag para pruebas repetibles.
*/
const dataDir = path.resolve("data");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "pagos.db");
export const db = new Database(dbPath);
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS pagos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pago_id TEXT NOT NULL UNIQUE,
reserva_id TEXT,
monto REAL NOT NULL,
moneda TEXT NOT NULL DEFAULT 'EUR',
metodo TEXT NOT NULL,
estado TEXT NOT NULL CHECK(estado IN ('pendiente','completado','fallido','reembolsado')),
creado_en TEXT NOT NULL DEFAULT (datetime('now')),
actualizado_en TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_pagos_reserva ON pagos(reserva_id);
CREATE INDEX IF NOT EXISTS idx_pagos_estado ON pagos(estado);
`);
function generarPagoId() {
const rnd = Math.random().toString(36).slice(2, 8).toUpperCase();
return `PAY-${Date.now()}-${rnd}`;
}
export function crearPagoPendiente({ reserva_id, monto, moneda, metodo, metadata }) {
const pagoId = generarPagoId();
db.prepare(`
INSERT INTO pagos (pago_id, reserva_id, monto, moneda, metodo, estado, metadata)
VALUES (?, ?, ?, ?, ?, 'pendiente', ?)
`).run(pagoId, reserva_id ?? null, monto, moneda ?? "EUR", metodo, metadata ?? null);
return obtenerPago(pagoId);
}
export function marcarPago({ pago_id, estado, metadata }) {
db.prepare(`
UPDATE pagos
SET estado = ?,
metadata = COALESCE(?, metadata),
actualizado_en = datetime('now')
WHERE pago_id = ?
`).run(estado, metadata ?? null, pago_id);
return obtenerPago(pago_id);
}
export function obtenerPago(pagoId) {
return db.prepare(`SELECT * FROM pagos WHERE pago_id = ?`).get(pagoId);
}
servicio-pagos/src/server.mjs
import express from "express";
import { crearPagoPendiente, marcarPago, obtenerPago } from "./db.mjs";
/*
API de Pagos
Endpoints clave:
- POST /pagos/procesar
- POST /pagos/:pago_id/reembolsar
- GET /pagos/:pago_id
*/
const app = express();
const PORT = 3003;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "pagos",
status: "healthy",
port: PORT,
endpoints: [
"POST /pagos/procesar",
"POST /pagos/:pago_id/reembolsar",
"GET /pagos/:pago_id"
]
});
});
/*
Procesar pago:
Body:
{
"reserva_id": "RES-..",
"monto": 190.00,
"moneda": "EUR",
"metodo": "tarjeta",
"forzar_fallo": false
}
DIDÁCTICA:
- Creamos un pago en pendiente
- Simulamos resultado
- Actualizamos a completado o fallido
*/
app.post("/pagos/procesar", (req, res) => {
const { reserva_id, monto, moneda, metodo, forzar_fallo } = req.body;
const montoNum = Number(monto);
if (!Number.isFinite(montoNum) || montoNum <= 0) {
return res.status(400).json({ error: "monto debe ser número > 0" });
}
if (typeof metodo !== "string" || metodo.trim().length < 2) {
return res.status(400).json({ error: "metodo inválido" });
}
const pago = crearPagoPendiente({
reserva_id: reserva_id ? String(reserva_id) : null,
monto: montoNum,
moneda: moneda ? String(moneda) : "EUR",
metodo: metodo.trim(),
metadata: JSON.stringify({ simulated: true })
});
// Simulación controlable:
// - forzar_fallo true: fallo
// - si no, 90% éxito
const ok = forzar_fallo === true ? false : Math.random() >= 0.1;
const final = marcarPago({
pago_id: pago.pago_id,
estado: ok ? "completado" : "fallido",
metadata: JSON.stringify({
simulated: true,
forced: forzar_fallo === true,
timestamp: new Date().toISOString()
})
});
if (!ok) return res.status(402).json(final);
res.status(201).json(final);
});
app.get("/pagos/:pago_id", (req, res) => {
const p = obtenerPago(req.params.pago_id);
if (!p) return res.status(404).json({ error: "pago no encontrado" });
res.json(p);
});
/*
Reembolso como compensación:
- Solo permitimos reembolsar pagos completados.
- Si el pago ya está reembolsado, devolvemos ok para permitir idempotencia simple.
*/
app.post("/pagos/:pago_id/reembolsar", (req, res) => {
const p = obtenerPago(req.params.pago_id);
if (!p) return res.status(404).json({ error: "pago no encontrado" });
if (p.estado === "reembolsado") {
return res.json({ ok: true, estado: "reembolsado", pago: p });
}
if (p.estado !== "completado") {
return res.status(409).json({ error: "solo se reembolsa si estado es completado", pago: p });
}
const out = marcarPago({
pago_id: p.pago_id,
estado: "reembolsado",
metadata: JSON.stringify({
...safeParse(p.metadata),
refund: { at: new Date().toISOString() }
})
});
res.json({ ok: true, pago: out });
});
function safeParse(s) {
try {
return s ? JSON.parse(s) : {};
} catch {
return {};
}
}
app.listen(PORT, () => {
console.log(`Servicio PAGOS en http://localhost:${PORT}`);
});
servicio-pagos/http-client.http
### Health pagos
GET http://localhost:3003/health
### Procesar pago OK
POST http://localhost:3003/pagos/procesar
Content-Type: application/json
{
"reserva_id": "RES-DEMO",
"monto": 190.00,
"moneda": "EUR",
"metodo": "tarjeta",
"forzar_fallo": false
}
### Procesar pago forzando fallo
POST http://localhost:3003/pagos/procesar
Content-Type: application/json
{
"reserva_id": "RES-DEMO",
"monto": 190.00,
"moneda": "EUR",
"metodo": "tarjeta",
"forzar_fallo": true
}
Servicio Notificaciones
servicio-notificaciones/src/server.mjs
import express from "express";
/*
SERVICIO: Notificaciones
RESPONSABILIDAD ÚNICA:
- Simular el envío de confirmaciones.
- No debe bloquear la reserva si falla.
DIDÁCTICA:
- Permitimos simular fallo para demostrar "no crítico".
*/
const app = express();
const PORT = 3004;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
service: "notificaciones",
status: "healthy",
port: PORT,
endpoints: ["POST /notificaciones/enviar"]
});
});
app.post("/notificaciones/enviar", (req, res) => {
const { destinatario, tipo, datos, simular_fallo } = req.body;
if (simular_fallo === true) {
return res.status(503).json({ error: "notificaciones no disponible (simulado)" });
}
if (typeof destinatario !== "string" || destinatario.trim().length < 3) {
return res.status(400).json({ error: "destinatario inválido" });
}
if (typeof tipo !== "string" || tipo.trim().length < 2) {
return res.status(400).json({ error: "tipo inválido" });
}
console.log("NOTIFICACION", {
to: destinatario,
tipo,
datos: datos ?? null,
at: new Date().toISOString()
});
res.json({ ok: true });
});
app.listen(PORT, () => {
console.log(`Servicio NOTIFICACIONES en http://localhost:${PORT}`);
});
servicio-notificaciones/http-client.http
### Health notificaciones
GET http://localhost:3004/health
### Enviar notificación ok
POST http://localhost:3004/notificaciones/enviar
Content-Type: application/json
{
"destinatario": "cliente@demo.local",
"tipo": "confirmacion",
"datos": { "reserva_id": "RES-123" },
"simular_fallo": false
}
API Gateway
api-gateway/src/server.mjs
import express from "express";
/*
API GATEWAY
RESPONSABILIDAD:
- Punto de entrada único para el frontend
- Health agregado
- Orquestar la SAGA de "reserva completa"
DECISIÓN DIDÁCTICA:
- Usamos fetch nativo (Node 18+), sin axios.
- Mantenemos el código explícito y lineal para aprender.
*/
const app = express();
const PORT = 3000;
app.use(express.json());
const URLS = {
habitaciones: "http://localhost:3001",
reservas: "http://localhost:3002",
pagos: "http://localhost:3003",
notificaciones: "http://localhost:3004"
};
// CORS simple para permitir frontend en Live Server
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") return res.sendStatus(200);
next();
});
app.get("/health", async (req, res) => {
const servicios = {};
for (const [name, base] of Object.entries(URLS)) {
try {
const r = await fetch(`${base}/health`, { method: "GET" });
servicios[name] = { status: r.ok ? "healthy" : "unhealthy" };
} catch (err) {
servicios[name] = { status: "unhealthy", error: String(err) };
}
}
const overall = Object.values(servicios).every((s) => s.status === "healthy")
? "healthy"
: "degraded";
res.status(overall === "healthy" ? 200 : 503).json({
system: "hotel-distribuido-limpio",
architecture: "distributed",
overall,
timestamp: new Date().toISOString(),
servicios
});
});
// Proxy lógico simple: el frontend solo llama al gateway
app.get("/habitaciones/disponibles", async (req, res) => {
const qs = new URLSearchParams(req.query).toString();
try {
const r = await fetch(`${URLS.habitaciones}/habitaciones/disponibles?${qs}`);
const data = await r.json();
res.status(r.status).json(data);
} catch (err) {
res.status(503).json({ error: "habitaciones no disponible", detalle: String(err) });
}
});
/*
Endpoint principal del laboratorio:
POST /reservas/completar
Body:
{
habitacion_id, fecha_entrada, fecha_salida, huespedes,
huesped_nombre, huesped_email,
metodo_pago,
simular_pago_fallo: false,
simular_notif_fallo: false
}
Regla didáctica:
- El monto_total lo calculamos leyendo precio_noche del servicio habitaciones
y multiplicando por noches.
- Eso refuerza la idea de que el gateway compone y orquesta.
Pasos SAGA:
1) verificar disponibilidad
2) crear reserva pendiente
3) procesar pago
4) bloquear habitación
5) confirmar reserva
6) notificar best-effort
Compensaciones:
- pago fallido => cancelar reserva
- bloqueo fallido tras pago ok => reembolsar y cancelar
*/
app.post("/reservas/completar", async (req, res) => {
const b = req.body;
const habitacionId = Number(b.habitacion_id);
const huespedes = Number(b.huespedes);
if (!Number.isInteger(habitacionId) || habitacionId <= 0) {
return res.status(400).json({ error: "habitacion_id inválido" });
}
if (!b.fecha_entrada || !b.fecha_salida) {
return res.status(400).json({ error: "fecha_entrada y fecha_salida son requeridos" });
}
if (!Number.isInteger(huespedes) || huespedes <= 0) {
return res.status(400).json({ error: "huespedes inválido" });
}
if (typeof b.huesped_nombre !== "string" || b.huesped_nombre.trim().length < 2) {
return res.status(400).json({ error: "huesped_nombre inválido" });
}
if (typeof b.huesped_email !== "string" || b.huesped_email.trim().length < 3) {
return res.status(400).json({ error: "huesped_email inválido" });
}
if (typeof b.metodo_pago !== "string" || b.metodo_pago.trim().length < 2) {
return res.status(400).json({ error: "metodo_pago inválido" });
}
const trace = [];
let reservaId = null;
let pagoId = null;
try {
trace.push({ step: 1, name: "VERIFICAR_DISPONIBILIDAD" });
const ver = await fetch(`${URLS.habitaciones}/habitaciones/verificar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
habitacion_id: habitacionId,
fecha_entrada: b.fecha_entrada,
fecha_salida: b.fecha_salida
})
});
const verData = await ver.json();
if (!ver.ok) {
return res.status(ver.status).json({ error: "error verificando disponibilidad", detalle: verData, trace });
}
if (verData.disponible !== true) {
return res.status(409).json({ error: "habitación no disponible", trace });
}
trace.push({ step: 2, name: "CALCULAR_MONTO_TOTAL" });
// Para mantenerlo simple:
// - Pedimos lista de habitaciones y buscamos la que coincide.
// - En una versión más estricta tendrías GET /habitaciones/:id.
const lista = await fetch(`${URLS.habitaciones}/habitaciones`);
const habitaciones = await lista.json();
const hab = Array.isArray(habitaciones) ? habitaciones.find((x) => x.id === habitacionId) : null;
if (!hab) return res.status(404).json({ error: "habitación no encontrada", trace });
const noches = calcularNoches(b.fecha_entrada, b.fecha_salida);
if (noches <= 0) return res.status(400).json({ error: "rango de fechas inválido", trace });
const montoTotal = Math.round((Number(hab.precio_noche) * noches) * 100) / 100;
trace.push({ step: 3, name: "CREAR_RESERVA_PENDIENTE" });
const crearReserva = await fetch(`${URLS.reservas}/reservas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
habitacion_id: habitacionId,
fecha_entrada: b.fecha_entrada,
fecha_salida: b.fecha_salida,
huespedes,
huesped_nombre: b.huesped_nombre,
huesped_email: b.huesped_email,
monto_total: montoTotal,
moneda: "EUR"
})
});
const reserva = await crearReserva.json();
if (!crearReserva.ok) {
return res.status(crearReserva.status).json({ error: "no se pudo crear reserva", detalle: reserva, trace });
}
reservaId = reserva.reserva_id;
trace.push({ step: 4, name: "PROCESAR_PAGO" });
const pagoResp = await fetch(`${URLS.pagos}/pagos/procesar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reserva_id: reservaId,
monto: montoTotal,
moneda: "EUR",
metodo: b.metodo_pago,
forzar_fallo: b.simular_pago_fallo === true
})
});
const pago = await pagoResp.json();
pagoId = pago.pago_id;
// Guardamos pago_id en reservas para trazabilidad, incluso si falla.
await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/pago`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pago_id: pagoId })
});
if (!pagoResp.ok) {
trace.push({ step: 4.1, name: "COMPENSAR_CANCELAR_RESERVA_POR_PAGO_FALLIDO" });
await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/cancelar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "PAGO_FALLIDO" })
});
return res.status(402).json({
ok: false,
error: "pago rechazado",
reserva_id: reservaId,
pago_id: pagoId,
trace
});
}
trace.push({ step: 5, name: "BLOQUEAR_HABITACION" });
const bloquear = await fetch(`${URLS.habitaciones}/habitaciones/bloquear`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
habitacion_id: habitacionId,
fecha_entrada: b.fecha_entrada,
fecha_salida: b.fecha_salida,
reserva_id: reservaId
})
});
const bloquearData = await bloquear.json();
if (!bloquear.ok) {
trace.push({ step: 5.1, name: "COMPENSAR_REEMBOLSAR_PAGO" });
await fetch(`${URLS.pagos}/pagos/${encodeURIComponent(pagoId)}/reembolsar`, {
method: "POST"
});
trace.push({ step: 5.2, name: "COMPENSAR_CANCELAR_RESERVA_POR_BLOQUEO_FALLIDO" });
await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/cancelar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "BLOQUEO_HABITACION_FALLIDO" })
});
return res.status(409).json({
ok: false,
error: "no se pudo bloquear habitación tras pago",
reserva_id: reservaId,
pago_id: pagoId,
detalle: bloquearData,
trace
});
}
trace.push({ step: 6, name: "CONFIRMAR_RESERVA" });
await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/confirmar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pago_id: pagoId })
});
trace.push({ step: 7, name: "NOTIFICAR_BEST_EFFORT" });
// No bloqueamos respuesta del cliente por notificaciones.
fetch(`${URLS.notificaciones}/notificaciones/enviar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
destinatario: b.huesped_email,
tipo: "confirmacion_reserva",
datos: { reserva_id: reservaId, habitacion_id: habitacionId, monto_total: montoTotal },
simular_fallo: b.simular_notif_fallo === true
})
}).catch(() => {});
res.status(201).json({
ok: true,
reserva_id: reservaId,
pago_id: pagoId,
monto_total: montoTotal,
noches,
estado: "confirmada",
trace
});
} catch (err) {
// Si algo explota inesperadamente, intentamos compensar lo que sepamos.
// Esta parte enseña que el error handling distribuido no es perfecto.
trace.push({ step: "X", name: "ERROR_INESPERADO", detalle: String(err) });
if (reservaId && pagoId) {
try { await fetch(`${URLS.pagos}/pagos/${encodeURIComponent(pagoId)}/reembolsar`, { method: "POST" }); } catch {}
try {
await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/cancelar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "ERROR_INESPERADO_GATEWAY" })
});
} catch {}
try {
await fetch(`${URLS.habitaciones}/habitaciones/liberar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reserva_id: reservaId })
});
} catch {}
}
res.status(500).json({ ok: false, error: "error inesperado en gateway", trace });
}
});
function calcularNoches(fe1, fe2) {
// Sin librerías, con Date.
// Entrada: "YYYY-MM-DD"
const a = new Date(`${fe1}T00:00:00`);
const b = new Date(`${fe2}T00:00:00`);
const ms = b.getTime() - a.getTime();
const dias = Math.floor(ms / (1000 * 60 * 60 * 24));
return dias;
}
app.listen(PORT, () => {
console.log(`API GATEWAY en http://localhost:${PORT}`);
});
api-gateway/http-client.http
### Health del sistema
GET http://localhost:3000/health
### Buscar disponibilidad a través del gateway
GET http://localhost:3000/habitaciones/disponibles?fechaEntrada=2025-12-13&fechaSalida=2025-12-15&huespedes=2
### Completar reserva OK
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 1,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": false
}
### Completar reserva con pago fallido
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 1,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": true,
"simular_notif_fallo": false
}
### Completar reserva con notificación fallida (debe confirmar igual)
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 2,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-14",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": true
}
Frontend
No lleva comentarios extensos por tu regla. Está separado en HTML, CSS y JS.
frontend-hotel/index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hotel distribuido laboratorio</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body class="bg-light">
<main class="container py-4">
<h1 class="mb-2">Reservas de hotel</h1>
<p class="text-muted">Frontend cliente del API Gateway.</p>
<section class="card p-3 mb-3">
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="gatewayUrl">Gateway URL</label>
<input id="gatewayUrl" class="form-control" type="text" value="http://localhost:3000" />
</div>
<div class="col-md-6">
<label class="form-label" for="email">Email</label>
<input id="email" class="form-control" type="email" value="cliente@demo.local" />
</div>
</div>
<div class="mt-3 d-flex gap-2 flex-wrap">
<button id="btnHealth" class="btn btn-outline-secondary">Ver health</button>
</div>
<pre id="healthOut" class="mt-3 p-2 bg-white border rounded small"></pre>
</section>
<section class="card p-3 mb-3">
<h2 class="h5 mb-3">Buscar disponibilidad</h2>
<form id="buscarForm" class="row g-2">
<div class="col-md-4">
<label class="form-label" for="fechaEntrada">Fecha entrada</label>
<input id="fechaEntrada" class="form-control" type="date" required />
</div>
<div class="col-md-4">
<label class="form-label" for="fechaSalida">Fecha salida</label>
<input id="fechaSalida" class="form-control" type="date" required />
</div>
<div class="col-md-2">
<label class="form-label" for="huespedes">Huéspedes</label>
<input id="huespedes" class="form-control" type="number" min="1" value="2" required />
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" type="submit">Buscar</button>
</div>
</form>
<div id="resultados" class="mt-3 row g-2"></div>
</section>
<section class="card p-3">
<h2 class="h5 mb-3">Completar reserva</h2>
<form id="reservarForm" class="row g-2">
<div class="col-md-2">
<label class="form-label" for="habitacionId">Habitación ID</label>
<input id="habitacionId" class="form-control" type="number" min="1" required />
</div>
<div class="col-md-4">
<label class="form-label" for="nombre">Nombre</label>
<input id="nombre" class="form-control" type="text" required />
</div>
<div class="col-md-3">
<label class="form-label" for="metodoPago">Método pago</label>
<select id="metodoPago" class="form-control">
<option value="tarjeta">Tarjeta</option>
<option value="paypal">PayPal</option>
<option value="transferencia">Transferencia</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<button class="btn btn-success w-100" type="submit">Reservar</button>
</div>
<div class="col-12">
<div class="form-check">
<input id="simPagoFallo" class="form-check-input" type="checkbox" />
<label class="form-check-label" for="simPagoFallo">Simular pago fallido</label>
</div>
<div class="form-check">
<input id="simNotifFallo" class="form-check-input" type="checkbox" />
<label class="form-check-label" for="simNotifFallo">Simular notificación fallida</label>
</div>
</div>
<div class="col-12">
<pre id="reservaOut" class="p-2 bg-white border rounded small"></pre>
</div>
</form>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
frontend-hotel/styles.css
#healthOut,
#reservaOut {
min-height: 90px;
white-space: pre-wrap;
word-break: break-word;
}
.card-room {
cursor: pointer;
}
frontend-hotel/app.js
const $ = (s) => document.querySelector(s);
const gatewayUrl = $("#gatewayUrl");
const email = $("#email");
const btnHealth = $("#btnHealth");
const healthOut = $("#healthOut");
const buscarForm = $("#buscarForm");
const fechaEntrada = $("#fechaEntrada");
const fechaSalida = $("#fechaSalida");
const huespedes = $("#huespedes");
const resultados = $("#resultados");
const reservarForm = $("#reservarForm");
const habitacionId = $("#habitacionId");
const nombre = $("#nombre");
const metodoPago = $("#metodoPago");
const simPagoFallo = $("#simPagoFallo");
const simNotifFallo = $("#simNotifFallo");
const reservaOut = $("#reservaOut");
function base() {
return gatewayUrl.value.trim().replace(/\/$/, "");
}
function pretty(x) {
return JSON.stringify(x, null, 2);
}
btnHealth.addEventListener("click", async () => {
healthOut.textContent = "Cargando...";
try {
const r = await fetch(`${base()}/health`);
const j = await r.json();
healthOut.textContent = pretty(j);
} catch (e) {
healthOut.textContent = pretty({ error: String(e) });
}
});
buscarForm.addEventListener("submit", async (e) => {
e.preventDefault();
resultados.innerHTML = "";
try {
const qs = new URLSearchParams({
fechaEntrada: fechaEntrada.value,
fechaSalida: fechaSalida.value,
huespedes: huespedes.value
}).toString();
const r = await fetch(`${base()}/habitaciones/disponibles?${qs}`);
const j = await r.json();
if (!r.ok) {
resultados.innerHTML = `<div class="col-12"><div class="alert alert-danger">${escapeHtml(j.error ?? "Error")}</div></div>`;
return;
}
if (!Array.isArray(j) || j.length === 0) {
resultados.innerHTML = `<div class="col-12"><div class="alert alert-info">No hay disponibles</div></div>`;
return;
}
for (const h of j) {
const col = document.createElement("div");
col.className = "col-12 col-md-6 col-lg-4";
col.innerHTML = `
<div class="card p-3 card-room">
<div class="d-flex justify-content-between">
<div>
<div class="fw-semibold">${escapeHtml(h.tipo)} ${escapeHtml(h.numero)}</div>
<div class="small text-muted">ID: ${h.id} Capacidad: ${h.capacidad}</div>
</div>
<div class="fw-semibold">${Number(h.precio_noche).toFixed(2)} €</div>
</div>
<div class="small text-muted mt-2">${escapeHtml(h.descripcion ?? "")}</div>
<div class="mt-3">
<button class="btn btn-outline-primary btn-sm" data-id="${h.id}">Usar</button>
</div>
</div>
`;
col.querySelector("button").addEventListener("click", () => {
habitacionId.value = String(h.id);
nombre.focus();
});
resultados.appendChild(col);
}
} catch (e2) {
resultados.innerHTML = `<div class="col-12"><div class="alert alert-danger">${escapeHtml(String(e2))}</div></div>`;
}
});
reservarForm.addEventListener("submit", async (e) => {
e.preventDefault();
reservaOut.textContent = "Procesando...";
const body = {
habitacion_id: Number(habitacionId.value),
fecha_entrada: fechaEntrada.value,
fecha_salida: fechaSalida.value,
huespedes: Number(huespedes.value),
huesped_nombre: nombre.value,
huesped_email: email.value,
metodo_pago: metodoPago.value,
simular_pago_fallo: simPagoFallo.checked,
simular_notif_fallo: simNotifFallo.checked
};
try {
const r = await fetch(`${base()}/reservas/completar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const j = await r.json();
reservaOut.textContent = pretty({ http_status: r.status, payload: j });
} catch (e3) {
reservaOut.textContent = pretty({ error: String(e3) });
}
});
function escapeHtml(str) {
return String(str)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
Cómo ejecutar todo desde VSCode sin scripts
Abre 5 terminales en VSCode.
- Servicio Habitaciones
cd .\servicio-habitaciones
node .\src\server.mjs
- Servicio Reservas
cd ..\servicio-reservas
node .\src\server.mjs
- Servicio Pagos
cd ..\servicio-pagos
node .\src\server.mjs
- Servicio Notificaciones
cd ..\servicio-notificaciones
node .\src\server.mjs
- API Gateway
cd ..\api-gateway
node .\src\server.mjs
Para probar con REST Client
- Abre api-gateway/http-client.http y ejecuta health y completar reserva
- Si quieres inspeccionar datos de un servicio, usa su http-client.http correspondiente
Para abrir el frontend
- Abre frontend-hotel/index.html
- Usa Live Server en VSCode
- Pulsa Ver health, luego busca disponibilidad, y completa una reserva
Laboratorio guiado de fallos parciales y trazabilidad
Ahora que tienes la versión limpia funcionando, lo que falta para completar el aprendizaje no es más “features”, sino practicar de forma controlada:
- Qué pasa cuando un servicio crítico falla
- Qué pasa cuando un servicio no crítico falla
- Cómo se ve el flujo real de la SAGA paso a paso
- Qué datos quedan persistidos en cada base de datos tras cada escenario
Para esto vamos a añadir dos mejoras pequeñas pero muy didácticas, y luego te dejo un laboratorio de pruebas con REST Client.
Mejora 1: Endpoint GET /habitaciones/:id
En el gateway, en la versión anterior, calculábamos el monto consultando la lista completa de habitaciones. Funciona, pero didácticamente es mejor tener un endpoint de detalle para reforzar:
- Contratos claros
- Menos acoplamiento
- Menos datos moviéndose por la red
servicio-habitaciones/src/server.mjs
Añade este endpoint justo debajo de GET /habitaciones.
app.get("/habitaciones/:id", (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "id debe ser entero positivo" });
}
const h = obtenerHabitacionPorId(id);
// El servicio de habitaciones es el dueño de este catálogo.
// Si no existe, el gateway debe parar la SAGA pronto.
if (!h || h.activa !== 1) {
return res.status(404).json({ error: "habitación no encontrada" });
}
// Devolvemos datos necesarios para el flujo.
res.json({
id: h.id,
numero: h.numero,
tipo: h.tipo,
descripcion: h.descripcion,
capacidad: h.capacidad,
precio_noche: h.precio_noche
});
});
Y asegúrate de que en el import del server está incluido obtenerHabitacionPorId, ya lo estaba en el código anterior.
servicio-habitaciones/http-client.http
Añade esta petición:
### Detalle habitación
GET http://localhost:3001/habitaciones/1
Mejora 2: Guardar eventos de orquestación en Reservas
Ya guardamos eventos en el servicio Reservas, pero en la SAGA del gateway solo registramos trace en memoria y lo devolvemos al cliente. Para aprendizaje, es mejor persistir también el “paso” en Reservas, porque entonces puedes:
- Ver el histórico incluso si el frontend se cierra
- Comparar estado final con los eventos
- Entender el concepto de trazabilidad distribuida sin herramientas extra
Vamos a exponer un endpoint interno de eventos “append-only” en Reservas y usarlo desde el gateway.
servicio-reservas/src/server.mjs
Añade este endpoint nuevo:
app.post("/reservas/:reserva_id/eventos", (req, res) => {
const { evento, detalle } = req.body;
if (typeof evento !== "string" || evento.trim().length < 2) {
return res.status(400).json({ error: "evento inválido" });
}
const r = obtenerReservaPorReservaId(req.params.reserva_id);
if (!r) return res.status(404).json({ error: "reserva no encontrada" });
// Append-only: no reescribe la historia, solo la amplía.
// Esto enseña el valor de "event log" en procesos distribuidos.
registrarEvento(req.params.reserva_id, evento.trim(), detalle ? String(detalle) : null);
res.json({ ok: true });
});
Para que compile, arriba debes importar registrarEvento desde db.mjs. Ajusta el import así:
import {
crearReservaPendiente,
confirmarReserva,
cancelarReserva,
setPagoId,
obtenerReservaPorReservaId,
listarReservasPorEmail,
eventosDeReserva,
registrarEvento
} from "./db.mjs";
servicio-reservas/http-client.http
Añade:
### Añadir evento manual
POST http://localhost:3002/reservas/RES-DEMO/eventos
Content-Type: application/json
{
"evento": "EVENTO_MANUAL",
"detalle": "solo prueba"
}
Actualización del gateway para usar GET /habitaciones/:id y persistir pasos
api-gateway/src/server.mjs
En el bloque donde calculabas el monto total, reemplaza la parte de “lista de habitaciones” por esto:
trace.push({ step: 2, name: "CALCULAR_MONTO_TOTAL" });
// Pedimos el detalle de la habitación.
// Esto es más realista que traer todo el catálogo.
const habResp = await fetch(`${URLS.habitaciones}/habitaciones/${habitacionId}`);
const hab = await habResp.json();
if (!habResp.ok) {
return res.status(habResp.status).json({
error: "habitación no encontrada o servicio no disponible",
detalle: hab,
trace
});
}
const noches = calcularNoches(b.fecha_entrada, b.fecha_salida);
if (noches <= 0) return res.status(400).json({ error: "rango de fechas inválido", trace });
const montoTotal = Math.round((Number(hab.precio_noche) * noches) * 100) / 100;
Y ahora, justo después de crear la reserva pendiente, empieza a persistir eventos del gateway en Reservas. Añade esta función helper dentro del archivo del gateway:
async function appendEventoReserva(reservaId, evento, detalle = null) {
// Best-effort: si el log falla, el flujo no debe romperse.
// Lo importante del laboratorio es que el “core” funcione.
try {
await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/eventos`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ evento, detalle })
});
} catch {}
}
Luego úsala en puntos clave, por ejemplo:
- Tras crear reserva pendiente:
await appendEventoReserva(reservaId, "GATEWAY_SAGA_INICIADA", null);
- Antes y después de procesar pago:
await appendEventoReserva(reservaId, "GATEWAY_PASO_PAGO_INICIO", null);
// ... tras respuesta
await appendEventoReserva(reservaId, "GATEWAY_PASO_PAGO_RESULTADO", pagoResp.ok ? "COMPLETADO" : "FALLIDO");
- Antes de bloquear habitación:
await appendEventoReserva(reservaId, "GATEWAY_BLOQUEO_HABITACION_INICIO", null);
- Si bloqueo falla y compensas:
await appendEventoReserva(reservaId, "GATEWAY_COMPENSACION_REEMBOLSO", pagoId);
await appendEventoReserva(reservaId, "GATEWAY_COMPENSACION_CANCELACION", "BLOQUEO_HABITACION_FALLIDO");
- Al confirmar:
await appendEventoReserva(reservaId, "GATEWAY_RESERVA_CONFIRMADA", null);
- Al disparar notificación:
await appendEventoReserva(reservaId, "GATEWAY_NOTIFICACION_DISPARADA", b.simular_notif_fallo === true ? "SIMULADA_FALLA" : "SIMULADA_OK");
Con esto, el endpoint GET /reservas/:reserva_id/eventos se convierte en tu “traza persistida”.
Archivo de pruebas del laboratorio
Crea un archivo nuevo en la raíz del proyecto:
laboratorio.http
### 1) Health agregado del sistema
GET http://localhost:3000/health
### 2) Buscar habitaciones disponibles
GET http://localhost:3000/habitaciones/disponibles?fechaEntrada=2025-12-13&fechaSalida=2025-12-15&huespedes=2
### 3) Completar reserva OK
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 1,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": false
}
### 4) Copia el reserva_id del paso 3 y pégalo aquí para ver eventos
GET http://localhost:3002/reservas/RES-PEGA-AQUI/eventos
### 5) Pago fallido: debe cancelar reserva
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 2,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": true,
"simular_notif_fallo": false
}
### 6) Notificación fallida: reserva debe confirmarse igual
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 3,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-14",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": true
}
### 7) Conflicto real: reserva la misma habitación y mismas fechas dos veces
### 7.1 Primera (debería OK)
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 4,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": false
}
### 7.2 Segunda (debería 409 por no disponible)
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 4,
"fecha_entrada": "2025-12-13",
"fecha_salida": "2025-12-15",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": false
}
### 8) Consultar reservas por email
GET http://localhost:3002/reservas?email=cliente@demo.local
Qué debes observar en cada escenario
- Reserva OK
- En Reservas: estado confirmada
- En Habitaciones: calendario bloqueado con reserva_id
- En Pagos: pago completado
- En Eventos: secuencia completa de pasos
- Pago fallido
- En Reservas: estado cancelada, reason PAGO_FALLIDO
- En Habitaciones: sin bloqueos para ese reserva_id
- En Pagos: pago fallido
- En Eventos: debe verse el paso de compensación
- Notificaciones fallidas
- En Reservas: confirmada
- En Habitaciones: bloqueada
- En Pagos: completado
- Notificaciones: error 503, pero el flujo no revierte
- Conflicto de doble reserva
- La segunda debe fallar por disponibilidad
- Importante: en este diseño, la verificación previa y el bloqueo real están separados
- Aun así, el bloqueo en Habitaciones es la autoridad final y evita dobles reservas
Cómo inspeccionar rápidamente las bases SQLite
Los .db están en:
- servicio-habitaciones/data/habitaciones.db
- servicio-reservas/data/reservas.db
- servicio-pagos/data/pagos.db
Si usas DB Browser for SQLite:
- En Habitaciones mira tabla calendario y filtra por reserva_id
- En Reservas mira reservas y reserva_eventos
- En Pagos mira pagos
Reto opcional para cerrar
Implementa un endpoint de cancelación en el gateway:
- POST /reservas/:reserva_id/cancelar
- Pide la reserva en Reservas
- Si está confirmada y tiene pago_id, reembolsa el pago
- Libera fechas en Habitaciones por reserva_id
- Cancela reserva en Reservas con reason CANCELACION_USUARIO
- Notifica best-effort
Este reto consolida perfectamente el concepto de compensación y orquestación inversa.
Endpoint de cancelación en el API Gateway
Vamos a implementar el cierre natural del laboratorio: una cancelación orquestada que demuestra compensaciones, consistencia práctica e idempotencia básica.
Qué va a hacer este endpoint
Ruta:
- POST /reservas/:reserva_id/cancelar
Flujo:
- Leer la reserva en el servicio Reservas
- Si ya está cancelada, devolver ok sin hacer nada crítico
- Si tiene pago_id y está confirmada, intentar reembolso en Pagos
- Liberar fechas en Habitaciones usando reserva_id
- Cancelar la reserva en Reservas con reason CANCELACION_USUARIO
- Disparar notificación best-effort
Notas didácticas:
- El gateway intenta compensar aunque algún paso falle
- La lógica principal no depende de notificaciones
- Reembolso e idempotencia: el servicio de pagos ya devuelve ok si está reembolsado
Cambios de código
api-gateway/src/server.mjs
Añade este endpoint debajo del POST /reservas/completar, en el mismo archivo.
app.post("/reservas/:reserva_id/cancelar", async (req, res) => {
/*
CANCELACIÓN ORQUESTADA (SAGA INVERSA)
Este endpoint sirve para enseñar:
- Que el gateway coordina pasos entre servicios
- Que la cancelación es, en realidad, una compensación global
- Que algunos pasos son críticos (liberar inventario, cancelar estado)
- Que otros pasos son best-effort (notificar)
Objetivo de consistencia práctica:
- La reserva debe terminar en estado "cancelada"
- La habitación debe quedar libre (si estaba bloqueada)
- Si hubo pago completado, intentamos reembolso
Idempotencia básica:
- Si ya está cancelada, devolvemos ok sin repetir todo
- Si el pago ya fue reembolsado, el servicio Pagos devuelve ok
- Liberar fechas por reserva_id es seguro (si no hay filas, changes será 0)
*/
const reservaId = String(req.params.reserva_id || "").trim();
if (!reservaId) return res.status(400).json({ error: "reserva_id requerido" });
const trace = [];
const reason = req.body?.reason ? String(req.body.reason) : "CANCELACION_USUARIO";
try {
trace.push({ step: 1, name: "LEER_RESERVA" });
const r1 = await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}`);
const reserva = await safeJson(r1);
if (!r1.ok) {
return res.status(r1.status).json({
ok: false,
error: "reserva no encontrada o servicio reservas no disponible",
detalle: reserva,
trace
});
}
// Persistimos traza en reservas si implementaste appendEventoReserva
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_CANCELACION_INICIADA", null);
}
// Si ya está cancelada, devolvemos ok e informamos.
if (reserva.estado === "cancelada") {
trace.push({ step: 2, name: "IDEMPOTENCIA_YA_CANCELADA" });
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_CANCELACION_IDEMPOTENTE", "YA_CANCELADA");
}
return res.json({
ok: true,
reserva_id: reservaId,
estado: "cancelada",
message: "La reserva ya estaba cancelada",
trace
});
}
// Paso 2: si hay pago_id, intentamos reembolso (solo si tenía sentido reembolsar)
// Didáctica: no todos los estados requieren reembolso. Aquí simplificamos:
// - si hay pago_id, intentamos reembolso y dejamos que Pagos decida.
if (reserva.pago_id) {
trace.push({ step: 3, name: "INTENTAR_REEMBOLSO", pago_id: reserva.pago_id });
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_REEMBOLSO_INTENTO", reserva.pago_id);
}
const rReembolso = await fetch(`${URLS.pagos}/pagos/${encodeURIComponent(reserva.pago_id)}/reembolsar`, {
method: "POST"
});
const reembolsoData = await safeJson(rReembolso);
// Importante: aunque el reembolso falle, seguimos intentando liberar y cancelar.
// En un sistema real, aquí emitirías una alerta y/o reintentarías.
trace.push({
step: 3.1,
name: "RESULTADO_REEMBOLSO",
ok: rReembolso.ok,
status: rReembolso.status,
detalle: reembolsoData
});
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(
reservaId,
"GATEWAY_REEMBOLSO_RESULTADO",
rReembolso.ok ? "OK" : `FALLO_${rReembolso.status}`
);
}
} else {
trace.push({ step: 3, name: "SIN_PAGO_NO_REEMBOLSO" });
}
// Paso 3: liberar habitación por reserva_id
trace.push({ step: 4, name: "LIBERAR_HABITACION_POR_RESERVA_ID" });
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_LIBERAR_HABITACION_INICIO", null);
}
const rLiberar = await fetch(`${URLS.habitaciones}/habitaciones/liberar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reserva_id: reservaId })
});
const liberarData = await safeJson(rLiberar);
trace.push({
step: 4.1,
name: "RESULTADO_LIBERAR_HABITACION",
ok: rLiberar.ok,
status: rLiberar.status,
detalle: liberarData
});
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(
reservaId,
"GATEWAY_LIBERAR_HABITACION_RESULTADO",
rLiberar.ok ? "OK" : `FALLO_${rLiberar.status}`
);
}
// Paso 4: cancelar reserva en Reservas
trace.push({ step: 5, name: "CANCELAR_RESERVA_EN_SERVICIO_RESERVAS" });
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_CANCELAR_RESERVA_INICIO", reason);
}
const rCancelar = await fetch(`${URLS.reservas}/reservas/${encodeURIComponent(reservaId)}/cancelar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason })
});
const cancelData = await safeJson(rCancelar);
if (!rCancelar.ok) {
// Si no podemos cancelar en Reservas, la operación no está “cerrada”.
// Aun así, ya intentamos reembolso y liberar.
trace.push({ step: 5.1, name: "FALLO_CANCELAR_RESERVA", status: rCancelar.status, detalle: cancelData });
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_CANCELAR_RESERVA_FALLO", String(rCancelar.status));
}
return res.status(503).json({
ok: false,
error: "no se pudo cancelar la reserva en el servicio Reservas",
detalle: cancelData,
trace
});
}
if (typeof appendEventoReserva === "function") {
await appendEventoReserva(reservaId, "GATEWAY_CANCELAR_RESERVA_OK", reason);
}
// Paso 5: notificación best-effort
trace.push({ step: 6, name: "NOTIFICAR_BEST_EFFORT" });
fetch(`${URLS.notificaciones}/notificaciones/enviar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
destinatario: cancelData.huesped_email,
tipo: "cancelacion_reserva",
datos: { reserva_id: reservaId, reason }
})
}).catch(() => {});
return res.json({
ok: true,
reserva_id: reservaId,
estado: "cancelada",
cancel_reason: reason,
trace
});
} catch (err) {
trace.push({ step: "X", name: "ERROR_INESPERADO", detalle: String(err) });
return res.status(500).json({ ok: false, error: "error inesperado en cancelación", trace });
}
});
// Helper local para leer JSON sin reventar si no hay body JSON válido
async function safeJson(resp) {
try {
return await resp.json();
} catch {
return null;
}
}
Importante
- Este endpoint asume que ya existe URLS y CORS como en el gateway anterior
- Si añadiste la función appendEventoReserva en el gateway, se usará para traza persistida. Si no existe, el endpoint funciona igual
Pruebas con REST Client
Añade estas pruebas al archivo laboratorio.http que creaste antes.
### 9) Cancelar una reserva (pega un reserva_id confirmado)
POST http://localhost:3000/reservas/RES-PEGA-AQUI/cancelar
Content-Type: application/json
{
"reason": "CANCELACION_USUARIO"
}
### 10) Ver eventos de esa reserva
GET http://localhost:3002/reservas/RES-PEGA-AQUI/eventos
### 11) Repetir cancelación (idempotencia básica)
POST http://localhost:3000/reservas/RES-PEGA-AQUI/cancelar
Content-Type: application/json
{
"reason": "CANCELACION_USUARIO"
}
### 12) Reservar y cancelar rápido para ver liberar calendario
### 12.1 Crear reserva OK
POST http://localhost:3000/reservas/completar
Content-Type: application/json
{
"habitacion_id": 5,
"fecha_entrada": "2025-12-16",
"fecha_salida": "2025-12-18",
"huespedes": 2,
"huesped_nombre": "Cliente Demo",
"huesped_email": "cliente@demo.local",
"metodo_pago": "tarjeta",
"simular_pago_fallo": false,
"simular_notif_fallo": false
}
### 12.2 Cancela pegando el reserva_id del paso 12.1
POST http://localhost:3000/reservas/RES-PEGA-AQUI/cancelar
Content-Type: application/json
{
"reason": "CANCELACION_USUARIO"
}
Qué debes observar en bases de datos
Servicio Reservas
- Tabla reservas
- estado debe pasar a cancelada
- cancel_reason debe ser CANCELACION_USUARIO
- Tabla reserva_eventos
- Debe verse la traza del proceso de reserva y luego la traza de cancelación si implementaste appendEventoReserva
Servicio Habitaciones
- Tabla calendario
- Las filas con ese reserva_id deben volver a estado libre y reserva_id NULL
Servicio Pagos
- Tabla pagos
- Si el pago estaba completado, debe pasar a reembolsado
- Si ya estaba reembolsado, debe seguir igual
Escenarios de fallo recomendados
- Apaga servicio-notificaciones y cancela una reserva
- La cancelación debe terminar ok igualmente
- Apaga servicio-pagos y cancela una reserva confirmada con pago_id
- Debe fallar el reembolso, pero el gateway debe seguir intentando liberar y cancelar
- La respuesta te mostrará en trace el fallo del reembolso