Ir al contenido
wannacode

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 ciudad
class Warehouse
{
public Dictionary<string, int> Stock { get; } = [];
public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty)
{
if (Free(sku) < qty) return false;
Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Free(string sku) =>
Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);
}
// Pedidos: meten la mano directamente en la tabla del almacén
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
int left = warehouse.Stock[sku];
if (left < qty) return "sin existencias";
warehouse.Stock[sku] = left - qty;
return "aceptada";
}
}
var warehouse = new Warehouse();
warehouse.Stock["KTL-100"] = 2;
bool held = warehouse.Reserve("KTL-100", 2);
Console.WriteLine("Reserva para la tienda: " + (held ? "sí" : "no"));
var orders = new Orders(warehouse);
Console.WriteLine("Pedido en línea: " + orders.Place("KTL-100", 1));
Console.WriteLine("Libres en el almacén: " + warehouse.Free("KTL-100"));
Salida
Reserva para la tienda: sí
Pedido en línea: aceptada
Libres en el almacén: -1

La 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 API
class Warehouse
{
private readonly Dictionary<string, int> stock = [];
private readonly Dictionary<string, int> reserved = [];
public void Receive(string sku, int qty) =>
stock[sku] = stock.GetValueOrDefault(sku) + qty;
public bool Reserve(string sku, int qty)
{
if (Available(sku) < qty) return false;
reserved[sku] = reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Available(string sku) =>
stock.GetValueOrDefault(sku) - reserved.GetValueOrDefault(sku);
}
// El módulo «pedidos»: solo llama a la API del almacén
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
if (!warehouse.Reserve(sku, qty)) return "sin existencias";
return "aceptada";
}
}
var warehouse = new Warehouse();
warehouse.Receive("KTL-100", 2);
bool held = warehouse.Reserve("KTL-100", 2);
Console.WriteLine("Reserva para la tienda: " + (held ? "sí" : "no"));
var orders = new Orders(warehouse);
Console.WriteLine("Pedido en línea: " + orders.Place("KTL-100", 1));
Console.WriteLine("Libres en el almacén: " + warehouse.Available("KTL-100"));
Salida
Reserva para la tienda: sí
Pedido en línea: sin existencias
Libres en el almacén: 0

// Almacén: las existencias y las reservas de la tienda de la ciudad
class Warehouse
{
public Dictionary<string, int> Stock { get; } = [];
public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty)
{
if (Free(sku) < qty) return false;
Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Free(string sku) =>
Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);
}
// Pedidos: meten la mano directamente en la tabla del almacén
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
int left = warehouse.Stock[sku];
if (left < qty) return "sin existencias";
warehouse.Stock[sku] = left - qty;
return "aceptada";
}
}
Paso a paso, en palabras
  1. Un proceso, una base de datos. El almacén guarda las existencias y las reservas, y sus tablas están abiertas a todos.
  2. 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.
  3. Un monolito modular: los datos del almacén pasan a ser suyos. Solo queda hacia fuera la API: receive, reserve, available.
  4. 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.
  5. 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.
  6. Tres pedidos, tres mensajes: ok, silencio, no. Una llamada que no podía fallar dentro del monolito ahora falla.
  7. 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.

En C# private protege los campos, y internal esconde las clases de otros proyectos. Un módulo suele ser un proyecto aparte (.csproj) en la misma solución: lo que es internal no se ve desde fuera.

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.

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.

record Use(string From, string To, string Name);
static class Boundaries
{
public static List<string> Check(Dictionary<string, HashSet<string>> api,
Dictionary<string, HashSet<string>> allowed, List<Use> uses)
{
var problems = new List<string>();
foreach (var (from, to, name) in uses)
{
if (!allowed[from].Contains(to))
problems.Add($"✗ {from} → {to}: esta dependencia no está permitida");
else if (!api[to].Contains(name))
problems.Add($"✗ {from} → {to}.{name}: no está en la API");
}
return problems;
}
}
// Lo que cada módulo ofrece hacia fuera
var api = new Dictionary<string, HashSet<string>>
{
["orders"] = ["place"],
["warehouse"] = ["receive", "reserve", "available"],
["payments"] = ["charge"],
["invoices"] = ["issue"],
};
// Qué módulo puede depender de cuáles
var allowed = new Dictionary<string, HashSet<string>>
{
["orders"] = ["warehouse", "payments"],
["warehouse"] = [],
["payments"] = [],
["invoices"] = ["orders"],
};
// Lo que el código usa de verdad (según los imports)
List<Use> uses =
[
new Use("orders", "warehouse", "reserve"),
new Use("orders", "warehouse", "stock"),
new Use("orders", "payments", "charge"),
new Use("invoices", "orders", "rows"),
new Use("warehouse", "orders", "place"),
];
var problems = Boundaries.Check(api, allowed, uses);
problems.ForEach(Console.WriteLine);
Console.WriteLine("Infracciones: " + problems.Count);
Salida
✗ orders → warehouse.stock: no está en la API
✗ invoices → orders.rows: no está en la API
✗ warehouse → orders: esta dependencia no está permitida
Infracciones: 3

Tres 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 JOIN entre 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 IWarehouse y no saben que detrás hay una red:

interface IWarehouse
{
bool Reserve(string sku, int qty);
}
// La red es un guion grabado: "ok", "no" o "timeout"
class Network(params string[] script)
{
private readonly Queue<string> queue = new(script);
public int Sent { get; private set; }
public string Send(string message)
{
Sent++;
return queue.Dequeue();
}
}
// El almacén como servicio aparte: cada llamada es un mensaje
class RemoteWarehouse(Network network) : IWarehouse
{
public bool Reserve(string sku, int qty)
{
var answer = network.Send($"reserve {sku} {qty}");
if (answer == "timeout")
throw new TimeoutException("el almacén no responde");
return answer == "ok";
}
}
// Pedidos no sabe si el almacén es un módulo o un servicio
class Orders(IWarehouse warehouse)
{
public string Place(string sku, int qty)
{
try
{
if (!warehouse.Reserve(sku, qty)) return "sin existencias";
return "aceptada";
}
catch (TimeoutException e)
{
return "espera: " + e.Message;
}
}
}
var network = new Network("ok", "timeout", "no");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
Console.WriteLine($"Pedido {i}: {orders.Place("KTL-100", 1)}");
Console.WriteLine("Mensajes por la red: " + network.Sent);
Salida
Pedido 1: aceptada
Pedido 2: espera: el almacén no responde
Pedido 3: sin existencias
Mensajes por la red: 3

El 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?

var network = new Network("timeout", "timeout", "ok");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
Console.WriteLine($"Pedido {i}: {orders.Place("KTL-100", 1)}");
Console.WriteLine("Mensajes por la red: " + network.Sent);
Respuesta

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.

Salida
✗ orders ↔ warehouse
Infracciones: 1

Más ejercicios

  • Un ciclo por tres módulos. orders → warehouse → invoices → orders tambié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 (invoices lee orders.rows) e imprime cada tabla que usa más de un módulo.

Test

Ponte a prueba

1. ¿Qué hace que un módulo sea un módulo y no solo una carpeta?
2. Los pedidos escriben directamente en la tabla de existencias. ¿Cuál es el problema?
3. ¿Cuál es una buena razón para convertir el almacén en un servicio aparte?
4. El almacén es un servicio y no responde. ¿Cuál de estos es un problema nuevo que el monolito no tenía?
5. La comprobación de límites permite tanto orders → warehouse como warehouse → orders. ¿Por qué es malo?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·