Hace tiempo —mucho antes de que existiera la etiqueta “modulith”— ya hablaba en charlas y proyectos de algo que consideraba fundamental:

un monolito puede ser saludable si está dividido en módulos de verdad, no en carpetas sueltas ni DLLs interdependientes.

Lo interesante es que, tiempo después, la industria ha puesto nombre a esta idea (modulith / monolito modular), pero sigo encontrando las mismas confusiones:

  • “Si lo separo en DLLs ya es un modulith, ¿no?” No. Un modulith no es un assembly party, es arquitectura con límites fuertes.

  • “¿Si tengo módulos puedo hablar entre ellos directamente?” No. Igual que en microservicios, deben colaborar por contratos, no por acoplamiento accidental.

  • “¿Puedo exponer Domain Events por Kafka o Service Bus?” Sí, pero en cuanto lo haces pasan a ser Integration Events, no Domain Events.

  • “¿Compilará más rápido en Docker si cambio solo un módulo?” Sí, si usas BuildKit + caché de compilación. No, si usas el Dockerfile estándar.

Por eso hoy dejo aquí un ejemplo completo y muy pequeño, en .NET 8, que demuestra lo que ES y lo que NO ES un modulith… y cómo construirlo rápido con Docker sin recompilar todo cada vez.

Artículos relacionados: leer.

¿Qué es realmente un modulith?

Un modulith es:

  • Monolito con límites nítidos entre módulos (bounded contexts internos).
  • Cada módulo contiene su dominio, su lógica, sus casos de uso.
    La app principal actúa como composition root.
  • Los módulos no se acoplan entre sí: se comunican por interfaces o eventos.
  • Se despliega como un solo proceso, pero se estructura como microservicios internos.

NO es:

  • Partir tu solución en 20 proyectos .csproj vacíos.
  • Tener “Domain”, “Services” y “Data” como cajones desastre.
  • Acceder a EF Core del módulo A desde el módulo B “porque es más rápido”.
  • Dejar que todo el mundo vea las clases internas de todos.

Mini-modulith en .NET 8 (Orders + Catalog)

// Estructura
src/
  ModularMonolith.Api            ← composición
  ModularMonolith.SharedKernel   ← contratos internos / eventos base
  ModularMonolith.Orders         ← módulo 1
  ModularMonolith.Catalog        ← módulo 2

// En la API registramos los módulos:
builder.Services
    .AddOrdersModule()
    .AddCatalogModule();

app.MapOrdersEndpoints();
app.MapCatalogEndpoints();

// Ejemplo de Domain Event interno
public sealed record OrderPlaced(Guid OrderId, decimal Total) : IDomainEvent;

// Ejemplo de Aggregate que dispara el evento
public sealed class Order : IHasDomainEvents
{
    public Guid Id { get; private set; }
    public decimal Total { get; private set; }
    private readonly List<IDomainEvent> _domainEvents = new();

    public Order(decimal total)
    {
        Id = Guid.NewGuid();
        Total = total;
        _domainEvents.Add(new OrderPlaced(Id, Total));
    }

    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;
    public void ClearDomainEvents() => _domainEvents.Clear();
}

// Ejemplo de endpoint del módulo Orders
public static class CreateOrderEndpoint
{
    public static IResult Handle(HttpContext httpContext)
    {
        var order = new Order(99.95m);

        foreach (var evt in order.DomainEvents)
            Console.WriteLine($"[Orders] Domain event => {evt}");

        order.ClearDomainEvents();

        return Results.Ok(new { order.Id, order.Total });
    }
}

// Y el módulo se monta así:
public static class OrdersModule
{
    public static IServiceCollection AddOrdersModule(this IServiceCollection services)
        => services;

    public static IEndpointRouteBuilder MapOrdersEndpoints(this IEndpointRouteBuilder endpoints)
    {
        var group = endpoints.MapGroup("/orders");
        group.MapPost("/", CreateOrderEndpoint.Handle);
        return endpoints;
    }
}     

Docker rápido para moduliths (BuildKit + caching)

El gran desconocido:

sí se puede compilar solo los módulos modificados, incluso dentro de Docker.

# ============================
# ETAPA 1: Build / Publish
# ============================
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copia solo los csproj (cache RESTORE)
COPY src/ModularMonolith.Api/*.csproj src/ModularMonolith.Api/
COPY src/ModularMonolith.SharedKernel/*.csproj src/ModularMonolith.SharedKernel/
COPY src/ModularMonolith.Orders/*.csproj src/ModularMonolith.Orders/
COPY src/ModularMonolith.Catalog/*.csproj src/ModularMonolith.Catalog/

RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet restore src/ModularMonolith.Api/ModularMonolith.Api.csproj

# Copia del código
COPY . .

RUN --mount=type=cache,id=build-cache,target=/src/.build \
    dotnet publish src/ModularMonolith.Api/ModularMonolith.Api.csproj \
        -c Release -o /app/publish \
        /p:IntermediateOutputPath=/src/.build/obj \
        /p:BaseIntermediateOutputPath=/src/.build/obj

# ============================
# ETAPA 2: Runtime
# ============================
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ModularMonolith.Api.dll"]

Build con BuildKit

export DOCKER_BUILDKIT=1
docker build -t modulith-demo .
  • Si cambias solo Orders, Docker recompila únicamente ese módulo.
  • Si cambias SharedKernel, recompila la cadena afectada.
  • Si no cambia ningún .csproj, el restore va instantáneo.

Esto acelera builds en CI y local, sin migrar a microservicios ni montar pipelines complejos.

¿Por qué sigo hablando de esto?

Porque todavía veo:

  • microservicios en exceso cuando un modulith sería mejor,

  • monolitos enormes que serían moduliths con 4 decisiones bien tomadas,

  • equipos convencidos de que “modular = carpetas”,

  • y gente que aún piensa que Docker obliga a recompilarlo todo cada vez.

Si quieres empezar bien con arquitectura moderna en .NET sin saltar de cabeza al ecosistema distribuido, un modulith es un gran punto de partida.