Monolito modular en .NET: 10 reglas para que los límites no se rompan
Reglas prácticas para construir un monolito modular en .NET con EF Core. Un schema por módulo, API pública como única puerta, tests de arquitectura, transacciones, consistencia y cómo dejar listo el camino a servicios.
Un monolito modular tiene un solo deploy pero módulos con fronteras reales. La parte difícil no es dibujar los módulos: es evitar que, tres meses después, alguien los atraviese "solo por hoy". Estas son diez reglas para que no pase, con ejemplos en .NET y EF Core.
Inspirado en 20 Laws of Modular Monoliths, de Anton Martyniuk. Acá elegí diez, las reescribí con mis ejemplos y agregué los matices que me parecen importantes. Para el concepto general, mirá Modulith.
Arrancá con un solo deploy y módulos separados. Extraé un módulo a un servicio únicamente cuando necesite algo que el monolito no le da: su propio equipo, su propio calendario de releases o su propio escalado.
La razón de fondo: los límites los vas a errar la primera vez, como todos. En un monolito modular, corregir un límite es un refactor dentro de una solución. En microservicios es una migración entre servicios, contratos y equipos.
No necesitás cuatro bases de datos para cuatro módulos. Alcanza con una base y un schema por módulo, y un DbContext por módulo que solo ve sus tablas:
CSHARP
public sealed class SalesDbContext(DbContextOptions<SalesDbContext> options) : DbContext(options){ public DbSet<Order> Orders => Set<Order>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema("sales"); modelBuilder.ApplyConfigurationsFromAssembly(typeof(SalesDbContext).Assembly); }}// Registro: tabla de historial de migraciones propia del móduloservices.AddDbContext<SalesDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "sales")));
Con su propia tabla de historial, cada módulo migra de forma independiente. La regla: ningún módulo toca las tablas de otro, ni siquiera para leer.
Dejar la implementación internal hace que el compilador oculte los detalles. Y si más adelante extraés el módulo a un servicio, esta misma interfaz pasa a ser un cliente HTTP sin que cambie quien la usa.
Es el mismo principio de los puertos de Hexagonal, aplicado entre módulos.
#4. Los límites los cuida un test, no el compilador
El compilador no te protege de una referencia "solo por hoy" al Domain de otro módulo: compila perfecto. Tu agente de IA va a tomar el mismo atajo. La defensa es un test de arquitectura que falla el PR y nombra el tipo que rompió la regla.
Usa NetArchTest (o ArchUnitNET). Corrélo en CI, no solo en local.
#5. Adentro del módulo: slices, dominio rico y resultados explícitos
Un proyecto por capa o por feature, según el tamaño. Para un módulo con reglas, tres proyectos funcionan bien: Domain, Features e Infrastructure. Dentro de Features, una carpeta por caso de uso con endpoint, handler, validador y mapeo juntos (ver Vertical Slice). Un módulo CRUD simple puede vivir en un único proyecto: no hace falta ceremonia donde no hay reglas.
CSHARP
Shipping/├── Shipping.Domain/ ← entidades, value objects├── Shipping.Features/│ ├── CreateShipment/ ← endpoint + handler + validator + mapping│ └── CancelShipment/├── Shipping.Infrastructure/ ← DbContext, migraciones, clientes externos└── Shipping.PublicApi/ ← la única puerta
Dominio rico. Setters privados y métodos con significado (Dispatch(), Cancel()), de modo que los estados inválidos no puedan existir.
Resultados en vez de excepciones para fallos esperables. Devolvé Result<T> cuando un fallo es parte del flujo normal (no existe, conflicto, datos inválidos) y mapealo en el borde a 404, 409 o 400. Reservá las excepciones para lo inesperado.
Sobre el repositorio: en un módulo, usar el DbContext directamente en los handlers suele alcanzar, porque ya es repositorio y unidad de trabajo. Es un tema con matices; lo desarrollé en DbContext vs. Repository + Unit of Work.
El primer dashboard va a poner a prueba todos tus límites: envíos, transportistas y stock en una pantalla es un JOIN que no podés escribir porque los datos son de módulos distintos. Tenés dos herramientas:
Llamar a la API pública de cada módulo. Agregá métodos por lotes para evitar el problema de N+1:
CSHARP
// En lugar de llamar GetCarrierAsync(id) en un bucle: Task<IReadOnlyDictionary<Guid, CarrierInfo>> GetCarriersAsync( IReadOnlyCollection<Guid> ids, CancellationToken ct);
Copiar datos que casi no cambian (por ejemplo, el nombre de un transportista) a tu propio schema mediante eventos. Así tu módulo no depende de otro para cada lectura.
Ojo: copiar datos introduce eventual consistency y hay que mantener la copia. Usalo solo para datos estables.
#7. Los reportes: un módulo aparte con acceso de solo lectura
Si de verdad necesitás consultas que cruzan schemas (reportes, analítica), creá un módulo dedicado a reportes con un usuario de base de datos de solo lectura. Es el único lugar autorizado para hacer joins entre schemas, y como no escribe, no puede romper invariantes de nadie.
Esto mantiene el resto del sistema limpio sin renunciar a las consultas transversales.
#8. Misma base de datos no significa misma transacción
Dos DbContext sobre la misma base no comparten transacción automáticamente. Una transacción compartida necesita una sola conexión física:
CSHARP
await using var connection = new NpgsqlConnection(connectionString);await connection.OpenAsync(ct);await using var transaction = await connection.BeginTransactionAsync(ct);await using var sales = new SalesDbContext( new DbContextOptionsBuilder<SalesDbContext>().UseNpgsql(connection).Options);await using var stock = new StockDbContext( new DbContextOptionsBuilder<StockDbContext>().UseNpgsql(connection).Options);await sales.Database.UseTransactionAsync(transaction, ct);await stock.Database.UseTransactionAsync(transaction, ct);// ... cambios en ambos contextos ...await sales.SaveChangesAsync(ct);await stock.SaveChangesAsync(ct);await transaction.CommitAsync(ct);
La trampa: si le das a cada módulo su propio usuario de base de datos (una buena práctica de aislamiento), cada uno usa su propia conexión y UseTransactionAsync falla. Entonces tenés dos caminos: compartir una conexión para esa escritura puntual, o usar eventos con Outbox.
#9. Elegí la consistencia según el costo de equivocarte
No todo necesita una transacción:
Situación
Si sale mal
Qué usar
Falló la notificación al transportista
Se reintenta después; el pedido sigue en pie
Evento (eventual)
Dos clientes compran el último ítem
Dinero perdido de verdad
Una transacción (consistencia fuerte)
Enviar un email o actualizar analítica
Un retraso no duele
Evento (eventual)
La regla práctica: eventos por defecto, y una única transacción donde los errores cuestan dinero (pagos, stock, reservas). Para publicar con seguridad, guardá primero y publicá después usando Outbox, así no perdés eventos si el proceso se cae entre ambos pasos.
Si algún día un módulo tiene que salir, no empieces separando repositorios: vas a pasar semanas armando pipelines antes de que algo funcione. Hacelo en pasos:
Dale su propio proyecto host dentro de la misma solución, de modo que pueda ejecutarse como proceso aparte.
Reemplazá las llamadas a métodos por HTTP. La interfaz de la API pública pasa a ser un cliente HTTP.
Recién después, movés los eventos a un message bus.
Para que esto sea viable, dale a cada módulo su propia trazabilidad, así cada traza muestra qué módulo hizo qué:
CSHARP
internal static class SalesTelemetry{ public static readonly ActivitySource Source = new("MyApp.Sales");}using Activity? activity = SalesTelemetry.Source.StartActivity("PlaceOrder");
Registrá cada fuente con AddSource("MyApp.Sales") en OpenTelemetry.
Un equipo chico con un dominio simple: un monolito bien organizado por carpetas alcanza. Los módulos con schema propio, API pública y tests de arquitectura son una inversión que se paga cuando hay varios equipos o un dominio que crece.
Si ya sabés que vas a ser un único equipo siempre: las reglas 2, 3 y 4 igual valen la pena; la 10 probablemente no.