En la Parte 1 de esta serie, exploramos por qué Clean Architecture tradicional suele agotar a los equipos con su laberinto de proyectos y capas. Vimos la filosofía de Vertical Slice Architecture (VSA): la complejidad se paga por slice, no por proyecto, y cómo organizar Minimal APIs limpias y auto-descubiertas.
Pero ahora viene lo bueno: abrir el capó del motor y escribir código de producción.
En este artículo responderemos a las preguntas que todo desarrollador .NET se hace al enfrentarse a VSA por primera vez:
- ¿Cómo estructuro un caso de uso para no terminar con un archivo inmanejable de 1000 líneas?
- ¿Dónde quedaron las interfaces? ¿Es pecado no poner
ICreateProductService? - ¿Por qué dejamos de usar MediatR en pleno 2026?
- ¿Cómo persistimos con Entity Framework Core sin caer en la trampa del patrón repositorio genérico?
- ¿Cómo validamos peticiones y cómo hacemos que dos slices se comuniquen sin acoplarse?
Vamos paso a paso.
Anatomía de un caso de uso: Todo lo que cambia junto, vive junto
En Clean Architecture, para entender qué hace "Crear Producto", tienes que abrir la entidad en un proyecto, el DTO en otro, el validador en otro y el handler en otro.
En VSA aplicamos el principio de cohesión espacial: todo lo relacionado con ese caso de uso específico vive en un único archivo: Features/Products/CreateProduct.cs.
Observa el código completo de producción:
using FluentValidation;
using Microsoft.AspNetCore.Http.HttpResults;
using VsaTemplate.Common.Events;
using VsaTemplate.Common.Persistence;
using VsaTemplate.Features.Products.Contracts;
using VsaTemplate.Features.Products.Domain;
namespace VsaTemplate.Features.Products;
// 1. Contratos y validación agrupados bajo el nombre del caso de uso
public static class CreateProduct
{
public record Request(string Name, decimal Price);
public record Response(Guid Id, string Name, decimal Price);
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThan(0);
}
}
}
// 2. La lógica del caso de uso en su clase Handler contigua
public class CreateProductHandler(AppDbContext db, IEventDispatcher eventDispatcher)
{
public async Task<Created<CreateProduct.Response>> Handle(
CreateProduct.Request request,
CancellationToken ct)
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = request.Name.Trim(),
Price = request.Price,
};
db.Products.Add(product);
await db.SaveChangesAsync(ct);
// Publicar evento in-process para otros interesados
await eventDispatcher.Publish(new ProductCreated(product.Name, product.Price), ct);
return TypedResults.Created(
$"/products/{product.Id}",
new CreateProduct.Response(product.Id, product.Name, product.Price));
}
}
Analicemos la genialidad de esta estructura:
-
Clase estática contenedora:
CreateProductactúa como un espacio de nombres local. Sus DTOs sonCreateProduct.RequestyCreateProduct.Response. Se acabaron los nombres kilométricos comoCreateProductCommandDtooCreateProductResultModel. -
Constructor primario de C#: Las dependencias (
AppDbContext,IEventDispatcher) entran directamente en la firma deCreateProductHandler(...). Sin campos privadosprivate readonly, sin boilerplate. - Todo a la vista: En 45 líneas tienes el contrato de entrada, el contrato de salida, las reglas de validación y la lógica de ejecución. Si el negocio te pide cambiar el largo máximo del nombre a 300 caracteres, modificas este archivo y nada más.
El fin de las interfaces ceremoniales: Cero interfaces para tus handlers
Si vienes del mundo corporativo de .NET, tu instinto probablemente te grite:
«¡Espera! ¿Dónde está ICreateProductHandler? ¿Cómo vas a registrar eso en el contenedor de dependencias? ¿Cómo lo vas a testear?»
Hablemos con honestidad:
Una interfaz sirve para intercambiar implementaciones en tiempo de ejecución. Un caso de uso de negocio tiene exactamente UNA implementación.
Crear una interfaz ICreateProductHandler cuya única clase hija es CreateProductHandler no es desacoplamiento: es ceremonia. Es código que tienes que mantener, renombrar y navegar en el IDE sin recibir ningún beneficio a cambio.
¿Y la testabilidad?
La testabilidad no viene de tener una interfaz; viene de cómo recibe sus dependencias la clase. Como CreateProductHandler recibe todo por su constructor primario, en tus pruebas unitarias simplemente haces:
var handler = new CreateProductHandler(dbRealOInMemory, fakeDispatcher);
var result = await handler.Handle(request, CancellationToken.None);
¡No necesitas mockear la interfaz del handler porque estás testeando el handler mismo!
Dónde SÍ usamos interfaces
La regla nunca fue "no usar interfaces", sino "no usar interfaces sin una segunda implementación real". Las interfaces se reservan para las fronteras del sistema, donde genuinamente existen implementaciones alternativas:
-
TimeProvider(reloj real del sistema vs reloj congelado para tests). -
IEventDispatcher(despachador in-process vs despachador de test o distribuido). - Pasarelas de pago (
IStripeServicevsFakePaymentGateway). - Envío de correos (
SendGridClientvsNullEmailSender).
Registro automático por convención con Scrutor
Para no tener que registrar a mano cada handler en el contenedor de dependencias con services.AddScoped<CreateProductHandler>(), usamos la librería Scrutor para escanear el ensamblado al iniciar la aplicación.
En Composition/UseCaseRegistration.cs:
namespace VsaTemplate.Composition;
public static class UseCaseRegistration
{
public static IServiceCollection AddUseCaseHandlers(this IServiceCollection services) =>
services.Scan(scan => scan
.FromAssemblyOf<Program>()
.AddClasses(classes => classes.Where(t =>
t is { IsPublic: true, IsAbstract: false } && t.Name.EndsWith("Handler")))
.AsSelf()
.WithScopedLifetime());
}
¿Qué hace esto?
- Busca cualquier clase pública que termine en
Handler. - La registra
AsSelf()(su propio tipo concreto) con ciclo de vidaScoped. -
Consecuencia: Cuando creas un nuevo caso de uso (ej.
UpdateProductHandler), no tocas ningún archivo de configuración. El contenedor ya sabe resolverlo.
¿Por qué le dijimos adiós a MediatR?
Durante años, MediatR fue el estándar indiscutible en .NET para implementar el patrón Command/Query. Pero el contexto ha cambiado drásticamente:
ERA ANTERIOR (Controllers + MediatR)
HTTP Request ──► Controller ──► MediatR.Send ──► Pipeline ──► Handler
(Mucha delegación innecesaria para terminar en el mismo método)
ERA MODERNA (Minimal APIs + Endpoint Filters)
HTTP Request ──► Minimal API Endpoint ──► Endpoint Filter ──► Handler
(Directo, nativo, sin intermediarios ni reflexión costosa)
-
Minimal APIs resuelve el enrutamiento directamente al handler: En el mapeo de rutas simplemente inyectas el handler:
(CreateProduct.Request req, CreateProductHandler handler) => handler.Handle(req). Minimal APIs resuelve el handler del contenedor y lo llama directo. No hay nada que "mediar". -
Los cross-cutting concerns ahora son Endpoint Filters: El logging, la validación o las métricas que antes metías en
IPipelineBehavior<TRequest, TResponse>ahora se manejan con los filtros nativos de ASP.NET Core (EndpointFilter). - Cambio de licenciamiento: A partir de 2025, MediatR adoptó un modelo de licencia comercial. Para qué pagar o preocuparte por licencias cuando el framework base de .NET ya te da todas las herramientas necesarias de forma más rápida y limpia.
Persistencia con EF Core: El "antipatrón" del repositorio genérico
Muchos proyectos de Clean Architecture meten una capa obligatoria de repositorios: IRepository<T>, IProductRepository, etc.
Pero recordemos un detalle técnico elemental:
Entity Framework Core ya implementa los patrones Unit of Work (
DbContext) y Repository (DbSet<T>).
Crear un ProductRepository encima de EF Core solo para hacer:
public async Task<Product?> GetByIdAsync(Guid id) => await _context.Products.FindAsync(id);
es crear una abstracción con fuga (leaky abstraction). Pierdes la potencia de LINQ, la capacidad de hacer proyecciones directas (.Select()), paginación eficiente, AsNoTracking(), o splits queries.
En nuestra plantilla, los handlers consumen AppDbContext directamente:
var product = await db.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(ct);
Rápido, expresivo y con cero capas intermedias inútiles.
La configuración Fluent API vive junto a la entidad
Siguiendo nuestra pregunta guía: ¿Qué cambia junto?, si agregas una columna a Product, la configuración de base de datos cambia en el mismo commit.
Por eso, en vez de una carpeta lejana Infrastructure/Data/Configurations/, colocamos ProductConfiguration.cs dentro del slice: Features/Products/Domain/:
namespace VsaTemplate.Features.Products.Domain;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.Name).HasMaxLength(200);
builder.Property(p => p.Price).HasPrecision(18, 2);
builder.Property(p => p.RowVersion).IsConcurrencyToken();
}
}
AppDbContext las descubre automáticamente con una sola línea en OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(Program).Assembly);
Pero... ¿por qué las migraciones están centralizadas en Common?
Aquí ocurre algo fascinante. Alguien podría pensar: "Si todo vive en el slice, ¿por qué no ponemos las migraciones dentro de Features/Products/Migrations?"
La respuesta es técnica: Entity Framework Core no permite partir las migraciones por carpeta o feature.
Cada migración es un cálculo diferencial del modelo completo de datos de la base, apoyado en un único ModelSnapshot lineal por DbContext. Si intentaras dividirlas por feature, corromperías la historia secuencial de la base de datos.
Por eso, las migraciones se generan centralizadas en Common/Persistence/Migrations/. VSA no es dogma: somos pragmáticos y respetamos la naturaleza de las herramientas que usamos.
Validación elegante: FluentValidation en un Endpoint Filter
¿Cómo validamos el request sin llenar el handler de ifs if (string.IsNullOrEmpty(request.Name))?
En la plantilla utilizamos un filtro de endpoint genérico: ValidationFilter.cs.
namespace VsaTemplate.Common.Endpoints;
public static class ValidationFilterExtensions
{
public static RouteHandlerBuilder WithValidation<TRequest>(this RouteHandlerBuilder builder)
where TRequest : class =>
builder
.AddEndpointFilter(async (context, next) =>
{
// Obtenemos el validador registrado para este tipo de Request
var validator = context.HttpContext.RequestServices
.GetRequiredService<IValidator<TRequest>>();
var request = context.Arguments.OfType<TRequest>().First();
var result = await validator.ValidateAsync(request, context.HttpContext.RequestAborted);
// Si la validación falla, corta la petición y retorna HTTP 400 Bad Request (ProblemDetails)
return result.IsValid
? await next(context)
: TypedResults.ValidationProblem(result.ToDictionary());
})
.ProducesValidationProblem(); // Documenta automáticamente el error 400 en OpenAPI
}
En el mapeo de la ruta en Endpoints.cs, solo agregas una extensión encadenada:
group.MapPost("/", (CreateProduct.Request request, CreateProductHandler handler, CancellationToken ct)
=> handler.Handle(request, ct))
.WithName(nameof(CreateProduct))
.WithValidation<CreateProduct.Request>();
Las grandes ventajas de este enfoque:
-
El Handler es territorio sagrado: El handler sabe que si el código llegó a él, los datos ya son 100% válidos. No necesita manejar
ValidationProblemni ensuciarse con validaciones. -
Respuesta estándar: Si falla, el cliente recibe automáticamente un
RFC 9457 ProblemDetailsestándar con el desglose de errores por propiedad. -
Fail fast en desarrollo: Si usas
.WithValidation<T>()pero olvidaste escribir la claseValidator, el contenedor falla inmediatamente con una excepción explícita (GetRequiredService), impidiendo que despliegues un endpoint con validaciones rotas.
Comunicación entre Slices: Eventos in-process
Nuestra Regla #1 de VSA dice:
«Un slice no usa los casos de uso ni los handlers de otro slice».
Pero surge un problema real: cuando se coloca una orden de compra en el slice Orders, necesitamos que el slice Notifications envíe un correo y registre un log. ¿Cómo lo hacemos sin que PlaceOrderHandler llame directamente a Notifications?
La respuesta: Eventos de dominio in-process.
Un Dispatcher propio de ~40 líneas
En lugar de instalar librerías pesadas, la plantilla incluye un dispatcher sencillo y directo en Common/Events/EventDispatcher.cs:
namespace VsaTemplate.Common.Events;
public class EventDispatcher(IServiceProvider services) : IEventDispatcher
{
public async Task Publish<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : class
{
// Resuelve todos los handlers suscritos a TEvent en el scope actual
foreach (var handler in services.GetServices<IEventHandler<TEvent>>())
{
await handler.Handle(@event, ct);
}
}
}
El contrato público vive en Contracts/
El slice emisor (Orders) define su evento en una subcarpeta pública: Features/Orders/Contracts/OrderPlaced.cs:
namespace VsaTemplate.Features.Orders.Contracts;
public record OrderPlaced(Guid OrderId, Guid ProductId, int Quantity, decimal Total);
Cuando se coloca la orden, PlaceOrderHandler simplemente publica el evento:
await eventDispatcher.Publish(new OrderPlaced(order.Id, product.Id, quantity, total), ct);
Y en el slice de notificaciones (Features/Notifications/OrderPlacedNotification.cs), escuchamos el evento:
public class OrderPlacedNotification(ILogger<OrderPlacedNotification> logger)
: IEventHandler<OrderPlaced>
{
public Task Handle(OrderPlaced @event, CancellationToken ct)
{
logger.LogInformation("Nueva orden recibida: {OrderId}. Enviando confirmación...", @event.OrderId);
return Task.CompletedTask;
}
}
Orders no tiene la más remota idea de que Notifications existe. Están 100% desacoplados.
¿Cuándo migrar a Wolverine o MassTransit?
Este dispatcher in-process es ligero y comparte la misma transacción y memoria del request. Si el proceso del servidor se cae a la mitad, el evento en memoria se pierde.
Cuando necesites garantías transaccionales duras (Outbox Pattern), reintentos automáticos ante caídas o enviar mensajes a brokers externos (RabbitMQ, Kafka, Azure Service Bus), el salto natural y recomendado en el ecosistema .NET es Wolverine. Y lo mejor: como tus handlers ya son métodos limpios, migrar a Wolverine requiere mínimos cambios.
Resumen del flujo de un caso de uso
HTTP POST /api/products
│
▼
┌──────────────────────────────────────┐
│ Endpoint Route Builder │
└──────────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ .WithValidation<CreateProduct.Req>() │ ──► ¿Inválido? ──► HTTP 400 ProblemDetails
└──────────────────┬───────────────────┘
│ (Válido)
▼
┌──────────────────────────────────────┐
│ CreateProductHandler │
│ • Guarda en DbContext (EF Core) │
│ • Publica EventDispatcher │
└──────────────────┬───────────────────┘
│
▼
HTTP 201 Created (JSON)
Conclusión y lo que viene en la Parte 3
Hemos visto cómo un slice cobra vida:
- Casos de uso compactos y altamente cohesivos.
- Handlers concretos sin interfaces ficticias, registrados automáticamente por convención con Scrutor.
- Persistencia directa con Entity Framework Core.
- Validación automática desacoplada del negocio mediante Endpoint Filters.
- Comunicación limpia entre slices mediante eventos in-process.
Pero falta la pieza más crítica de todas: la protección y el futuro.
- ¿Cómo testeamos estos slices sin caer en la pesadilla de mockear 20 llamadas a repositorios?
- ¿Cómo evitamos que un desarrollador distraído rompa las reglas y acople los slices entre sí?
- ¿Cómo compartimos estos modelos C# con el frontend en Angular/React sin desincronizarnos?
- ¿Y cómo escala esta arquitectura si el sistema crece a 50 o 100 slices?
Todo eso lo resolveremos en el cierre de la trilogía: Parte 3: Blindaje, Frontend y Evolución.
Código fuente de referencia
El código de todos los ejemplos proviene directamente de la plantilla:
👉 Repositorio en GitHub: betoramiz/vsa-template
¡Déja tu opinión en los comentarios! ¿Qué opinas de eliminar las interfaces en los handlers? ¿Prefieres eventos in-process o usar un mediador tradicional?
Top comments (0)