La "arquitectura limpia" es un concepto que, aunque abstracto, juega un papel crucial en la manera en que organizamos el código y establecemos las relaciones entre sus componentes. Aunque existen diversas formas de implementar arquitecturas limpias, cada una de ellas con su propia visión y enfoque, el principio fundamental que las une es la independencia: las capas de la aplicación deben ser desacopladas tanto de la tecnología como de las fuentes de datos externas, garantizando así un desarrollo más flexible y escalable.

No existe una única verdad sobre cómo implementar una arquitectura limpia. La interpretación que se presenta aquí proviene de la experiencia adquirida a lo largo de los años y se basa en una visión pragmática de la arquitectura, que se ajusta a las necesidades de proyectos web, especialmente aquellos enfocados en el desarrollo de APIs con complejidad baja a intermedia.

Una de las claves fundamentales es lograr que cada capa de la aplicación sea independiente. Esta independencia se debe de entender en varios niveles:

  1. Independencia de la interfaz de usuario: La interfaz de usuario, ya sea una API, una aplicación de escritorio u otro tipo de presentación, debe funcionar de manera autónoma con respecto a la lógica central de la aplicación (como la lógica de negocios o el acceso a datos). Esta separación permite que cualquier cambio en la interfaz no afecte directamente a la funcionalidad del núcleo de la aplicación.

  2. Independencia de bibliotecas y frameworks externos: Las aplicaciones no deben depender en exceso de bibliotecas o frameworks específicos. Si la aplicación está fuertemente acoplada a una tecnología particular, el mantenimiento y la expansión se vuelven más complicados con el tiempo. La solución a esto radica en abstraer esas dependencias, de manera que se puedan intercambiar o modificar sin afectar otras partes del sistema.

  3. Independencia del acceso a datos externos: Un sistema bien diseñado debe permitir cambiar la fuente de datos (por ejemplo, cambiar de una base de datos relacional a una base de datos no relacional) sin que esto implique un trabajo adicional significativo. Para ello, la abstracción juega un papel crucial, permitiendo que el acceso a los datos esté desacoplado del resto de la aplicación.

  4. Pruebas independientes: El sistema debe permitir que cada componente sea probado de manera aislada. Las pruebas unitarias, que dependen de la abstracción, son una herramienta clave para garantizar que las partes de la aplicación se comporten correctamente sin interferir con otros módulos.

En la práctica, esto implica diseñar la aplicación en varias capas claramente diferenciadas. Mi enfoque personal, que denomino "mi arquitectura limpia", divide la aplicación en un mínimo de cuatro capas:

  • Capa de Dominio: Aquí residen los objetos del dominio, las interfaces de repositorios (para el acceso a datos) y las interfaces de servicios. Esta capa es completamente independiente y no depende de ninguna otra capa, funcionando como la base sólida de la aplicación.

  • Capa de Presentación: En este caso, se trata de una capa web basada en ASP.NET Core que expone las APIs. Aunque esta capa depende de las demás para implementarse correctamente, su interacción con ellas se realiza únicamente a través de contratos y abstracciones. Toda la configuración que conecta las abstracciones con las implementaciones concretas se define en el archivo de configuración principal de la aplicación.

  • Capa de Lógica de Negocios: Esta capa es responsable de la implementación de las reglas de negocio y de orquestar acciones paso a paso, utilizando componentes que realizan tareas específicas como el acceso a datos, el almacenamiento en caché o el registro de logs. Es crucial que esta capa solo dependa de la capa de dominio y nunca de ninguna tecnología de infraestructura específica.

  • Capas de Infraestructura: Estas capas implementan tecnologías concretas (como bases de datos SQL o acceso a servicios HTTP) y deben depender exclusivamente de la capa de dominio, que define las abstracciones necesarias para interactuar con ellas.

A veces, puede ser útil incluir una capa adicional denominada Herramientas, que no depende de ninguna tecnología ni de la lógica de negocio, sino que proporciona funcionalidades reutilizables, como clases auxiliares para la manipulación de datos, expresiones regulares, conversiones de formatos, etc.

El principal beneficio de esta estructura es la independencia y la capacidad de cambiar una capa sin afectar las demás. Al no depender de tecnologías específicas, la aplicación se vuelve más flexible, fácil de mantener y ampliar.

A lo largo de este libro, se exploran los detalles de cómo implementar esta arquitectura en diferentes contextos. Sin embargo, siempre se debe tener en mente el principio de desacoplamiento, que es lo que realmente marca la diferencia en el desarrollo de software bien estructurado.

Es importante también reconocer que, aunque el enfoque descrito en este texto puede parecer sencillo, la implementación de una arquitectura limpia puede volverse compleja en proyectos de mayor envergadura. Aquí es donde entran en juego otros patrones de diseño que, aunque no se profundizará en ellos en este libro, también juegan un papel importante en el diseño de software.

Los patrones de diseño proporcionan soluciones estandarizadas a problemas recurrentes en el desarrollo de software. Algunos de estos patrones, como el patrón de repositorio, pueden ayudar a implementar la abstracción del acceso a datos, mientras que el patrón de inyección de dependencias puede facilitar la implementación del desacoplamiento entre capas. Aunque no es necesario conocer todos los patrones de diseño, entender algunos de los más comunes puede mejorar significativamente la calidad y flexibilidad del código.

¿Cómo implementar y gestionar limitadores de tasa y manejo de errores en ASP.NET Core 8?

El modelo de concurrencia es el limitador más sencillo de configurar, ya que solo requiere definir las opciones QueueLimit y PermitLimit, como se muestra en el ejemplo 5-31. Con este modelo, el control de las solicitudes concurrentes se establece de manera eficiente, permitiendo gestionar la cantidad de solicitudes que se pueden realizar simultáneamente y las que pueden ser aceptadas en la cola.

csharp
builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests; options.OnRejected = async (context, token) => { await context.HttpContext.Response.WriteAsync("Demasiadas solicitudes. Por favor, inténtelo más tarde."); }; options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => { var priceTierService = httpContext.RequestServices.GetRequiredService(); var ip = httpContext.Connection.RemoteIpAddress.ToString(); var priceTier = priceTierService.GetPricingTier(ip); return priceTier switch {
PricingTier.Paid => RateLimitPartition.GetConcurrencyLimiter(ip, _ => new ConcurrencyLimiterOptions { QueueLimit = 10, PermitLimit = 50 }),
PricingTier.Free => RateLimitPartition.GetConcurrencyLimiter(ip, _ =>
new ConcurrencyLimiterOptions { QueueLimit = 0, PermitLimit = 10 }) }; }); });

Este fragmento de código configura un limitador de tasa global utilizando el modelo de concurrencia, permitiendo ajustar los límites según el tipo de cliente (por ejemplo, clientes de pago o gratuitos). Así, un cliente con un nivel de suscripción más bajo podría estar limitado a una cantidad menor de solicitudes concurrentes, mientras que un cliente de pago tiene un mayor umbral.

Es importante destacar que la implementación de un limitador de tasa no solo protege tu aplicación contra abusos, sino que también asegura que los recursos se distribuyan de manera justa entre los usuarios, evitando que un solo usuario consuma demasiados recursos. Este tipo de control es crucial, sobre todo cuando se manejan servicios en la nube o sistemas de gran escala.

Además de los limitadores de tasa, la gestión global de errores es otro aspecto fundamental en el desarrollo de aplicaciones robustas. En ASP.NET Core 8, manejar errores de manera global es sencillo y evita la repetición de código en cada controlador o servicio. Al utilizar la clase ProblemDetails, es posible devolver errores estandarizados al cliente en formato JSON, lo cual facilita la interoperabilidad y permite que los desarrolladores del cliente manejen los errores de manera consistente.

csharp
public interface IExceptionHandler {
ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken); }

La interfaz IExceptionHandler se debe implementar para capturar excepciones globalmente y devolver una respuesta formateada. El siguiente ejemplo muestra cómo se configura un manejador de excepciones por defecto:

csharp
public class DefaultExceptionHandler : IExceptionHandler {
public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) {
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails { Status = (int)HttpStatusCode.InternalServerError, Type = exception.GetType().Name, Title = "Ocurrió un error inesperado", Detail = exception.Message, Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}" }); return true; } }

El uso de ProblemDetails sigue un estándar de RFC, lo que garantiza que el cliente recibirá respuestas de error bien estructuradas y comprensibles. Cuando una excepción es lanzada, este manejador responde con un código de estado 500 (Internal Server Error), proporcionando detalles sobre el tipo de error y su causa.

La implementación de manejadores de excepciones personalizados también permite capturar errores específicos. Por ejemplo, el manejo de excepciones de tiempo de espera es fundamental, ya que puede ocurrir que la API no responda en un tiempo razonable debido a problemas en el servidor, no en el cliente. En lugar de devolver un error 408 (Request Timeout), que implicaría un problema del cliente, es más apropiado devolver un error 503 (Service Unavailable) en caso de un tiempo de espera prolongado.

csharp
public class TimeOutExceptionHandler : IExceptionHandler {
public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { if (exception is TimeoutException) { httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; await httpContext.Response.WriteAsJsonAsync(new ProblemDetails { Status = (int)HttpStatusCode.ServiceUnavailable, Type = exception.GetType().Name, Title = "Ocurrió un error de tiempo de espera", Detail = exception.Message, Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}" }); } } }

Para habilitar estos manejadores de excepciones en la pipeline de ASP.NET Core, se debe configurar el middleware correspondiente:

csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler(); var app = builder.Build(); app.UseExceptionHandler(opt => { }); app.Run();

La cadena de manejadores de excepciones también permite configurar distintos comportamientos para diferentes tipos de errores. Esto es útil cuando se requiere un tratamiento específico para errores particulares, como las excepciones de tiempo de espera o errores relacionados con la base de datos.

Un aspecto crucial que no debe pasarse por alto es que, aunque la implementación de limitadores de tasa y manejo de errores es importante, es necesario mantener un equilibrio. Un límite de tasa demasiado estricto puede afectar negativamente la experiencia del usuario, especialmente en aplicaciones que requieren alta disponibilidad o en entornos donde los usuarios legítimos pueden verse perjudicados por limitaciones severas. Por lo tanto, los parámetros de QueueLimit y PermitLimit deben ser ajustados de acuerdo con las necesidades específicas del servicio y los tipos de usuarios.

Además, la capacidad de personalizar la respuesta ante errores y la implementación de un sistema de manejo de excepciones robusto no solo mejora la fiabilidad de la API, sino que también facilita el diagnóstico y la resolución de problemas, brindando una experiencia más profesional y confiable tanto para los desarrolladores como para los usuarios finales.

¿Cómo gestionar tareas en segundo plano y la integración de canales en una aplicación ASP.NET Core?

El uso de tareas en segundo plano es una de las características esenciales cuando se requiere realizar operaciones largas o periódicas en aplicaciones web, sin afectar la experiencia del usuario ni la respuesta de la aplicación. En ASP.NET Core, una manera eficiente de manejar estas tareas es utilizando la interfaz IHostedService, que permite ejecutar trabajos en segundo plano de forma controlada, a través de la implementación de la clase abstracta BackgroundService.

Para comenzar, se necesita descargar el paquete NuGet Microsoft.Extensions.Hosting.Abstractions, que proporciona la interfaz IHostedService. Esta interfaz, a su vez, está compuesta por tres métodos clave: StartAsync, StopAsync y ExecuteAsync. El primero y el segundo se ejecutan automáticamente al inicio y cierre de la aplicación, mientras que el método ExecuteAsync es el que debemos implementar para ejecutar las tareas en segundo plano. Este es el punto en el que definimos nuestras operaciones de larga duración, las cuales se ejecutarán repetidamente hasta que la tarea sea cancelada.

La clase CountryFileIntegrationBackgroundService es un ejemplo de cómo organizar una tarea en segundo plano para gestionar la integración de archivos en una aplicación. En su forma básica, el servicio se ve como un esqueleto de tarea, que se ejecuta mientras no se le indique que se cancele. Esto significa que, si bien el servicio no se interrumpe si se cierra el navegador, sí lo hace cuando se detiene la aplicación ASP.NET Core.

Sin embargo, es importante tener en cuenta que, como se trata de una tarea de fondo, la instancia de este servicio se comporta como un Singleton. Esto implica que, a diferencia de los servicios HTTP que requieren una nueva instancia por cada solicitud, las tareas en segundo plano comparten una única instancia a lo largo de toda la vida de la aplicación. Este comportamiento puede ser un desafío si se necesitan instancias de servicios con un ciclo de vida más corto, como los de tipo Scoped. Para resolver esto, es posible inyectar el IServiceProvider dentro del servicio en segundo plano, lo que permite crear un alcance temporal para cada tarea de fondo.

Este enfoque de inyectar el IServiceProvider permite acceder a servicios específicos que tienen un ciclo de vida Scoped. En el ejemplo de CountryFileIntegrationBackgroundService, se utiliza el servicio ICountryService para procesar archivos de manera continua en el fondo. Este servicio se inyecta y utiliza dentro de un alcance, asegurando que se cree una nueva instancia cada vez que se necesite para realizar el procesamiento de datos, sin comprometer el ciclo de vida del servicio global.

Un desafío adicional surge cuando se considera cómo las tareas en segundo plano, que funcionan de manera independiente, pueden recibir datos de la aplicación principal. Aquí entra en juego la característica de .NET conocida como Channels. Los Channels permiten que diferentes partes de una aplicación se comuniquen de manera asíncrona, enviando y recibiendo mensajes entre sí. La comunicación se realiza mediante el uso de la clase Channel dentro del espacio de nombres System.Threading.Channels.

En una arquitectura como la descrita, el servicio en segundo plano puede leer y procesar mensajes que se envían desde otras partes de la aplicación. Estos mensajes pueden ser archivos, representados como flujos de datos (streams), que se envían a través de un canal. A través de un canal, la aplicación puede publicar los archivos que necesita procesar, mientras que la tarea en segundo plano los consume y ejecuta las operaciones correspondientes.

El canal utilizado en este caso es de tipo "unbounded" (sin límites), lo que significa que puede recibir un número ilimitado de mensajes. Esto es útil cuando no se sabe de antemano cuántos archivos se procesarán, lo que proporciona una solución flexible y eficiente para manejar un gran volumen de datos. A través de la clase CountryFileIntegrationChannel, se gestiona la inserción y la lectura de los mensajes en el canal.

Es importante destacar que el uso de canales no solo permite la comunicación entre diferentes partes de la aplicación, sino que también facilita la integración entre las tareas de fondo y las solicitudes HTTP que gestionan los datos en la aplicación. Los canales aseguran que los archivos sean procesados de manera asíncrona, sin bloquear el flujo principal de la aplicación ni afectar la experiencia del usuario.

En resumen, al combinar IHostedService, tareas en segundo plano, y Channels, se pueden construir soluciones robustas para procesar grandes volúmenes de datos sin interrumpir la operación de la aplicación. Esto abre posibilidades para realizar integraciones complejas, como la ingestión de archivos o el procesamiento de datos en tiempo real, mientras se mantiene una arquitectura limpia y eficiente.

Es fundamental que el desarrollador comprenda que, aunque las tareas en segundo plano y los canales permiten manejar datos de manera eficiente, se debe gestionar adecuadamente el ciclo de vida de los servicios y la creación de alcances temporales, especialmente cuando se trabajan con servicios Scoped. Además, la configuración y el uso de canales deben realizarse de manera adecuada para asegurar que la aplicación sea capaz de manejar la carga de trabajo de manera eficiente y sin bloqueos.