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.


#1. Empezá modular y extraé solo con motivo

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.

Para decidir con criterio, mirá la comparativa de estilos.


#2. Un schema por módulo, no una base por módulo

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ódulo
services.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.


#3. La API pública es la única puerta

Cada módulo expone un proyecto PublicApi con una interfaz y unos pocos DTOs (records). Todo lo demás es internal:

CSHARP
// Sales.PublicApi
public interface ISalesModule
{
    Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct);
}

public sealed record OrderSummary(Guid Id, decimal Total, string Status);

// Sales (proyecto interno)
internal sealed class SalesModule(SalesDbContext db) : ISalesModule
{
    public async Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct) =>
        await db.Orders
            .Where(o => o.Id == orderId)
            .Select(o => new OrderSummary(o.Id, o.Total, o.Status.ToString()))
            .SingleOrDefaultAsync(ct);
}

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.

CSHARP
quot;Forbidden dependencies: {string.Join(", ", result.FailingTypeNames ?? [])}"); }" aria-label="Copiar código"> Copiar
[Fact]
public void SalesMustNotDependOnBillingInternals()
{
    TestResult result = Types.InAssembly(typeof(SalesModule).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny("Billing.Domain", "Billing.Infrastructure", "Billing.Features")
        .GetResult();

    Assert.True(result.IsSuccessful,
        $"Forbidden dependencies: {string.Join(", ", result.FailingTypeNames ?? [])}");
}

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.


#6. Leé a otros módulos por su API pública

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:

  1. 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);
  1. 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.


#10. Dejá el camino a servicios preparado

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:

  1. Dale su propio proyecto host dentro de la misma solución, de modo que pueda ejecutarse como proceso aparte.
  2. Reemplazá las llamadas a métodos por HTTP. La interfaz de la API pública pasa a ser un cliente HTTP.
  3. 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.


#Cuándo NO hace falta todo esto

#modulith #dotnet #ef-core #architecture #ddd