Arquitectura de software

Arquitectura modular en aplicaciones .NET

Módulos con límites claros dentro de una sola aplicación: cómo separarlos en .NET, cómo se comunican y cómo proteger sus datos y hacer cumplir los límites.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# con la interfaz pública de un módulo de inventario

Un monolito no se vuelve difícil de mantener por ser un solo despliegue. Se vuelve difícil cuando cualquier parte puede llamar a cualquier otra: facturación consulta directamente las tablas de inventario, inventario modifica las entidades de pedidos y un cambio en el modelo de clientes rompe tres funcionalidades que nadie sabía que lo usaban.

Una arquitectura modular mantiene el despliegue único, pero divide la aplicación en módulos con límites explícitos. Cada módulo es dueño de sus datos y de su lógica, y los demás solo lo usan a través de lo que decide exponer.

Qué es un módulo

Un módulo corresponde a una capacidad del negocio, no a una capa técnica:

src/
  Host/                       → Program.cs, configuración, composición
  Modules/
    Catalog/
      Catalog.Contracts/      → lo que otros módulos pueden usar
      Catalog/                → implementación interna
    Orders/
      Orders.Contracts/
      Orders/
    Billing/
      Billing.Contracts/
      Billing/

Catalog, Orders y Billing son áreas que cambian por razones distintas, que a menudo tienen responsables distintos y que podrían, en teoría, ser sistemas separados. Esa es la prueba para trazar un límite: si podrías describir el módulo como un producto independiente, el límite probablemente está bien puesto.

Regla 1: cada módulo expone un contrato pequeño

// Catalog.Contracts: lo único que otros módulos ven de Catalog
public interface ICatalogModule
{
    Task<ProductSnapshot?> GetProductAsync(int productId, CancellationToken ct);
    Task<bool> IsAvailableAsync(int productId, int quantity, CancellationToken ct);
}

public record ProductSnapshot(int Id, string Name, decimal Price);

Orders puede preguntar el precio de un producto, pero no sabe cómo Catalog lo guarda, qué tablas tiene ni qué reglas aplica. Si Catalog cambia su modelo interno, Orders no se entera mientras el contrato se mantenga.

Regla 2: los internos son internos de verdad

La forma más efectiva de que los límites se respeten es que el compilador los haga cumplir:

// En el proyecto Catalog: todo internal salvo la implementación del contrato
internal sealed class Product { ... }
internal sealed class CatalogDbContext : DbContext { ... }

public sealed class CatalogModule(CatalogDbContext db) : ICatalogModule { ... }

Con proyectos separados y tipos internal, otro módulo no puede usar Product aunque quiera. Las referencias entre proyectos también ayudan: Orders referencia a Catalog.Contracts, nunca a Catalog.

Si prefieres un solo proyecto, pruebas de arquitectura con bibliotecas como NetArchTest o ArchUnitNET pueden verificar en cada compilación que ningún módulo usa los internos de otro.

Regla 3: cada módulo es dueño de sus datos

Este es el límite más importante y el más difícil de mantener. Si Billing puede hacer JOIN contra las tablas de Orders, los módulos están acoplados a nivel de base de datos, y ningún contrato en C# lo evita.

En la práctica:

  • Un esquema por módulo en la misma base de datos (catalog.Products, orders.Orders, billing.Invoices).
  • Un DbContext por módulo, que solo mapea su esquema.
  • Sin claves foráneas entre esquemas. orders.OrderLines.ProductId guarda el ID, pero no tiene FK hacia catalog.Products.
internal sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("orders");
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
    }
}

Perder las FK entre módulos parece una pérdida de integridad, y lo es. Es el costo de que cada módulo pueda evolucionar su esquema sin coordinar con los demás. Por eso conviene que los datos que un módulo necesita de otro se copien en el momento en que importan: la línea de pedido guarda el nombre y el precio del producto al momento de la compra, no solo su ID.

Regla 4: comunicación síncrona para preguntar, eventos para avisar

Dos formas de comunicarse entre módulos:

Llamadas al contrato, cuando un módulo necesita una respuesta para continuar:

var product = await catalog.GetProductAsync(request.ProductId, ct);
if (product is null) return Results.NotFound();

Eventos, cuando un módulo avisa que algo pasó y no le importa quién reaccione:

// Orders publica
await events.PublishAsync(new OrderPlaced(order.Id, order.CustomerId, order.Total), ct);

// Billing reacciona, sin que Orders sepa que existe
internal sealed class CreateInvoiceOnOrderPlaced(BillingDbContext db) : IEventHandler<OrderPlaced> { ... }

Los eventos dentro del mismo proceso pueden ser simples (un despachador en memoria). Pero si el manejador falla, ¿el pedido ya se guardó? Para eventos que no pueden perderse, el patrón outbox guarda el evento en la misma transacción que el cambio y lo publica después. Es la misma idea que vimos en colas y topics de Azure Service Bus, aplicada dentro de un solo proceso.

Regla 5: el host solo compone

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddCatalogModule(builder.Configuration)
    .AddOrdersModule(builder.Configuration)
    .AddBillingModule(builder.Configuration);

var app = builder.Build();

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

app.Run();

Cada módulo registra sus propios servicios y endpoints. El host no conoce sus internos.

El trade-off

Una arquitectura modular agrega disciplina y algo de código: contratos, eventos, datos copiados entre módulos, un DbContext por módulo. A cambio:

  • Los cambios dentro de un módulo no afectan a los demás.
  • Los equipos pueden trabajar en paralelo con menos conflictos.
  • Si algún día un módulo necesita escalar o desplegarse aparte, ya tiene límites claros para extraerlo.

Cuándo no vale la pena

  • Aplicaciones pequeñas con un solo equipo y un dominio simple: la estructura por funcionalidades da casi los mismos beneficios con menos ceremonia.
  • Cuando todavía no está claro dónde están los límites del negocio. Un límite mal trazado es más caro de corregir que no tener ninguno: obliga a comunicación constante entre módulos que en realidad son uno solo.

Conclusión

La arquitectura modular toma lo mejor de los microservicios (límites claros, autonomía, datos propios) sin pagar el costo de la red, los despliegues independientes y la consistencia distribuida. La clave es que los límites sean reales: contratos pequeños, internos que el compilador protege y, sobre todo, datos que cada módulo controla.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Tu sistema necesita una revisión de arquitectura?

Te ayudamos a decidir qué estructura necesita tu software hoy y cómo prepararlo para crecer, sin sobreingeniería.

Ver desarrollo a medida