¿Alguna vez has tenido esa sensación de "estoy escribiendo demasiada burocracia" al crear un endpoint sencillo en .NET?
Imagina esta escena cotidiana: tu equipo necesita agregar un endpoint para registrar un producto. Una tabla sencilla con tres columnas: Id, Name y Price. Nada del otro mundo.
Abres tu solución y el viaje comienza:
- Creas la entidad en
MiApp.Domain. - Defines una interfaz en
MiApp.Application.Contracts(ICreateProductUseCaseoIProductRepository). - Creas un
ProductDto, unCreateProductCommandy un mapper (o perfil de AutoMapper). - Implementas la interfaz en
MiApp.ApplicationoMiApp.Infrastructure. - Implementas el repositorio
ProductRepositorycon un único métodoAddAsync. - Creas el controlador en
MiApp.Api, inyectas la interfaz y conectas los cables. - Registras el servicio en el contenedor de Inyección de Dependencias.
Quince minutos después, tienes 7 archivos modificados o creados en 4 proyectos distintos para guardar una fila en una base de datos. Y lo peor de todo: esa interfaz ICreateProductService solo tiene —y tendrá por siempre— una sola implementación.
Si te sentiste identificado, bienvenido al club. Esto no es falta de ganas: es fatiga por arquitectura ceremonial.
En esta serie de 3 entregas exhaustivas, vamos a desarmar este problema y ver cómo construir una Web API en .NET con Vertical Slice Architecture (VSA) de forma pragmática, tomando como referencia una plantilla lista para producción construida con C# moderno y Minimal APIs.
El problema de Clean Architecture: El costo se paga el día uno
Clean Architecture, Onion Architecture o la arquitectura hexagonal son herramientas magníficas... cuando el problema que resuelven justifica su costo. El problema ocurre cuando las adoptamos por defecto como si fueran una ley grabada en piedra.
En una arquitectura por capas tradicional, el software se organiza por preocupaciones técnicas:
[ Capa de Presentación / API ]
│
▼
[ Capa de Aplicación (Casos de uso / DTOs) ]
│
▼
[ Capa de Dominio (Entidades / Lógica pura) ]
▲
│
[ Capa de Infraestructura (EF Core / Repositorios) ]
Cada capa vive en un proyecto .csproj separado. Para cualquier cambio, tu mente y tu editor tienen que saltar de un proyecto a otro. Las cosas que cambian juntas están separadas por la fuerza.
Pero lo más peligroso es esto:
En Clean Architecture pagas el costo máximo de complejidad desde el día 1 para todos los casos de uso, incluso para los más triviales.
Un CRUD de lectura de 2 campos paga la misma ceremonia que un motor de pricing financiero hipercomplejo con reglas de negocio cambiantes.
¿Y si invertimos la fórmula?
La idea central: La complejidad se paga por slice
Aquí es donde entra Vertical Slice Architecture (VSA), popularizada por referentes como Jimmy Bogard (creador de MediatR) y refinada por Derek Comartin (CodeOpinion) y Milan Jovanović.
En VSA, la estructura del código no refleja la técnica (capas), sino las capacidades del negocio (features):
CAPAS TRADICIONALES VERTICAL SLICE
(División por técnica) (División por negocio)
┌─────────────────────────┐ ┌──────────┐ ┌──────────┐
│ Presentación │ │ │ │ │
├─────────────────────────┤ │ Feature │ │ Feature │
│ Aplicación │ ──► │ Products │ │ Orders │
├─────────────────────────┤ │ │ │ │
│ Dominio │ │ │ │ │
├─────────────────────────┤ │ │ │ │
│ Infraestructura │ │ │ │ │
└─────────────────────────┘ └──────────┘ └──────────┘
En lugar de rebanar el sistema de forma horizontal, lo cortamos en rebanadas verticales. Un slice atraviesa todo lo necesario para resolver ese caso de uso: desde la ruta HTTP hasta la consulta a la base de datos.
La premisa de oro es:
La complejidad se paga por slice, no por proyecto.
- ¿Un caso de uso es un simple reporte o un CRUD directo? Se resuelve en un único archivo, usando consultas directas sin ceremonias.
- ¿Otro caso de uso es crítico, tiene reglas complejas y requiere validación avanzada y eventos? Ese slice particular tendrá un modelo de dominio rico, validadores dedicados y más pruebas.
Y lo mejor: la complejidad de un caso de uso difícil no contamina al resto de la aplicación.
Las 5 reglas que sustituyen a la policía de capas
En Clean Architecture, la separación en múltiples proyectos .csproj existe para que el compilador nos impida hacer trampas (por ejemplo, que el dominio no referencie a la base de datos).
Al movernos a una arquitectura de vertical slices dentro de un solo proyecto Web API, eliminamos los muros físicos. Pero para que no se convierta en el temido "código espagueti", establecemos 5 reglas de oro explícitas:
-
Un slice no usa los casos de uso de otro slice.
Lo público de un slice es únicamente su modelo de datos (
Domain/, para consultas compartidas) y sus eventos (Contracts/). La lógica interna de un caso de uso es privada. -
Nada entra a
Commonhasta que se repita al menos 3 veces. La abstracción prematura es la raíz de la complejidad innecesaria. No crees una clase base genérica porque "quizás la usemos luego". Si dos features hacen algo similar, permítete duplicar un poco de código. A la tercera repetición, se refactoriza aCommon. - El archivo de endpoints se mantiene tonto. Solo mapea la ruta HTTP hacia el handler correspondiente. Cero lógica de negocio, cero consultas directas en el mapeo de rutas.
- La complejidad es opt-in por slice. Validadores, modelos enriquecidos, manejo transaccional... se agregan únicamente donde el negocio lo exige.
- Camino de crecimiento sin saltos dramáticos. Feature pequeño en una carpeta → Feature grande convertido en módulo → Monolito Modular → Microservicio (solo si realmente hace falta escalabilidad independiente).
Estructura de proyecto: Menos proyectos, más claridad
¿Cómo se ve esto en la vida real? En lugar de tener una solución con 5 proyectos .csproj, arrancamos con un solo proyecto Web API y su proyecto de tests correspondiente:
# Crear solución y proyectos
dotnet new slnx -n MiApp
dotnet new web -n MiApp -o src/MiApp
dotnet new xunit -n MiApp.Tests -o tests/MiApp.Tests
dotnet sln add src/MiApp tests/MiApp.Tests
dotnet add tests/MiApp.Tests reference src/MiApp
La estructura dentro de src/MiApp queda limpia, intuitiva y navegable:
src/MiApp/
├── Program.cs ← Composición de la app
├── Common/ ← Infraestructura compartida real
│ ├── Endpoints/ ← Contratos base y filtros
│ ├── Persistence/ ← DbContext global y migraciones
│ └── ErrorHandling/ ← Manejo de errores RFC 9457
├── Composition/
│ └── UseCaseRegistration.cs ← Registro automático por convención
└── Features/
├── Products/ ← Slice de Productos
│ ├── Endpoints.cs ← Solo enrutamiento de este feature
│ ├── CreateProduct.cs ← Un caso de uso = un archivo
│ ├── GetProducts.cs
│ ├── GetProductById.cs
│ ├── DeleteProduct.cs
│ └── Domain/
│ ├── Product.cs
│ └── ProductConfiguration.cs
└── Orders/ ← Slice de Órdenes
├── Endpoints.cs
├── PlaceOrder.cs
├── GetOrderById.cs
├── Contracts/
│ └── OrderPlaced.cs ← Eventos públicos para otros slices
└── Domain/
└── Order.cs
Nota algo fundamental: cuando te asignen una tarea sobre "Productos", nunca sales de la carpeta Features/Products. No tienes que abrir 6 carpetas en el explorador de soluciones. Todo lo que cambia junto, vive junto.
Enrutamiento con Minimal APIs: Endpoints tontos y limpios
Hay quienes meten toda la definición de rutas, validación y lógica dentro del propio Program.cs. A los 5 endpoints, Program.cs se vuelve inleíble.
Nuestra decisión de diseño fue:
-
Separar las rutas de la lógica: Cada feature tiene un archivo
Endpoints.cs. Funciona como la "tabla de contenidos" del slice. - Endpoints tontos: El endpoint solo recibe la petición HTTP y se la pasa al handler.
Observa cómo se ve Features/Products/Endpoints.cs:
namespace VsaTemplate.Features.Products;
public class Endpoints : IFeatureEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products");
group.MapPost("/", (CreateProduct.Request request, CreateProductHandler handler, CancellationToken ct)
=> handler.Handle(request, ct))
.WithName(nameof(CreateProduct))
.WithValidation<CreateProduct.Request>();
group.MapGet("/", (int? page, int? pageSize, GetProductsHandler handler, CancellationToken ct)
=> handler.Handle(page, pageSize, ct))
.WithName(nameof(GetProducts));
group.MapGet("/{id:guid}", (Guid id, GetProductByIdHandler handler, CancellationToken ct)
=> handler.Handle(id, ct))
.WithName(nameof(GetProductById));
group.MapDelete("/{id:guid}", (Guid id, DeleteProductHandler handler, CancellationToken ct)
=> handler.Handle(id, ct))
.WithName(nameof(DeleteProduct));
}
}
¿Qué logramos con esto?
- En 20 líneas de código entiendes todo lo que ofrece el módulo de productos.
- Las rutas están agrupadas con prefijo
/api/productsy categorizadas para OpenAPI. - Cada endpoint delega inmediatamente en su
*Handler. -
.WithName(...)le da una identidad única a la operación (clave para el frontend, como veremos en la Parte 3).
Auto-descubrimiento de Endpoints: Olvídate de tocar Program.cs
Uno de los mayores dolores de cabeza en proyectos grandes es el archivo central de configuración. Cada vez que alguien crea un feature, tiene que tocar Program.cs para registrarlo. Si 3 programadores hacen esto al mismo tiempo en ramas distintas, prepárate para los conflictos de Git (merge conflicts).
Para resolver esto, implementamos un patrón de auto-descubrimiento.
Aprovechando las interfaces con métodos estáticos abstractos (static abstract) introducidas en C# 11, definimos un contrato muy simple en Common/Endpoints/IFeatureEndpoints.cs:
namespace VsaTemplate.Common.Endpoints;
public interface IFeatureEndpoints
{
static abstract void Map(IEndpointRouteBuilder app);
}
Y luego, en Common/Endpoints/EndpointRegistration.cs, creamos un método de extensión que escanea el ensamblado al arrancar y mapea automáticamente todas las clases que implementen esta interfaz:
public static class EndpointRegistration
{
public static IEndpointRouteBuilder MapFeatureEndpoints(this IEndpointRouteBuilder app)
{
var endpointTypes = typeof(Program).Assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false }
&& typeof(IFeatureEndpoints).IsAssignableFrom(t));
foreach (var type in endpointTypes)
{
var mapMethod = type.GetMethod(
nameof(IFeatureEndpoints.Map),
BindingFlags.Public | BindingFlags.Static);
mapMethod?.Invoke(null, [app]);
}
return app;
}
}
Ahora, en tu Program.cs, solo necesitas una sola línea:
var app = builder.Build();
// Registra todos los endpoints de todos los features automáticamente
app.MapFeatureEndpoints();
app.Run();
El resultado: Agregar un feature nuevo es tan simple como crear la carpeta
Features/Clientes/, agregar suEndpoints.csimplementandoIFeatureEndpoints, y listo. El sistema lo descubre al compilar. No tocasProgram.cspara nada.
Conclusión y lo que viene
Hemos pasado de una maraña de capas horizontales a un esquema vertical donde:
- Organizamos el código por capacidades de negocio, no por conceptos técnicos.
- Eliminamos proyectos
.csprojinnecesarios y reducimos la fricción mental. - Definimos rutas limpias con Minimal APIs y las descubrimos automáticamente sin tocar archivos centrales.
Pero ahora viene la pregunta del millón:
¿Cómo se ve por dentro ese CreateProduct.cs?
¿Dónde quedaron las interfaces? ¿Cómo inyectamos las dependencias si no usamos ICreateProductHandler?
¿Y qué pasó con MediatR? ¿Cómo interactuamos con Entity Framework Core sin escribir una capa de repositorios?
Todo eso lo veremos en la Parte 2: Anatomía de un Slice en Producción, donde destriparemos el diseño de los Handlers, la validación elegante sin ensuciar el negocio, y la comunicación entre slices mediante eventos ligeros.
¿Quieres explorar el código completo?
Puedes clonar y probar la plantilla directamente desde el repositorio de GitHub:
👉 betoramiz/vsa-template
Cuéntame en los comentarios: ¿En tu equipo actual usan Clean Architecture tradicional? ¿Cuántos proyectos tiene tu solución .NET habitual? ¡Nos leemos abajo!
Top comments (0)