I. Introducción a la Arquitectura de Vertical Slice en .NET
IV. Ventajas y Desventajas Generales de la Arquitectura Vertical Slice
V. Otras Posibles Alternativas de Implementación en VSA
VI. Conclusión y Recomendaciones Estratégicas: Un Enfoque Pragmático con .NET
La Arquitectura de Corte Vertical (VSA) ha ganado un lugar importante dentro de la comunidad de desarrollo de .NET como una alternativa a las arquitecturas en capas más tradicionales. Se basa principalmente en organizar la lógica de la aplicación en 'features' o características de negocio, llamadas "slices" (rebanadas o cortes), en lugar de agrupar el código por afinidad técnica en "capas horizontales", como presentación, lógica de negocio y acceso a datos. Cada slice vertical agrupa todos lo que se requiera para implementar una característica específica. Desde la presentación hasta la persistencia, atravesando todos los requisitos de dominio/negocio relevantes para esa funcionalidad particular.
La VSA cuenta con algunas especificidades que guían su implementación y pretenden optimizar la estructura del código para la mantenibilidad y su escalabilidad:
Orientado a Casos de Uso (Use-case Driven):
Disolución de Abstracciones (Melting Abstractions):
Eje de Cambio (Axis Of Change):
La idea principal es maximizar la cohesión dentro de cada slice (todos los elementos están fuertemente relacionados) y minimizar el acoplamiento entre slices diferentes (hacerlos lo más independientes posible).
Si bien la comunidad .NET no es exclusivamente la única que lo implementa actualmente, la popularización de VSA en .NET podría atribuirse quizá a Jimmy Bogard (creador de AutoMapper, MediatR y Respawn). Su argumento es que las arquitecturas en capas tradicionales pueden ser demasiado genéricas y no óptimas para la mayoría de las necesidades individuales cada sistema.
Puedes leer más acerca de este tema en el enlace Vertical Slice Architecture para ampliar la información de este artículo.
En el contexto de .NET, la Arquitectura de Vertical Slice se beneficia mucho de características y patrones del propio framework ASP.NET:
Minimal APIs y ASP.NET Core MVC:
Inyección de Dependencias (DI):
Estructura de Proyectos:
Separar los slices en proyectos de biblioteca de clases independientes, y agruparlos por "contexto de negocio", es una de tantas estrategias válidas para organizar aplicaciones grandes. La aplicación principal (API web ASP.NET Core) actúa como ensamblador u orquestador.
Vuelvo a mencionar para mayor claridad que esta arquitectura no se limita exclusivamente a APIs RESTful, sino que es perfectamente compatible con gRPC, aplicaciones Blazor, Razor Pages, e incluso aplicaciones de escritorio o móviles.
Por qué:
Cómo:
Una estructura típica:
MainApiProject.csproj
: Proyecto ASP.NET Core.ContextA.Features.csproj
: Biblioteca de clases con slices para el Contexto A.ContextB.Features.csproj
: Biblioteca de clases con slices para el Contexto B.SharedKernel.csproj
: Código compartido (utilidades, interfaces transversales, DTOs comunes si son estrictamente necesarios y bien definidos).La comunicación entre contextos o slices, si es necesaria, debe ser explícita, usualmente a través de interfaces definidas en un proyecto compartido o expuestas por la API pública de cada proyecto. No debe haber comunicación entre componentes internos de cada slice directamente. El desafío principal es cómo el MainApiProject
descubre y registra los endpoints en los proyectos de contexto.
Personalmente prefiero usar y abusar de métodos de extensión que luego pueda agregar casi de forma transparente a mi MainApiProject
.
ApplicationParts
para MVC y Minimal APIs quizá con técnicas de reflexión, generadores de código, o simples métodos de extensión.SharedKernel
con precaución para no crear un "god object". Compartir solo lo que es estable y reutilizable.Program.cs
, se configura el registro de servicios y el mapeo de endpoints provenientes de los proyectos de contexto, ya sea directamente (poco recomendable) o usando métodos de extensión (lo que se demuestra en el proyecto que agregué al pie de este artículo).Cuando los slices están en proyectos separados, el proyecto API principal necesita un mecanismo para descubrir y exponer los endpoints HTTP.
Por qué existe ApplicationParts
: ASP.NET Core MVC necesita una forma de encontrar Controladores, Vistas, View Components, etc., que pueden estar en el ensamblado principal u otros referenciados (como los proyectos de contexto), y para esto es posible usar las ApplicationParts
.
Cómo funciona: El ApplicationPartManager
rastrea las ApplicationParts
(una AssemblyPart
encapsula un ensamblado) y los IFeatureProvider
(como ControllerFeatureProvider
) para descubrir estos componentes. Por defecto, MVC examina las dependencias del proyecto principal y descubre controladores en ensamblados referenciados.
Una excelente lectura para entender más a detalle este punto es este artículo When ASP.NET Core can't find your controller: debugging application parts de Andrew Lock.
Ventajas para VSA con Proyectos Separados (usando Controladores MVC):
ApplicationParts
.Program.cs
.Desventajas y Limitaciones:
Ejemplo de Código Minimalista (Ilustrativo):
//FeatureAController.cs en un proyecto a parte
using Microsoft.AspNetCore.Mvc;
namespace ContextA.Features.Controllers;
[ApiController]
[Route("[controller]")]
public class FeatureAController: ControllerBase
{
public IActionResult Index()
{
return Ok("Hola mundo desde FeatureA!");
}
}
Program.cs:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
IServiceCollection services = builder.Services;
services
.AddControllers()
.AddApplicationPart(
typeof(ContextA.Features.Controllers.FeatureAController).Assembly
);
WebApplication app = builder.Build();
app.MapControllers();
app.Run();
o más personalizable:
using Microsoft.AspNetCore.Mvc.ApplicationParts;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
IServiceCollection services = builder.Services;
services.AddControllers()
.ConfigureApplicationPartManager(apm =>
{
// Ejemplo de cómo se podría añadir un ensamblado cargado dinámicamente:
// var pluginAssembly = Assembly.LoadFrom("ruta/a/ContextA.Features.dll");
// apm.ApplicationParts.Add(new AssemblyPart(pluginAssembly));
// Ejemplo de cómo se podría remover un ensamblado si fuera necesario:
// var assemblyToExclude = apm.ApplicationParts
// .FirstOrDefault(part => part.Name == "EnsambladoAExcluir");
// if (assemblyToExclude!= null)
// {
// apm.ApplicationParts.Remove(assemblyToExclude);
// }
apm
.ApplicationParts
.Add(
new AssemblyPart(
typeof(ContextA.Features.Controllers.FeatureAController).Assembly
)
);
});
WebApplication app = builder.Build();
app.MapControllers();
app.Run();
O mediante métodos de extensión:
// FeatureAExtensions.cs
// Suelo colocar uno en cada proyecto para que cada funcionalidad
// sea responsable de registrarse a sí misma
using Microsoft.Extensions.DependencyInjection;
namespace ContextA.Features.Extensions;
public static class FeatureAExtensions
{
public static IMvcBuilder AddFeatureA(this IMvcBuilder builder)
{
// Descubriá todos los controladores que contiene
builder
.AddApplicationPart(
Assembly.GetExecutingAssembly()
);
return builder;
}
}
// Program.cs
using ContextA.Features.Extensions;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
IServiceCollection services = builder.Services;
// Requerido para FeatureA
services
.AddControllers()
.AddFeatureA();
// Opcional para FeatureA
services
.AddFeatureAServices();
WebApplication app = builder.Build();
app.MapControllers();
app.Run();
Este ejemplo se basa en lo descrito anteriormente. El ApplicationPartManager
permite agregar AssemblyPart
para ensamblados que no son referencias directas, siendo esto clave para un sistema de plugins o para cargar módulos de características de forma más dinámica.
Los sistemas escalables mediante plugins se verá en otros artículos, pero entender este concepto ayuda a facilitar la implementación de este y otros tipos de software ampliable y dinámico.
Por qué Minimal APIs: Ofrecen una sintaxis concisa para definir endpoints HTTP, y se alinean mejor con la idea de Vertical Slice Architecture, de reducir el boilerplate y mantener la lógica cohesiva. Aunque no estoy completamente de acuerdo con que sea complemtanete una sintaxis concisa, ya que depende mucho de cada implementación, sí creo que los beneficio durante la implementación son indiscutible en la mayoría de los casos que se centran en reducir la base de código.
Por qué se necesita descubrimiento personalizado: ASP.NET Core no tiene un mecanismo automático como las ApplicationParts
para descubrir Minimal APIs existentens en otros ensamblados. Aunque podemos usar para ellos métodos de extensión y otros mecanismos antes mencionados. Qué tan elaborado, dependerá de las necesidades de cada proyecto.
Cómo implementar el descubrimiento personalizado:
Convención y Reflexión:
IEndpointDefinition
) en un proyecto compartido como el ya mencionado SharedKernel
. En cada proyecto de slice, las clases que definen Minimal APIs deberán implementar esta interfaz. En el Program.cs
del proyecto principal, se puede entonces usar reflexión para escanear los ensamblados de de cada slice, encontrar tipos que implementen IEndpointDefinition
, instanciarlos y llamar a un método convenido (ej. MapEndpoints(IEndpointRouteBuilder app)
) para registrar las rutas. Aquí definir las abstracciones con antelación facilitará el proceso de implementación.Source Generators (Generadores de Código Fuente):
Más información acerca de los generadores de código en este enlace.
Ventajas con Proyectos Separados (usando Minimal APIs):
Desventajas y Limitaciones:
Ejemplo de Código Minimalista (Descubrimiento de Minimal APIs por Reflexión):
En SharedKernel.csproj
(o similar):
// SharedKernel/Api/IEndpointDefinition.cs
using Microsoft.AspNetCore.Routing;
namespace SharedKernel.Api;
public interface IEndpointDefinition
{
void MapEndpoints(IEndpointRouteBuilder app);
}
// SharedKernel/Api/EndpointRegistrationExtensions.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace SharedKernel.Api
public static class EndpointRegistrationExtensions
{
public static WebApplication MapAllEndpoints(
this WebApplication app, params Assembly[] assembliesToScan)
{
Type endpointDefinitionType = typeof(IEndpointDefinition);
// Estos objetos solo se usarán una vez al inicio de la aplicación
var endpointDefinitions = new List<IEndpointDefinition>();
foreach (Assembly assembly in assembliesToScan)
endpointDefinitions
.AddRange(
assembly
.ExportedTypes
.Where(t => endpointDefinitionType
.IsAssignableFrom(t)
&& t is {
IsInterface: false,
IsAbstract: false
}
)
.Select(Activator.CreateInstance)
.Cast<IEndpointDefinition>()
);
foreach (IEndpointDefinition definition in endpointDefinitions)
{
definition.MapEndpoints(app);
}
return app;
}
}
En ContextB.Features.csproj
:
// ContextB.Features/FeatureB.cs
namespace ContextB.Features.Api;
// Empieza a parecerse más a los controladores MVC
public sealed class FeatureB : IEndpointDefinition
{
// Solo se requiere el constructor por defecto
public void MapEndpoints(
IEndpointRouteBuilder app
)
{
app.MapGet("/FeatureB",
(SomeService someService) => someService.GetHi()
);
}
}
En MainApiProject/Program.cs
:
// MainApiProject/Program.cs
using ContextB.Features.Extensions;
using SharedKernel.Api;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
IServiceCollection services = builder.Services;
// Opcional si hay que registrar servicios
services
.AddFeatureBServices();
WebApplication app = builder.Build();
app.MapAllEndpoints(
typeof(ContextB.Features.Api.FeatureB).Assembly
);
app.Run();
Este ejemplo ilustra el cómo (reflexión para encontrar implementaciones de IEndpointDefinition
) y el porqué (necesidad de un mecanismo de registro explícito para Minimal APIs en ensamblados separados).
En ContextC.Features.csproj
:
// FeatureC.cs
using ContextC.Features.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
namespace ContextC.Features.Api;
public static class FeatureC
{
public static void MapFeatureCEndpoints(
this IEndpointRouteBuilder app
)
{
app
.AddGet()
//.AddPost();
//.AddPut();
//.AddPatch();
//.AddDelete();
;
}
// Cada método HTTP podría tener su propia clase para mayor control
private static IEndpointRouteBuilder AddGet(this IEndpointRouteBuilder app)
{
app.MapGet("/FeatureC",
(SomeService someService) => someService.GetHi()
);
return app;
}
}
En MainApiProject/Program.cs
:
using ContextC.Features.Api;
using ContextC.Features.Extensions;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
IServiceCollection services = builder.Services;
// Opcional
services
.AddFeatureCServices();
WebApplication app = builder.Build();
app.MapFeatureCEndpoints();
app.Run();
Este enfoque es más directo y también más todoterreno, ya que no usa Reflexión y no depende de un proyecto externo para descubrir los endpoints. El rendimiento podría ser apenas superior durante arranque de la aplicación, pero no habría diferencias durante la ejecución regular del proceso. También se evita la sobreingeniería en la mayoría de los casos.
Característica/Aspecto | ApplicationParts (con Controladores MVC) | Minimal APIs (con descubrimiento personalizado vía reflexión/source generators/métodos de extensión) | Por qué y Cómo Impacta en VSA con Proyectos Separados |
---|---|---|---|
Mecanismo de Descubrimiento | Cómo: Integrado (ApplicationPartManager escanea por tipos ControllerBase ). Por qué: MVC tiene un modelo rico de características que necesitan ser descubiertas. |
Cómo: Implementación manual (reflexión sobre interfaces/atributos o source generators) Por qué: Minimal APIs es "mínimo", no incluye descubrimiento complejo de ensamblados externos por defecto. | ApplicationParts es "listo para usar" para controladores. Minimal APIs requiere un esfuerzo de infraestructura para el descubrimiento, pero ofrece más control |
Tipo de Endpoint Soportado | Controladores MVC. | Endpoints MapGet , MapPost , etc. |
La elección del tipo de endpoint en el slice dicta la estrategia de descubrimiento. |
Complejidad de Configuración Inicial (Descubrimiento) | Baja si son referencias de proyecto. Ya que el sistema de build y MVC lo manejan. | Baja (métodos de extensión) o Moderada (reflexión), a potencialmente más alta source generators), ya que se construye la lógica de descubrimiento. | Minimal APIs requiere más código de infraestructura inicial para el descubrimiento en algunos casos. |
Rendimiento Percibido del Endpoint | Pipeline MVC completo. Podría tener más sobrecarga. | Diseño más ligero puede llevar a mejor rendimiento, aunque no necesariamente. | Para alta sensibilidad al rendimiento, Minimal APIs puede ser preferible. |
Flexibilidad | Menos flexible (deben ser clases Controlador). | Muy flexible (delegados, métodos en clases, etc.). | Minimal APIs ofrece más libertad, alineándose con la idea de VSA de adaptar la implementación a la necesidad del slice. |
Alineación Filosófica con VSA | Moderada. | Alta (endpoints pequeños, cohesivos). | Minimal APIs encaja más naturalmente con el espíritu de VSA. |
Por qué elegir uno u otro:
Controladores MVC con ApplicationParts
:
Minimal APIs con Descubrimiento Personalizado:
Coexistencia:
La decisión se basa en el porqué de las necesidades del proyecto (legacy, rendimiento, complejidad de características) y en cómo se desea gestionar la infraestructura de descubrimiento.
SharedKernel
o utilidades compartidas. Distinguir duplicación "mala" de la "aceptable" que preserva autonomía.Existen variaciones en cómo se implementan los detalles dentro de VSA, utilizando capacidades de .NET:
Estas alternativas se centran en cómo estructurar el código o cómo realizar ciertas tareas (como el registro) utilizando funcionalidades intrínsecas de .NET, sin depender de bibliotecas externas. De hecho, usar generadores de código o reflexión podría ser mucho código extra si no se usará frecuentemente, y quizá, lol métodos de extensión que mencioné antes, sean una solución más polivalente.
La Arquitectura de Vertical Slice en .NET, implementada mediante proyectos separados por contexto, ofrece una vía pragmática para construir sistemas modulares y mantenibles.
Resumen de Hallazgos Clave (Porqués y Cómos):
ApplicationParts
(mecanismo nativo de MVC; ApplicationPartManager
descubre controladores).Recomendaciones Estratégicas (Decisiones Pragmáticas):
ApplicationParts
(configuración en Program.cs
).SharedKernel
y Estrategias para Intereses Transversales (El "Cómo" de la compartición y la consistencia):
SharedKernel
" para código verdaderamente común y estable. Middleware, filtros y decoradores de .NET para intereses transversales.Consideraciones Finales:
La Arquitectura de Vertical Slice, cuando se aborda con un entendimiento claro de sus principios y se utilizan las capacidades del framework .NET de manera pragmática, permite construir sistemas robustos y evolutivos. La elección entre ApplicationParts
para controladores MVC y un descubrimiento personalizado para Minimal APIs es una decisión técnica fundamental, guiada por el por qué de las necesidades de cada slice y el cómo se integran estos en la aplicación principal.
Todo lo mencionado anteriormente se condensó (razonablemente) en el repositorio de Github Vertical Slices en .NET - Github