Desarrollo

Monolito modular: por qué no empecé un ERP con microservicios

Catorce módulos, un solo despliegue y una regla: un módulo puede usar los mensajes de otro, nunca sus tripas. Cómo se comunican con queries, commands y eventos en NestJS.

El backend de UVA Cloud, el ERP que construyo, tiene catorce módulos: contactos, catálogo, ventas, compras, inventario, contabilidad, bancos, firma electrónica, producción, tributario y algunos más. Se despliegan como una sola aplicación.

No es descuido ni falta de ambición. Es una decisión, y este post explica por qué y cómo se mantiene ordenado algo así.

Qué es un monolito modular

Es un solo despliegue, como un monolito de toda la vida, pero dividido por dentro en módulos con fronteras que se respetan como si fueran servicios separados.

Monolito clásico Monolito modular Microservicios
Despliegues uno uno uno por servicio
Fronteras entre módulos difusas estrictas, en el código estrictas, por la red
Llamada entre módulos cualquier función mensajes en memoria HTTP o colas
Transacción que cruza módulos fácil fácil difícil

La diferencia con el monolito clásico no está en cómo se despliega, sino en la disciplina: qué puede ver cada módulo del otro.

Por qué no empecé con microservicios

En un ERP, casi todo cruza módulos. Una factura afecta inventario, genera un asiento contable y se envía a autorizar al SRI. Con microservicios, cada una de esas flechas se vuelve una llamada por la red que puede fallar a la mitad, y mantener la consistencia entre servicios (que no quede una factura sin su asiento) se convierte en un proyecto por sí mismo.

Además, los microservicios resuelven problemas que un equipo chico no tiene: equipos grandes que necesitan desplegar sin coordinarse, o partes del sistema con cargas tan distintas que conviene escalarlas por separado. A cambio, cobran desde el primer día: más infraestructura, más despliegues, más monitoreo y errores que viajan entre servicios.

Un monolito modular da la parte buena (fronteras claras y módulos que evolucionan sin pisarse) y deja la puerta abierta: si un día un módulo necesita salir como servicio, ya tiene definido cómo se habla con él.

La regla: los mensajes sí, las tripas no

Toda la disciplina cabe en una regla:

Un módulo puede usar los mensajes de otro (sus queries, commands y eventos), nunca sus tripas (repositorios, casos de uso, entidades).

En NestJS lo implemento con @nestjs/cqrs. Cada módulo publica qué se le puede preguntar, qué se le puede pedir y qué avisa, y no exporta nada más. Hay tres formas de hablar.

Query: preguntar

El módulo de tributario necesita encontrar un proveedor por su RUC. No importa el repositorio de contactos: le pregunta.

// contactos/domain/queries/get-contacto-by-identificacion.query.ts
export class GetContactoByIdentificacionQuery extends Query<Contacto | null> {
  constructor(
    public readonly accountId: string,
    public readonly identificacion: string,
  ) {
    super();
  }
}

// tributario/domain/usecases/analizar-migracion.ts
const contacto = await this.queryBus.execute(new GetContactoByIdentificacionQuery(ctx.accountId, ruc));

Tributario no sabe si contactos usa TypeORM, una caché o un servicio externo. Solo sabe qué preguntar.

Command: pedir que haga algo

Bancos, compras y ventas generan asientos contables, pero las reglas de los asientos son de contabilidad. Así que no los arman ellos: se los piden.

// contabilidad/domain/commands/generate-diario-from-template.command.ts
/**
 * Permite a otros módulos (compras, bancos, ventas) generar diarios contables
 * sin tener que importar directamente el módulo de contabilidad.
 */
export class GenerateDiarioFromTemplateCommand extends Command<Either<DiarioTemplateError, Diario>> {
  constructor(
    public readonly accountId: string,
    public readonly templateData: DiarioTemplateData,
  ) {
    super();
  }
}

// bancos/domain/services/transferencia-diario.service.ts
const diarioE = await this.commandBus.execute(new GenerateDiarioFromTemplateCommand(ctx.accountId, templateData));

El día que cambie cómo se arma un asiento, el cambio queda dentro de contabilidad.

Evento: avisar sin saber a quién

El módulo de firma habla con el SRI. Cuando el SRI autoriza una factura, firma no actualiza la factura (eso es de ventas): publica un evento y se olvida.

// firma/domain/services/consulta-autorizacion-sri.service.ts
this.eventBus.publish(new FacturaAutorizadaEvent({ facturaId, accountId, numeroAutorizacion, fechaAutorizacion }));

Ventas lo escucha y hace lo suyo: marca la factura como autorizada y se la envía por correo al cliente.

// ventas/domain/events/handlers/factura-autorizada.handler.ts
@EventsHandler(FacturaAutorizadaEvent)
export class FacturaAutorizadaHandler implements IEventHandler<FacturaAutorizadaEvent> {
  async handle(event: FacturaAutorizadaEvent) {
    const { facturaId, accountId, numeroAutorizacion, fechaAutorizacion } = event.payload;
    const factura = await this.facturaRepo.update(accountId, facturaId, {
      estadoAutorizacion: EstadoAutorizacion.AUTORIZADA,
      numeroAutorizacion,
      fechaAutorizacion: new Date(fechaAutorizacion),
    } as Factura);
    if (factura) await this.autoenvioMail.enviarFactura(factura);
  }
}

Firma no sabe que ventas existe. Si mañana inventario o un reporte también necesitan enterarse, se agrega otro handler y firma no cambia una línea.

Ventas sí importa la clase FacturaAutorizadaEvent desde firma, y está bien: es un mensaje, un contrato público. Lo que nunca importa es el repositorio de firma.

Cómo se ve en el código

La prueba de que la regla se cumple está en lo que exporta cada módulo. Este es el de contactos:

@Module({
  imports: [TypeOrmModule.forFeature([ContactoEntity, SucursalEntity])],
  controllers: [ClientesController],
  providers: [
    { provide: ContactoRepository, useClass: ContactoRepositoryImpl },
    ContactoCrud,
    // …casos de uso y handlers de sus queries y commands
  ],
  exports: [InitializeContactos],
})
export class ContactosModule {}

Exporta una sola cosa: el inicializador que se usa al crear una empresa nueva. Todo lo demás es privado. Su API para el resto del sistema son sus queries y commands, igual que sus endpoints son la API para el frontend.

Dentro de cada módulo, la organización es la de Clean Architecture en NestJS: dominio, datos y presentación.

Lo que hay que vigilar

  • La regla no se cumple sola. Nada en TypeScript impide importar un repositorio de otro módulo. Hace falta revisión de código, y vale la pena una regla de lint que prohíba importar las carpetas internas de otros módulos.
  • Los eventos en memoria no son duraderos. Si la aplicación se cae justo después de publicar un evento, el handler no corre. Para lo que no se puede perder, como consultar al SRI si autorizó un comprobante, UVA guarda el evento en una tabla (en la misma transacción cuando hace falta) y un proceso aparte lo procesa y lo reintenta hasta que sale. Eso es el patrón outbox, y da para otro post.
  • Un solo despliegue es un solo punto de falla. Si un módulo tiene un error grave, se cae todo. Es el precio de la simplicidad, y en un ERP de este tamaño lo pago con gusto.

Cuándo sí iría a microservicios

Cuando haya equipos separados que necesiten desplegar a su ritmo, o un módulo con una carga tan distinta que convenga escalarlo aparte. Si llega ese día, el módulo ya tiene sus mensajes definidos: cambiar el bus en memoria por una cola o por HTTP es mucho más fácil que desenredar un monolito clásico.

Empezar con microservicios es pagar por adelantado una complejidad que quizá nunca necesites. Empezar con un monolito modular es dejar la puerta abierta sin pagar la entrada.


Si tu sistema es un monolito donde cada cambio rompe algo lejos, o un conjunto de microservicios que nadie quiere tocar, conversemos.