Monolito, monolito modular, servicios
En resumen: Un monolito modular es una aplicación con módulos claros, cada uno con una API pública y datos propios; los microservicios se separan cuando los equipos, la escala o los deploys independientes compensan el coste.
Problema
Pedidos en la tabla del almacén
La tienda de la ciudad reserva artículos para clientes que vendrán en persona. El almacén guarda las existencias y las reservas, y del hervidor KTL-100 hay 2. Los pedidos en línea los escribió otro equipo, que «para ahorrar tiempo» lee y escribe la tabla del almacén directamente:
// Almacén: las existencias y las reservas de la tienda de la ciudadclass Warehouse { stock = new Map(); reserved = new Map();
reserve(sku, qty) { if (this.free(sku) < qty) return false; this.reserved.set(sku, (this.reserved.get(sku) ?? 0) + qty); return true; }
free(sku) { return (this.stock.get(sku) ?? 0) - (this.reserved.get(sku) ?? 0); }}
// Pedidos: meten la mano directamente en la tabla del almacénclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { const left = this.warehouse.stock.get(sku); if (left < qty) return "sin existencias"; this.warehouse.stock.set(sku, left - qty); return "aceptada"; }}const warehouse = new Warehouse();warehouse.stock.set("KTL-100", 2);const held = warehouse.reserve("KTL-100", 2);console.log("Reserva para la tienda: " + (held ? "sí" : "no"));const orders = new Orders(warehouse);console.log("Pedido en línea: " + orders.place("KTL-100", 1));console.log("Libres en el almacén: " + warehouse.free("KTL-100"));Reserva para la tienda: síPedido en línea: aceptadaLibres en el almacén: -1La tienda reservó los dos hervidores, y el pedido en línea se acepta. Las existencias libres son -1: se vendió un artículo que no existe. La regla de las reservas está en el almacén, pero los pedidos no pasan por él.
El segundo dolor llega después. El equipo del almacén quiere dividir stock por almacén (Sofía, Plovdiv). No puede: no sabe quién más lee y escribe su tabla, y cada cambio rompe código ajeno.
La idea
Módulos con una API y datos propios
El código es un solo programa que se despliega de una vez: un monolito. Eso solo no es el problema. El problema es que dentro no hay límites.
Un monolito modular es un monolito dividido en módulos. Cada módulo:
- tiene una API pública: unos pocos métodos por los que los demás lo llaman (
receive,reserve,available); - tiene datos propios: las tablas y clases de dentro son suyas, y nadie más las lee ni las escribe;
- depende solo de los módulos de los que puede depender.
Esta es la organización «por función» de la lección «Capas», con una regla para los límites. En el domain-driven design un límite así se llama bounded context: la parte del sistema en la que la palabra «existencias» tiene un solo significado.
Los módulos están en un proceso y a menudo en una base de datos. Llamar a un módulo es llamar a un método: rápido, sin red y, si hace falta, en una transacción.
Míralo
El almacén como módulo
// El módulo «almacén»: los datos son suyos, hacia fuera solo está la APIclass Warehouse { #stock = new Map(); #reserved = new Map();
receive(sku, qty) { this.#stock.set(sku, (this.#stock.get(sku) ?? 0) + qty); }
reserve(sku, qty) { if (this.available(sku) < qty) return false; this.#reserved.set(sku, (this.#reserved.get(sku) ?? 0) + qty); return true; }
available(sku) { return (this.#stock.get(sku) ?? 0) - (this.#reserved.get(sku) ?? 0); }}
// El módulo «pedidos»: solo llama a la API del almacénclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { if (!this.warehouse.reserve(sku, qty)) return "sin existencias"; return "aceptada"; }}const warehouse = new Warehouse();warehouse.receive("KTL-100", 2);const held = warehouse.reserve("KTL-100", 2);console.log("Reserva para la tienda: " + (held ? "sí" : "no"));const orders = new Orders(warehouse);console.log("Pedido en línea: " + orders.place("KTL-100", 1));console.log("Libres en el almacén: " + warehouse.available("KTL-100"));Reserva para la tienda: síPedido en línea: sin existenciasLibres en el almacén: 0// Almacén: las existencias y las reservas de la tienda de la ciudadclass Warehouse { stock = new Map(); reserved = new Map();
reserve(sku, qty) { if (this.free(sku) < qty) return false; this.reserved.set(sku, (this.reserved.get(sku) ?? 0) + qty); return true; }
free(sku) { return (this.stock.get(sku) ?? 0) - (this.reserved.get(sku) ?? 0); }}
// Pedidos: meten la mano directamente en la tabla del almacénclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { const left = this.warehouse.stock.get(sku); if (left < qty) return "sin existencias"; this.warehouse.stock.set(sku, left - qty); return "aceptada"; }}// Almacén: las existencias y las reservas de la tienda de la ciudadclass Warehouse { stock = new Map(); reserved = new Map();
reserve(sku, qty) { if (this.free(sku) < qty) return false; this.reserved.set(sku, (this.reserved.get(sku) ?? 0) + qty); return true; }
free(sku) { return (this.stock.get(sku) ?? 0) - (this.reserved.get(sku) ?? 0); }}
// Pedidos: meten la mano directamente en la tabla del almacénclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { const left = this.warehouse.stock.get(sku); if (left < qty) return "sin existencias"; this.warehouse.stock.set(sku, left - qty); return "aceptada"; }}// El módulo «almacén»: los datos son suyos, hacia fuera solo está la APIclass Warehouse { #stock = new Map(); #reserved = new Map();
receive(sku, qty) { this.#stock.set(sku, (this.#stock.get(sku) ?? 0) + qty); }
reserve(sku, qty) { if (this.available(sku) < qty) return false; this.#reserved.set(sku, (this.#reserved.get(sku) ?? 0) + qty); return true; }
available(sku) { return (this.#stock.get(sku) ?? 0) - (this.#reserved.get(sku) ?? 0); }}
// El módulo «pedidos»: solo llama a la API del almacénclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { if (!this.warehouse.reserve(sku, qty)) return "sin existencias"; return "aceptada"; }}// La red es un guion grabado: "ok", "no" o "timeout"class Network { sent = 0;
constructor(...script) { this.script = script; }
send(message) { this.sent++; return this.script.shift(); }}
// El almacén como servicio aparte: cada llamada es un mensajeclass RemoteWarehouse { constructor(network) { this.network = network; }
reserve(sku, qty) { const answer = this.network.send(`reserve ${sku} ${qty}`); if (answer === "timeout") throw new Error("el almacén no responde"); return answer === "ok"; }}
// Pedidos no sabe si el almacén es un módulo o un servicioclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { try { if (!this.warehouse.reserve(sku, qty)) return "sin existencias"; return "aceptada"; } catch (e) { return "espera: " + e.message; } }}Paso a paso, en palabras
- Un proceso, una base de datos. El almacén guarda las existencias y las reservas, y sus tablas están abiertas a todos.
- La tienda reserva los dos hervidores. El pedido en línea mete la mano directamente en la tabla, no ve la reserva, y las existencias libres pasan a -1.
- Un monolito modular: los datos del almacén pasan a ser suyos. Solo queda hacia fuera la API: receive, reserve, available.
- Los pedidos pasan por reserve, y el almacén mantiene su regla: «sin existencias», las existencias libres se quedan en 0. Sigue siendo un proceso, sin red.
- El almacén sale como un servicio aparte: su propio proceso, su propio deploy, su propio equipo. Una llamada pasa a ser un mensaje por la red.
- Tres pedidos, tres mensajes: ok, silencio, no. Una llamada que no podía fallar dentro del monolito ahora falla.
- Los módulos mantienen los límites sin red. Un servicio añade un deploy independiente, y lo paga con red, datos separados y monitoreo.
El pedido pasa por reserve, y el almacén mantiene su regla: los dos hervidores están reservados, así que el pedido en línea recibe «sin existencias». Los últimos pasos de la escena muestran qué pasa cuando el almacén sale como servicio aparte. Su código está más abajo.
#stock está de verdad escondido: no se puede alcanzar desde fuera de la clase. Un módulo es un archivo o un paquete que exporta (export) solo su API.Pruébalo
Encuentra el error
El informe del almacén quiere mostrar la tabla entera, y alguien añade un método:
Elige la línea con el error.
El método devuelve la tabla misma, no una copia. Quien la recibe puede escribir en ella (stockTable().put(...)), y el límite vuelve a estar abierto, solo que mejor escondido. El módulo debería devolver una copia, o solo lo que el informe necesita de verdad, por ejemplo una lista de «producto y unidades libres».
Míralo
Comprobar los límites
Los límites no se mantienen con buenas intenciones. Aquí hay una comprobación pequeña: qué ofrece cada módulo, qué módulo puede depender de cuál y qué usa el código de verdad. En un proyecto real la última lista se lee de los import.
function check(api, allowed, uses) { const problems = []; for (const [from, to, name] of uses) { if (!allowed[from].includes(to)) problems.push(`✗ ${from} → ${to}: esta dependencia no está permitida`); else if (!api[to].includes(name)) problems.push(`✗ ${from} → ${to}.${name}: no está en la API`); } return problems;}// Lo que cada módulo ofrece hacia fueraconst api = { orders: ["place"], warehouse: ["receive", "reserve", "available"], payments: ["charge"], invoices: ["issue"],};// Qué módulo puede depender de cuálesconst allowed = { orders: ["warehouse", "payments"], warehouse: [], payments: [], invoices: ["orders"],};// Lo que el código usa de verdad (según los imports)const uses = [ ["orders", "warehouse", "reserve"], ["orders", "warehouse", "stock"], ["orders", "payments", "charge"], ["invoices", "orders", "rows"], ["warehouse", "orders", "place"],];
const problems = check(api, allowed, uses);problems.forEach((problem) => console.log(problem));console.log("Infracciones: " + problems.length);✗ orders → warehouse.stock: no está en la API✗ invoices → orders.rows: no está en la API✗ warehouse → orders: esta dependencia no está permitidaInfracciones: 3Tres infracciones: los pedidos en la tabla del almacén, las facturas en las filas de los pedidos y un almacén que llama a los pedidos (cuando los pedidos ya llaman al almacén). Una comprobación así corre en cada cambio y falla en cuanto alguien atraviesa la pared. Cómo pasa a ser parte de CI está en la lección «Revisión de arquitectura».
La idea
Cuándo un módulo se convierte en servicio
Un servicio (microservicio) es un módulo que corre por su cuenta: su propio proceso, su propio deploy, su propia base de datos. Los demás lo llaman por la red. Vale la pena cuando:
- los equipos se estorban: el almacén quiere entregar una versión nueva tres veces al día, y la tienda una vez por semana;
- la carga es distinta: la búsqueda del catálogo necesita diez copias, y las facturas necesitan una;
- hay que aislar un fallo: si las recomendaciones caen, el pago debe seguir funcionando.
Y se paga con:
- la red: una llamada puede ser lenta, no llegar o llegar dos veces;
- los datos: cada servicio tiene su propia base de datos, así que no hay
JOINentre ellos ni una transacción única. Un pedido y una reserva ya no se guardan «juntos». Cómo vivir con eso está en «Eventos y colas», «Transactional Outbox» y «Una transacción entre servicios (Saga)»; - el monitoreo: una solicitud pasa por cinco procesos, y el error se busca en cinco logs.
Los servidores, los contenedores, Kubernetes y el monitoreo como infraestructura son el tema del curso de DevOps ZeroToOps, no de esta lección.
Míralo
El almacén por la red
El almacén es un servicio aparte. En el programa la «red» es un objeto con un guion grabado: a cada mensaje responde ok, no o calla (timeout). Los pedidos solo ven la API reserve y no saben que detrás hay una red:
// La red es un guion grabado: "ok", "no" o "timeout"class Network { sent = 0;
constructor(...script) { this.script = script; }
send(message) { this.sent++; return this.script.shift(); }}
// El almacén como servicio aparte: cada llamada es un mensajeclass RemoteWarehouse { constructor(network) { this.network = network; }
reserve(sku, qty) { const answer = this.network.send(`reserve ${sku} ${qty}`); if (answer === "timeout") throw new Error("el almacén no responde"); return answer === "ok"; }}
// Pedidos no sabe si el almacén es un módulo o un servicioclass Orders { constructor(warehouse) { this.warehouse = warehouse; }
place(sku, qty) { try { if (!this.warehouse.reserve(sku, qty)) return "sin existencias"; return "aceptada"; } catch (e) { return "espera: " + e.message; } }}const network = new Network("ok", "timeout", "no");const orders = new Orders(new RemoteWarehouse(network));for (let i = 1; i <= 3; i++) console.log(`Pedido ${i}: ${orders.place("KTL-100", 1)}`);console.log("Mensajes por la red: " + network.sent);Pedido 1: aceptadaPedido 2: espera: el almacén no respondePedido 3: sin existenciasMensajes por la red: 3El segundo pedido no sabe si el artículo está reservado: el almacén puede haber recibido el mensaje y respondido, y la respuesta puede haberse perdido. En el módulo esta pregunta no existía.
Pruébalo
Predice la salida
La red calla dos veces y luego responde ok. ¿Qué imprime el programa?
const network = new Network("timeout", "timeout", "ok");const orders = new Orders(new RemoteWarehouse(network));for (let i = 1; i <= 3; i++) console.log(`Pedido ${i}: ${orders.place("KTL-100", 1)}`);console.log("Mensajes por la red: " + network.sent);Cada pedido envía su propio mensaje, así que son tres. Los dos primeros no reciben respuesta y esperan, el tercero se acepta. Cuántas veces y cuándo reintentar es un tema aparte: la lección «Cuando el vecino es lento: timeout, retry, circuit breaker».
La idea
Cuándo los servicios duelen
- Demasiado pronto. Una tienda nueva con un equipo y diez servicios paga todo el precio de la red sin necesitar deploys independientes. Los límites aún no están claros, y mover un límite entre servicios cuesta mucho más que entre módulos.
- Un monolito distribuido. Los servicios que deben desplegarse juntos y se llaman entre sí en cada solicitud tienen todos los inconvenientes de un monolito y de los servicios a la vez.
- Una base de datos compartida. Dos servicios que escriben en las mismas tablas son el problema del principio de la lección, solo que por la red.
Por eso el camino habitual es primero el monolito modular: módulos claros en un proceso y una comprobación de límites. Un módulo que de verdad necesita ser independiente sale como servicio. Eso es fácil si ya tiene una API y datos propios, como el almacén de la escena.
Más precisamente, para curiosos
Los frameworks. Un servicio es una aplicación normal: Spring Boot, ASP.NET Core, FastAPI o Django, Express o Nest. Los servicios hablan por HTTP (REST, gRPC) o a través de un message broker (Kafka, RabbitMQ). Delante suele haber un API gateway: una entrada que dirige las solicitudes a los servicios y comprueba los datos de entrada.
La ley de Conway. Los sistemas se parecen a la organización que los construye: tres equipos construyen un sistema de tres partes. Por eso los límites de los servicios suelen seguir a los equipos.
Comprobar los límites. ArchUnit (Java) y ArchUnitNET o NetArchTest (C#) escriben la comprobación como una prueba. import-linter (Python) y dependency-cruiser (JavaScript) la escriben como reglas en la configuración. Spring Modulith (Java) y Nx (JavaScript) también conocen los módulos.
Qué tamaño tiene un servicio. «Micro» no es un tamaño en líneas. Un buen límite es aquel por el que pasan pocas llamadas y tras el cual hay datos que nadie más escribe.
Ejercicio
Un ciclo entre módulos
Resuélvelo en tu editor.
Alguien ha permitido también warehouse → orders. Ahora los dos módulos dependen el uno del otro. Escribe una función que reciba el mapa «qué módulo puede depender de cuáles» y encuentre cada par de módulos que dependen entre sí. Cada par se imprime una vez, en orden alfabético.
✗ orders ↔ warehouseInfracciones: 1Más ejercicios
- Un ciclo por tres módulos.
orders → warehouse → invoices → orderstambién es un ciclo. Encuéntralo con búsqueda en profundidad (la lección «DFS: islas y ciclos» encuentra ciclos). - Las tablas. Añade a la comprobación qué módulo lee la tabla de quién (
invoicesleeorders.rows) e imprime cada tabla que usa más de un módulo.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Repository · SOLID sin dogma · Capas · Arquitectura hexagonal y clean architecture
- Continúa en: Eventos y colas · Transactional Outbox · Escalado · ADR: registros de decisiones de arquitectura
- Ejercicios sobre el tema: Arquitectura de software