DEV Community

Beto Ramirez
Beto Ramirez

Posted on

"Blindaje, Frontend y Evolución: Tests de Arquitectura, SQLite in-memory y el Camino a Modular Monolith (Parte 3)

Llegamos a la última entrega de nuestra serie sobre Vertical Slice Architecture (VSA) en .NET.

En la Parte 1, eliminamos los proyectos innecesarios y sentamos las bases con Minimal APIs y auto-descubrimiento. En la Parte 2, escribimos código de producción: handlers cohesivos, persistencia directa con EF Core, validación con filtros y eventos desacoplados.

Pero cualquier arquitecto o desarrollador senior se estará haciendo la pregunta decisiva:

«Al meter todo en un solo proyecto Web API, eliminaste los muros del compilador. ¿Qué impide que un desarrollador con prisa llame a un handler privado de otro slice o cree dependencias circulares? ¿Cómo testeamos esto sin volvernos locos? ¿Cómo se conecta con el frontend? ¿Y qué pasa si la aplicación crece a 100 slices?»

En este artículo cerramos el círculo: blindaje con tests de arquitectura automáticos, testing honesto sin mocks de repositorio, generación de contratos hacia TypeScript y la hoja de ruta para evolucionar hacia un Monolito Modular sin reescribir tu sistema.


Testing sin mentiras: Dile adiós a los mocks de repositorio

En proyectos tradicionales con Clean Architecture, las pruebas unitarias suelen lucir así:

// El clásico test que prueba el mock, no tu código:
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(new Product());

var service = new ProductService(mockRepo.Object);
await service.DoSomethingAsync(id);

mockRepo.Verify(r => r.GetByIdAsync(id), Times.Once); // "Probamos que llamamos al mock"
Enter fullscreen mode Exit fullscreen mode

¿Qué acabas de probar? Que si llamas a un método ficticio, ese método ficticio devuelve lo que tú mismo le dijiste que devolviera.

No probaste si tu consulta LINQ era válida, si rompía por una columna nula, si el mapping fallaba o si las restricciones de clave foránea se cumplían. Ese test miente por omisión.

La alternativa honesta: SQLite in-memory con esquema real

En nuestra plantilla, no hay repositorios que mockear porque no hay repositorios que escribir. Los handlers reciben AppDbContext.

Para testearlos de forma ultrarrápida y realista, creamos un helper TestDb.Create() que levanta una base de datos SQLite in-memory con todas las tablas y configuraciones reales creadas en memoria:

namespace VsaTemplate.Tests.Common;

public static class TestDb
{
    public static AppDbContext Create()
    {
        var connection = new SqliteConnection("Data Source=:memory:");
        connection.Open();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite(connection)
            .Options;

        var db = new OwnedConnectionDbContext(options, connection);
        db.Database.EnsureCreated(); // Aplica el schema real al instante
        return db;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cómo luce un test unitario real del Handler

Observa tests/Features/Products/CreateProductTests.cs:

public class CreateProductTests
{
    [Fact]
    public async Task Creates_the_product_when_the_data_is_valid()
    {
        // 1. Arrange: BD real en memoria y mock solo para la frontera (EventDispatcher)
        await using var db = TestDb.Create();
        var fakeDispatcher = new Mock<IEventDispatcher>();

        // El handler se construye con new directo: ¡sin interfaces, sin DI container!
        var handler = new CreateProductHandler(db, fakeDispatcher.Object);

        // 2. Act
        var request = new CreateProduct.Request("Teclado Mecánico", 99m);
        var result = await handler.Handle(request, CancellationToken.None);

        // 3. Assert: Comprobamos el resultado Y que la fila realmente existe en la BD
        Assert.Equal("Teclado Mecánico", result.Value!.Name);
        Assert.Single(db.Products);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Corre en milisegundos.
  • Ejecuta SQL real.
  • Solo mockeas las verdaderas fronteras externas (como IEventDispatcher o pasarelas de pago).

Testear validadores es aún más simple

Como los validadores de FluentValidation viven en el slice, se prueban como funciones puras:

[Fact]
public void The_validator_rejects_the_empty_name()
{
    var result = new CreateProduct.Validator()
        .Validate(new CreateProduct.Request("   ", 99m));

    Assert.False(result.IsValid);
    Assert.Contains(result.Errors, e => e.PropertyName == "Name");
}
Enter fullscreen mode Exit fullscreen mode

Sin base de datos, sin servidor HTTP, sin complejidad.

Dos niveles de tests

  • Unitarios por Slice (tests/Features/): Construyes el handler con new, pasas TestDb y pruebas la lógica interna de inmediato.
  • Integración HTTP (tests/Integration/): Usamos WebApplicationFactory (ApiFactory) para levantar la API completa, reemplazando la base de datos por SQLite in-memory y enviando peticiones HTTP reales. Esto valida routing, serialización JSON, middleware de autenticación, rate limiting y filtros.

Tests de Arquitectura: El guardián de las reglas

La mayor crítica a Vertical Slice Architecture es:

«Al no tener proyectos separados, cualquiera puede meter un using VsaTemplate.Features.Orders; dentro de Features.Products y nadie se va a dar cuenta en el code review».

Y es una crítica válida. Una violación de arquitectura en un solo proyecto es una línea invisible que se cuela en silencio.

Para resolver esto de raíz, usamos NetArchTest.Rules. Convertimos nuestras reglas arquitectónicas en pruebas automatizadas de xUnit que corren con dotnet test y rompen el build en el Pull Request si alguien comete una infracción.

Observa cómo se ve en tests/Architecture/ArchitectureTests.cs:

[Fact]
public void A_slice_does_not_use_use_cases_from_another_slice()
{
    var failures = new List<string>();

    foreach (var slice in Slices())
    {
        // Obtiene todos los tipos de casos de uso de los demás slices
        var foreignUseCases = Slices()
            .Where(other => other != slice)
            .SelectMany(UseCaseTypeNames)
            .ToArray();

        // Verifica que este slice NO tenga dependencia sobre los casos de uso ajenos
        var result = Types.InAssembly(Api)
            .That().ResideInNamespaceStartingWith($"VsaTemplate.Features.{slice}")
            .ShouldNot().HaveDependencyOnAny(foreignUseCases)
            .GetResult();

        if (!result.IsSuccessful)
            failures.AddRange(result.FailingTypes.Select(t => $"  {t.FullName}"));
    }

    Assert.True(failures.Count == 0,
        "Estos tipos usan casos de uso de otro slice. Lo público de un slice es su " +
        "Domain (consultas) y sus Contracts (eventos); para lo demás, comunícate por " +
        $"eventos (README, regla 1):\n{string.Join('\n', failures)}");
}
Enter fullscreen mode Exit fullscreen mode

Las 6 reglas vigiladas automáticamente:

  1. Un slice no usa los casos de uso de otro slice: Vigila automáticamente cada carpeta dentro de Features/.
  2. Common no usa casos de uso de los slices: La infraestructura compartida jamás debe depender de la lógica de negocio.
  3. Toda clase *Endpoints implementa IFeatureEndpoints: Si a un dev se le olvida implementar la interfaz, el test falla y le avisa que sus rutas no se iban a registrar.
  4. Los event handlers viven en Features, no en Common: Escuchar eventos es responsabilidad de negocio.
  5. Los contratos anidados se llaman Request, Response o Validator: Garantiza coherencia en toda la solución y nombres limpios para el generador de TypeScript.
  6. Todo handler queda registrado por convención: Si alguien crea un handler privado o que no cumpla la convención de nombres de Scrutor, el test lo detecta antes de que cause un error HTTP 500 en runtime.

Lección de oro: Un test de arquitectura que nunca falla no protege nada. Durante el desarrollo de la plantilla creamos clases con violaciones a propósito para confirmar que el test fallaba y arrojaba mensajes explicativos y educativos para el desarrollador infractor.


Contrato directo hacia el Frontend (OpenAPI → TypeScript)

¿Cuántas veces has tenido que escribir en Angular o React una interfaz interface ProductDto { id: string; name: string; price: number; } que ya tenías escrita en C#? Y a las dos semanas, alguien renombra un campo en el backend y el frontend explota silenciosamente.

No hay que duplicar modelos a mano: OpenAPI es el puente natural.

El desafío de VSA con OpenAPI: Colisión de nombres

En VSA, todos los slices anidan sus records y los llaman igual: CreateProduct.Request, PlaceOrder.Request.

Por defecto, el generador OpenAPI de ASP.NET Core solo toma el nombre de la clase interna (Request), provocando una colisión de nombres inmediata en el archivo openapi.json.

En Common/OpenApi/OpenApiRegistration.cs lo resolvimos configurando CreateSchemaReferenceId:

public static IServiceCollection AddOpenApiDocument(this IServiceCollection services) =>
    services.AddOpenApi(options =>
    {
        // Si el tipo está anidado, combinamos el nombre del padre con el hijo:
        // CreateProduct + Request = "CreateProductRequest"
        options.CreateSchemaReferenceId = typeInfo =>
            typeInfo.Type.DeclaringType is { } owner
                ? $"{owner.Name}{typeInfo.Type.Name}"
                : OpenApiOptions.CreateDefaultSchemaReferenceId(typeInfo);
    });
Enter fullscreen mode Exit fullscreen mode

Métodos limpios con .WithName() y TypedResults

Al mapear el endpoint con .WithName(nameof(CreateProduct)), le asignamos un operationId explícito. Y al usar Task<Results<Created<Response>, ValidationProblem>>, ASP.NET Core infiere automáticamente los esquemas de respuesta HTTP 201 y HTTP 400 sin tener que llenar los endpoints de atributos [ProducesResponseType].

Generación automática en Angular

En el frontend (Angular en este caso), un simple comando lee el JSON generado y crea clientes y tipos con tipado estricto:

npx ng-openapi-gen --input ./openapi/VsaTemplate.json --output src/app/api
Enter fullscreen mode Exit fullscreen mode

El ciclo de feedback es perfecto:

  1. Modificas un record en C#.
  2. Compilas la API (dotnet build actualiza el JSON de OpenAPI).
  3. Corres ng-openapi-gen.
  4. El compilador de TypeScript te marca en rojo exactamente qué pantalla de Angular quedó desactualizada.

Registro de Decisiones de Arquitectura.

Para tener una visión de todas las decisiones técnicas tomadas en la plantilla, esta tabla resume el por qué detrás de cada elección:

# Decisión Técnica Justificación Pragmática
1 VSA en un solo proyecto La complejidad se paga por slice, no por proyecto. Elimina fricción innecesaria.
2 Endpoints separados de la lógica El archivo Endpoints.cs actúa como tabla de rutas del slice; el handler queda libre de HTTP.
3 Handlers sin interfaces Una interfaz sin segunda implementación es pura ceremonia.
4 Registro por convención con Scrutor Cero configuración manual en Program.cs. Un test de arquitectura vigila que nada quede fuera.
5 Interfaces solo en fronteras TimeProvider, IEventDispatcher, pasarelas de pago y proveedores externos.
6 Sin MediatR Minimal APIs despacha directo; los Endpoint Filters cubren los cross-cutting concerns.
7 Tests con SQLite in-memory Prueba el LINQ y SQL real; elimina la necesidad de repositorios ficticios.
8 Dispatcher de eventos propio (~40 líneas) Desacopla slices in-process sin librerías externas ni problemas de licenciamiento.
9 Eventos en Contracts/ Frontera explícita y verificable por namespace entre features.
10 OpenAPI emitido en build + TS Contrato de tipos compartido entre backend y frontend sin desincronización manual.
11 Tests de arquitectura con NetArchTest Las convenciones se vigilan con tests automáticos en el CI/CD, no con policías en review.
12 EF Core Config en el slice, Migraciones en Common La config cambia con la entidad; las migraciones son globales por diseño del ModelSnapshot de EF.
13 FluentValidation en Endpoint Filter Menos código por slice; el handler solo recibe peticiones válidas.
14 dotnet-ef como tool local El repositorio fija sus versiones de herramientas de desarrollo sin tocar la máquina global del dev.

El camino de crecimiento: De Slice a Monolito Modular y Microservicios

El mayor temor al comenzar con Vertical Slice Architecture es:

«¿Qué pasa cuando la aplicación crezca a 50 modelos y 200 casos de uso? ¿Nos quedaremos atrapados?»

La respuesta es que VSA ofrece el camino de crecimiento más natural y sin saltos traumáticos:

      FASE 1: SLICE SIMPLE (HOY)
      Un proyecto Web API.
      Slices en carpetas `Features/`.
      Dispatcher in-process.
                 │
                 ▼ (Un feature crece mucho en complejidad o equipo)
      FASE 2: MÓDULO INDEPENDIENTE
      Se extrae la carpeta a su propio proyecto de clase o DLL.
      DbContext propio y migraciones propias.
      Frontera ya lista gracias a `Contracts/`.
                 │
                 ▼ (Los módulos requieren comunicación resiliente)
      FASE 3: MONOLITO MODULAR
      Reemplazas el dispatcher in-process por Wolverine o MassTransit.
      Outbox pattern, reintentos y mensajería asíncrona.
                 │
                 ▼ (Solo si un módulo requiere despliegue/escala independiente)
      FASE 4: MICROSERVICIO
      El módulo se despliega en su propio contenedor/proceso.
      Cero refactorización de lógica de negocio.
Enter fullscreen mode Exit fullscreen mode
  1. Hoy: Todo vive en un solo proyecto, pero perfectamente ordenado por slices.
  2. Un feature crece demasiado: Imagina que el módulo Billing crece y tiene su propio equipo asignado. Como su configuración de EF Core, sus handlers y sus contratos ya viven en su carpeta, convertirlo en un módulo (proyecto de biblioteca de clases separado) es una mudanza limpia y barata.
  3. Comunicación resiliente: Cuando varios módulos necesitan coordinarse de forma asíncrona y transaccional, reemplazas el dispatcher simple por Wolverine. Como tus handlers ya son clases con un método Handle, no tienes que reescribir nada.
  4. Microservicio (solo si hace falta): Si después de todo eso, el módulo de Facturación necesita escalar a 10 instancias en Kubernetes mientras el resto corre en 1, lo extraes como servicio independiente.

La regla de oro: Nunca te saltes etapas. Empezar con microservicios desde el día uno es comprar complejidad antes de tener el problema. Con VSA tienes agilidad inmediata hoy y una puerta abierta al futuro sin deuda técnica.


Conclusión de la serie

A lo largo de estas 3 entregas hemos demostrado que:

  • No necesitas 5 proyectos para hacer software profesional en .NET.
  • Puedes tener código limpio, testeable y desacoplado sin caer en la trampa del boilerplate infinito.
  • Con las herramientas modernas de C# (.NET 10 / C# 13-14), Minimal APIs, Scrutor, FluentValidation y NetArchTest, el desarrollo backend vuelve a ser divertido, ágil y legible.

Ponlo en práctica hoy mismo

La plantilla completa está lista para que la uses en tus proyectos personales o en tu empresa:

# Instalar el template desde GitHub
dotnet new install https://github.com/betoramiz/vsa-template.git

# Crear un proyecto nuevo
dotnet new vsa-api -n MiEmpresa.Api -o MiEmpresa.Api --IncludeSampleFeature
Enter fullscreen mode Exit fullscreen mode

👉 Repositorio oficial: github.com/betoramiz/vsa-template

👉 Wiki con detalles arquitectónicos: Documentación en GitHub Wiki


¡Dejame conocer tu experiencia!

¿Te ha tocado sufrir soluciones con decenas de capas y proyectos vacíos? ¿Te animarías a probar Vertical Slice Architecture en tu próximo proyecto en .NET?

¡Deja tus reflexiones, preguntas o discrepancias en los comentarios! Comparte esta trilogía con tu equipo si crees que les ahorrará horas de boilerplate. ¡Hasta la próxima!

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.