Qué es un monolito modular
Un monolito modular es una única aplicación desplegable con límites internos de módulos estrictos. Desde el exterior, se ve exactamente como un monolito: un proceso, una compilación, una base de datos, un despliegue. En su interior, está organizado en módulos que son dueños de sus propios datos y exponen una interfaz pública restringida; además, estos límites se aplican estrictamente en lugar de ser solo una intención.
Esto no es un compromiso ni un paso intermedio con el que te conformas. Es una arquitectura legítima que muchos sistemas nunca deberían abandonar. Mantiene las propiedades que facilitan la construcción de software —llamadas en el mismo proceso, una sola transacción, un único pipeline, refactorizaciones económicas— y añade la disciplina necesaria para evitar que una base de código en crecimiento se convierta en un caos.
La distinción importante es entre un único desplegable y un bloque único sin estructura. Un monolito tradicional por capas no tiene noción de propiedad: cualquier controlador puede acceder a cualquier tabla y un cambio repercute en toda la base de código. Un monolito modular establece que el módulo de facturación es lo único que entiende de facturas, y todos los demás se comunican con él a través de una puerta que el propio módulo controla.
Por qué es la opción predeterminada más sensata
La mayoría de los equipos recurren a los microservicios para resolver problemas que no tienen. Un monolito modular aborda los problemas que sí tienen —una base de código difícil de cambiar, propiedad poco clara, entregas lentas— sin añadir una red entre sus partes.
Las ventajas prácticas son sustanciales:
- Las llamadas en proceso son gratuitas. Cuando un módulo llama a otro, se trata de una llamada a una función, no de una solicitud con un timeout, una política de reintentos y un modo de fallo.
- Las transacciones son reales. Un caso de uso que afecta a un módulo se confirma (commit) de forma atómica. No hay sagas, ni compensaciones, ni ventanas de inconsistencia.
- El refactorizado es un commit. Mover un límite, renombrar un concepto o fusionar dos módulos es un trabajo ordinario, no una migración con escrituras duales y un proceso de cutover.
- Las operaciones se mantienen simples. Un build, un deploy, un conjunto de logs, un guardia de turno (on-call). Esto no es un detalle menor para un equipo de cinco personas.
- El dominio tiene tiempo de asentarse. Puedes posponer las decisiones sobre los límites hasta que entiendas el negocio, en lugar de congelar suposiciones en la infraestructura.
El coste real es que los módulos no fallan ni escalan de forma independiente, y el bug de un solo módulo puede tirar abajo toda la aplicación. Para la mayoría de los equipos y en la mayoría de las etapas de un producto, es un intercambio que vale la pena hacer.
Los módulos siguen las capacidades de negocio
Un módulo debe mapearse a una capacidad de negocio, no a una capa técnica. La facturación, el catálogo, el envío, la identidad y las notificaciones son capacidades. controllers, services, repositories y utils son capas, y agrupar por ellas produce el clásico monolito en capas donde cada cambio de funcionalidad afecta a cuatro directorios.
Un buen límite de módulo tiene las mismas propiedades que un buen límite de servicio:
- Contiene un conjunto coherente de reglas que cambian juntas.
- Oculta mucho más de lo que expone.
- Puede ser comprendido por un solo equipo sin necesidad de leer el resto del sistema.
Concretamente, cada módulo es una carpeta con su propia estructura interna:
src/modules/billing/
domain/ # entities, value objects, invariants
invoice.ts
money.ts
application/ # use cases and orchestration
billing-service.ts
order-handlers.ts
infra/ # persistence and external clients
invoice-repository.ts
stripe-client.ts
index.ts # the only public entry point
Las carpetas domain y infra son privadas. index.ts re-exporta el pequeño conjunto de tipos y servicios que el resto de la aplicación tiene permitido usar. Ese único archivo es el contrato del módulo y debe ser lo suficientemente pequeño como para leerse en un minuto.
Si alguna vez has visto la regla de dependencias de Clean Architecture, esta es la misma idea aplicada a nivel de módulo: el dominio en el centro no depende de nada externo, y la infraestructura depende del dominio, no al revés.
El núcleo compartido (shared kernel)
Algunos conceptos genuinamente no pertenecen a ningún módulo en particular. El dinero, un CustomerId, un Clock o un tipo Result se utilizan en todas partes y no pertenecen a nadie. Coloca estos elementos en un paquete shared pequeño y explícito, y mantenlo deliberadamente diminuto.
src/shared/
money.ts # value object, no dependencies
ids.ts # branded id types
clock.ts # a testable time source
Un núcleo compartido es un punto de acoplamiento, así que trata su crecimiento como una señal de advertencia. En el momento en que shared contenga un servicio, un repositorio o cualquier cosa que conozca una regla de negocio, se habrá convertido en un módulo sin dueño y todos los demás módulos dependerán de él. La regla general es que shared contiene tipos y funciones puras, nunca orquestación ni estado.
Cada módulo es dueño de sus datos
La regla que otorga su poder a los monolitos modulares es la propiedad de los datos (data ownership). Cada módulo es el único encargado de escribir en sus tablas o en su esquema, y ningún otro módulo lee esas tablas directamente. El acceso entre módulos se realiza a través de la interfaz del módulo propietario o mediante un evento.
En una sola base de datos, tienes tres formas prácticas de expresar esta propiedad:
- Esquemas separados.
billing.invoices,catalog.products,shipping.shipments. Es la señal más clara y se mapea perfectamente hacia una futura división de la base de datos. - Prefijos de tabla.
billing_invoices,catalog_products. Más sencillo, pero con la misma intención. - Bases de datos separadas desde el inicio. Es el límite más fuerte, pero pierdes la capacidad de realizar transacciones únicas entre módulos y añade carga operativa.
La mayoría de los monolitos modulares deberían comenzar con una sola base de datos y esquemas separados. La clave es la regla de propiedad, no la separación física: solo el repositorio del módulo propietario toca sus tablas. Si el módulo de envíos necesita la dirección de un cliente, se lo solicita al módulo de clientes; no hace un SELECT de customers.
Esto es lo que hace posible la extracción posterior. Cuando un módulo ya es dueño de sus datos y se comunica a través de una interfaz, moverlo a su propio servicio es un cambio de despliegue en lugar de un rediseño.
Forzando los límites con herramientas
Los límites que son solo una convención tienden a degradarse. Bajo la presión de los plazos de entrega, importar el repositorio de otro módulo siempre es más rápido que añadir un método a su interfaz, y un atajo se convierte rápidamente en veinte. Haz que el límite sea mecánico.
Una regla de dependencia es fácil de definir y fácil de verificar: un módulo puede importar sus propios archivos y la interfaz pública de otros módulos, y nada más. Herramientas como eslint-plugin-boundaries y dependency-cruiser pueden expresar esto y hacer que la compilación falle.
{
"forbidden": [
{
"name": "no-cross-module-internals",
"from": { "path": "^src/modules/([^/]+)/" },
"to": {
"path": "^src/modules/(?!$1)([^/]+)/(?!index\\.ts).+"
}
},
{
"name": "no-cycles",
"from": {},
"to": { "circular": true }
}
]
}
Dos reglas hacen la mayor parte del trabajo: no importar los internos de otro módulo y no permitir dependencias circulares. Añade una entrada de CODEOWNERS por módulo para que las revisiones lleguen a las personas responsables del código, y trata un cambio de interfaz como un pequeño cambio de API: merece una segunda revisión.
Comunicación en proceso sin enredos
El modo de fallo de un monolito es un grafo de dependencias donde todo apunta a todo. Un monolito modular mantiene ese grafo acíclico y superficial. Existen dos formas para que un módulo utilice a otro.
Llamar a la interfaz pública. Cuando el llamador necesita una respuesta inmediata, se inyecta el servicio del otro módulo y se llama a él. Orders llama a catalog.getProduct(sku) para calcular el precio de una línea. La llamada es síncrona y ocurre en el mismo proceso, por lo que es rápida y falla mediante una excepción, no por un timeout.
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly catalog: CatalogService,
) {}
async execute(input: PlaceOrderInput): Promise<OrderId> {
const order = Order.create(input.customerId, input.lines);
for (const line of order.lines) {
const product = await this.catalog.getProduct(line.sku);
if (!product.isAvailable()) throw new OutOfStock(line.sku);
order.priceLine(line, product.priceCents);
}
await this.orders.save(order);
return order.id;
}
}
Publicar un evento. Cuando el llamador no necesita una respuesta, anuncia un hecho y otros módulos reaccionan. Orders publica order.placed; billing, analytics y notifications se suscriben. El módulo de orders no sabe que los demás existen, que es precisamente lo que elimina el acoplamiento.
Los eventos también rompen los ciclos. Si orders necesita un efecto secundario de shipping y shipping ya depende de orders, una llamada directa crearía un bucle. Un evento permite que shipping reaccione sin que orders dependa de él. Mantén el número de llamadas directas entre módulos al mínimo; si dos módulos se llaman constantemente entre sí, probablemente sean un solo módulo o su límite esté mal definido.
Una base de datos, esquemas separados
Tener una sola base de datos es una ventaja, no un compromiso. Te proporciona transacciones, claves foráneas, un único pool de conexiones y una sola historia de migraciones. Lo que sacrificas es el aislamiento físico, y lo sustituyes por la regla de propiedad.
Dentro de una misma base de datos, prefiere un esquema por módulo. Esto mantiene los nombres de las tablas limpios, hace que la propiedad sea visible en cada consulta y otorga a cada módulo un espacio de nombres para las migraciones. Cuando un módulo se extraiga posteriormente, su esquema se moverá con él.
Evita las claves foráneas entre módulos. Una clave foránea de billing.invoices a catalog.products acopla fuertemente ambos módulos a nivel de base de datos: el catálogo no puede eliminar ni reestructurar nada sin tener en cuenta la facturación, y la extracción requeriría eliminar la restricción. En su lugar, almacena el id y valídalo a través de la interfaz. La guía de PostgreSQL cubre los esquemas y las restricciones en detalle.
Las migraciones merecen la misma disciplina. Cada módulo es dueño de sus archivos de migración, y el inicio de la aplicación o un paso de migración los aplica en orden. Debido a que hay un único despliegue, puedes migrar y lanzar la versión al mismo tiempo, un lujo que los microservicios no tienen.
Transacciones y consistencia dentro de un módulo
El “superpoder” de la transacción única solo funciona cuando un caso de uso permanece dentro de un mismo módulo. Haz que ese sea el caso habitual. Un caso de uso que cargue un agregado, valide un invariante y lo guarde de nuevo debería ser una sola transacción que se confirme por completo o se revierta limpiamente.
await db.transaction(async (tx) => {
const order = await orders.getForUpdate(orderId, tx);
order.confirm();
await orders.save(order, tx);
await outbox.add(
{ type: "order.confirmed", orderId: order.id },
tx,
);
});
Existen dos patrones para mantener la consistencia entre módulos de forma fiable.
El outbox. Escribe el evento de dominio en una tabla outbox dentro de la misma transacción que el cambio de estado; posteriormente, un despachador lo publica. Esto garantiza que el evento no se pierda si el proceso falla entre el commit y la publicación, y es el mismo patrón que utilizarías después de una extracción.
Un process manager. Cuando un flujo abarca varios módulos, un pequeño coordinador puede escuchar eventos y emitir el siguiente comando, gestionando reintentos y timeouts de forma explícita. Este es el equivalente en proceso de una saga, y es mucho más sencillo que una distribuida porque el estado de la coordinación reside en una tabla normal.
No recurras a una transacción distribuida. Si un caso de uso realmente abarca varios módulos y debe ser atómico, generalmente es una señal de que esos módulos son, en realidad, un solo módulo.
El camino hacia la extracción
La razón para invertir en límites es la opcionalidad. Un monolito modular cuyos módulos se comunican a través de interfaces y eventos puede desglosarse más adelante, módulo por módulo, utilizando el enfoque de strangler fig.
- Elige el módulo con el límite más claro y la mayor presión. El escalado independiente, el ritmo de trabajo de un equipo separado o un requisito de cumplimiento son buenas razones.
- Confirma que sea dueño de sus datos. Si otros módulos todavía leen sus tablas, soluciona eso primero redirigiéndolos a través de la interfaz.
- Dale su propia base de datos y pipeline. Mueve su esquema, apunta su repositorio al nuevo almacenamiento y mantén la interfaz estable.
- Reemplaza las llamadas en proceso por llamadas de red o eventos. Quienes realizan las llamadas ya dependen de una interfaz, por lo que esto es un cambio de adaptador, no una reescritura.
- Sustituye el despachador de eventos por un broker. El patrón outbox hace que el flujo de eventos apenas cambie cuando el transporte pasa a ser Kafka o RabbitMQ.
Debido a que la costura ya existe, cada paso está acotado. Este es el argumento más sólido a favor del monolito modular: no es un destino diferente a los microservicios, sino la opción de llegar allí deliberadamente, solo para las partes que lo ameriten.
Pruebas de módulos en aislamiento
Los límites de los módulos dan sus frutos en las pruebas. Debido a que un módulo expone una interfaz pública y es dueño de sus propios datos, puedes probarlo de forma independiente sin necesidad de iniciar toda la aplicación.
- Pruebas de dominio: son puras y rápidas. Instancia el agregado, ejecuta las reglas y valida el resultado. Sin base de datos ni HTTP.
- Pruebas de interfaz de módulo: ejecutan el servicio público del módulo contra una base de datos de prueba limitada a su esquema. Verifican el contrato del cual dependen otros módulos.
- Pruebas de contrato de eventos: validan que el módulo publique los eventos que los consumidores esperan, con los campos en los que estos confían.
- Pruebas end-to-end: ponen a prueba la capa HTTP para un número reducido de flujos críticos. Mantén estas pruebas al mínimo, ya que son lentas.
La alternativa basada en capas obliga a que cada prueba significativa pase por todas las capas, razón por la cual esas suites de pruebas se vuelven lentas e inestables. Probar en el límite del módulo mantiene la mayoría de las pruebas rápidas mientras se protegen las interfaces que realmente importan.
Monolito modular frente a monolito en capas
Vale la pena ser precisos, ya que ambos son “un monolito”, pero solo uno es modular.
| Monolito en capas | Monolito modular | |
|---|---|---|
| Agrupación | Por capa técnica | Por capacidad de negocio |
| Impacto del cambio | Afecta a todas las capas | Se mantiene en un solo módulo |
| Acceso a datos | Cualquier capa accede a cualquier tabla | El módulo es dueño de sus tablas |
| Propiedad | Poco clara | Un equipo por módulo |
| Testabilidad | Predominan los tests end-to-end | Tests con alcance de módulo |
| Extracción | Requiere una reescritura | Un cambio delimitado |
El monolito en capas no es incorrecto para una aplicación pequeña; es simple y familiar. Se convierte en un problema cuando muchas personas trabajan en él, porque no hay una costura a lo largo de la cual dividir el trabajo ni una forma de razonar sobre un cambio de manera local.
El modo de fallo: una gran bola de lodo
El monolito modular falla de una manera específica: los límites se disuelven. Todo comienza con un atajo razonable que termina convirtiéndose en la norma.
Señales de alerta:
- Un módulo importa la carpeta
infrade otro módulo. - Dos módulos escriben en la misma tabla.
- Un paquete
utilsosharedcrece hasta convertirse en una segunda aplicación. - Cambiar el esquema de un módulo rompe la compilación de otro.
- El grafo de dependencias tiene un ciclo, generalmente introducido por una única importación de “solo por esta vez”.
La prevención requiere la misma disciplina de la que depende el resto de la arquitectura: reglas de lint que fallen la compilación, code owners por módulo, una interfaz pública pequeña y una cultura de revisión que trate una importación interna como un bug. Los límites son baratos de mantener y costosos de restaurar, así que hazlos cumplir desde el primer módulo.
Eventos dentro de un mismo proceso
Los eventos en proceso desacoplan los módulos sin necesidad de un broker. Un pequeño dispatcher recibe un hecho publicado y llama a los handlers suscritos, todo dentro del mismo proceso y, si así lo deseas, dentro de la misma transacción.
type DomainEvent = { type: string; occurredAt: string };
type Handler = (event: DomainEvent, tx?: Transaction) => Promise<void>;
class EventBus {
private handlers = new Map<string, Handler[]>();
on(type: string, handler: Handler) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
}
async publish(event: DomainEvent, tx?: Transaction) {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event, tx);
}
}
}
Dos advertencias. Si los handlers se ejecutan dentro de la transacción del llamador, un handler lento prolonga la transacción y cualquier fallo provoca el rollback de todo el caso de uso. Si se ejecutan después del commit, un crash entre ambos puede causar la pérdida del evento. El patrón outbox resuelve esto escribiendo el evento en una tabla outbox dentro de la misma transacción, para luego despacharlo desde allí.
await db.transaction(async (tx) => {
await orders.save(order, tx);
await tx.insert(outbox).values({
type: "order.placed",
payload: order.toEvent(),
});
});
Debido a que el evento se confirma junto con el estado, no puede perderse, y un relay puede reintentar el despacho hasta que cada suscriptor lo haya procesado. Cuando un módulo se extrae posteriormente, el relay es lo único que cambia.
Un gestor de procesos para flujos entre módulos
Cuando un caso de uso abarca varios módulos y debe reaccionar ante fallos, un process manager lo coordina de forma explícita en lugar de ocultar el flujo en una cadena de manejadores de eventos. Este escucha eventos, mantiene su propio estado y emite comandos.
class PlaceOrderProcess {
async onOrderPlaced(event: OrderPlaced) {
await this.catalog.reserve(event.orderId, event.lines);
}
async onReservationFailed(event: ReservationFailed) {
await this.orders.cancel(event.orderId, "out_of_stock");
await this.notifications.send(event.customerId, "order_cancelled");
}
async onReservationConfirmed(event: ReservationConfirmed) {
await this.payments.charge(event.orderId, event.totalCents);
}
}
El process manager es el equivalente en proceso de una saga. Hace que el camino feliz (happy path) y el camino de compensación sean visibles en un solo lugar, que es precisamente lo que la coreografía oculta. Mantén el estado del proceso en una tabla normal para que, tras un reinicio, se reanude donde se quedó.
Versionado de la interfaz de un módulo
La index.ts de un módulo es una API interna y merece el mismo cuidado que una pública. Otros módulos compilan basándose en ella, por lo que un cambio descuidado puede romper sus builds.
- Añade, no rompas. Agrega parámetros opcionales y nuevos métodos; evita cambiar una firma ya existente.
- Mantén la superficie pequeña. Cada exportación es una promesa. Si un tipo no necesita salir del módulo, no lo exportes.
- Depreca por etapas. Marca el método antiguo, migra a los llamadores en commits separados y luego elimínalo. Al tratarse de un único codebase, puedes buscar cada instancia donde se llame.
- Prueba el contrato. Las pruebas de la interfaz del módulo protegen la promesa que hiciste a los demás módulos.
Esto es más económico que versionar una API de red porque no hay un orden de despliegue que respetar, pero requiere la misma disciplina, y es lo que evita que una futura extracción se convierta en una reescritura completa.
Migraciones por módulo
Dado que un monolito modular tiene una sola base de datos, resulta tentador mantener un único conjunto gigante de archivos de migración. Eso recrea el acoplamiento que la arquitectura intenta eliminar. En su lugar, permite que cada módulo sea dueño de sus propias migraciones, limitadas a su esquema o prefijo de tabla.
migrations/
catalog/ 20260901_add_product_status.sql
orders/ 20260903_add_order_confirmed_at.sql
billing/ 20260905_add_invoice_paid_at.sql
Una migración que afecte a las tablas de otro módulo es una violación de límites disfrazada y debería fallar en la revisión. Mantener las migraciones modulares significa que la regla de propiedad se mantiene hasta llegar al esquema, y hace que mover las tablas de un módulo a su propia base de datos sea simplemente cuestión de ejecutar nuevamente una carpeta.
Cuándo volver a fusionar módulos
No todas las fronteras son correctas a la primera. Si dos módulos siempre cambian al mismo tiempo, comparten una transacción y se llaman mutuamente en ambas direcciones, en realidad son un solo módulo con una costura artificial. Fusionarlos de nuevo es una decisión legítima y saludable.
Las señales son concretas: un pull request toca rutinariamente ambos módulos, su interfaz cambia con cada funcionalidad y el grafo de dependencias tiene un ciclo que intentas evitar constantemente. Fusionar es sencillo en un monolito —mueves el código, eliminas la interfaz y actualizas los imports— y elimina un coste de coordinación que solo empeoraría con el tiempo. El objetivo es tener fronteras claras, no alcanzar un número específico de ellas.
Manteniendo las costuras saludables
Los límites se degradan silenciosamente, así que revísalos periódicamente en lugar de esperar a tener que reescribir el código. Unas cuantas fitness functions automatizadas detectan la deriva mientras aún es barato solucionarla.
- Haz que falle la CI cuando un módulo importe los internos de otro módulo.
- Haz que falle la CI cuando el grafo de dependencias contenga un ciclo.
- Haz que falle la CI cuando una migración haga referencia a una tabla que pertenece a otro módulo.
- Reporta el número de archivos y exportaciones públicas por módulo como una tendencia; un módulo que no deja de crecer es un límite que podría estar en el lugar equivocado.
- Requiere una revisión de
CODEOWNERSpara los cambios en la interfaz pública de un módulo.
Ninguna de estas medidas requiere infraestructura nueva. Son tests y reglas de lint comunes que, en conjunto, convierten el “acordamos mantener los límites” en algo que el build hace cumplir.
Mejores prácticas
- Define los módulos por capacidad de negocio, nunca por capa técnica.
- Asigna a cada módulo un único archivo
indexpúblico y mantenlo pequeño. - Haz que cada módulo sea el único encargado de escribir en sus propias tablas o esquema.
- Prohíbe las importaciones de internos entre módulos y los ciclos mediante reglas de lint en CI.
- Prefiere una llamada a una interfaz directa cuando necesites una respuesta, y un evento cuando no sea así.
- Mantén el grafo de dependencias acíclico y superficial; si dos módulos se llaman entre sí, fusiónalos o redefine sus límites.
- Utiliza una sola base de datos con un esquema por módulo y evita las claves foráneas entre módulos.
- Mantén cada caso de uso dentro de un solo módulo para que quepa en una única transacción.
- Utiliza un outbox para la consistencia entre módulos en lugar de transacciones distribuidas.
- Prueba los módulos a través de su interfaz pública, con unos pocos tests end-to-end en el borde.
- Mantén las interfaces preparadas para eventos para que un módulo pueda extraerse sin necesidad de reescribirlo.
Errores comunes
- Llamarlo monolito modular pero compartir tablas e importar internos.
- Organizar las carpetas por capas y creer que los módulos son reales.
- Ignorar las reglas de lint porque “todo el mundo conoce la convención”.
- Permitir que un paquete de utilidades compartidas se convierta en un punto de acoplamiento oculto.
- Ejecutar un flujo entre módulos como una transacción distribuida cuando los módulos deberían ser uno solo.
- Añadir dependencias circulares y parchearlas con importaciones dinámicas.
- Extraer un servicio antes de que el módulo tenga una interfaz limpia o sea dueño de sus datos.
- Colocar reglas de negocio en los controladores, impidiendo que los módulos se puedan probar de forma aislada.
- Tratar el estilo como una medida temporal y nunca hacer cumplir los límites.
- Asumir que un despliegue único significa un único dominio de falla y omitir el trabajo básico de resiliencia.
Próximos pasos
Si más adelante una presión concreta justifica una división, la guía de Microservices explica qué ganas, cuál es el coste y cómo extraer un módulo a la vez. Para organizar el código dentro de cada módulo, lee sobre Clean Architecture, y para desacoplar módulos mediante hechos en lugar de llamadas, lee sobre Event-Driven Architecture. Cuando necesites los patrones de almacenamiento que sustentan la propiedad de los módulos, la sección de PostgreSQL cubre schemas, transacciones y constraints.