Inyección de dependencias con Microsoft.Extensions.DependencyInjection - Conceptos básicos

Photo by AltumCode on Unsplash

Inyección de dependencias con Microsoft.Extensions.DependencyInjection - Conceptos básicos

Para mi primer post, he decidido compartir algunos conceptos básicos sobre Dependency Injection en .NET. No entraré en una definición teórica, ya que existen numerosos recursos explicándolo. En su lugar, me centraré en demostrarlo directamente con código.

Para facilitar su comprensión, utilizaré una aplicación de consola como ejemplo. En general, las plantillas del framework, como ASP.NET Core Web API, ya incluyen la biblioteca de Dependency Injection integrada. Esto puede dar la impresión de ser un proceso automático y difícil de entender, especialmente para quienes no están familiarizados con los conceptos fundamentales.

Registro de servicios en el contenedor de dependencias.

Para resolver dependencias en nuestro proyecto, primero es necesario registrar todos los servicios requeridos. En el contexto de Microsoft.Extensions.DependencyInjection, un servicio es un objeto registrado en el contenedor de inyección de dependencias, lo que permite que el framework lo gestione y resuelva automáticamente. Estos servicios suelen encapsular lógica específica y reutilizable, desempeñando un papel crucial en la funcionalidad y la arquitectura del proyecto.

Paso 1. Creación del ServiceCollection y registro de servicios

En .NET, IServiceCollection es la interfaz, la cual pertenece al espacio de nombre Microsoft.Extensions.DependencyInjection, que permite registrar y configurar servicios que serán gestionados por el contenedor de Dependency Injection. Estos servicios pueden ser inyectados en diferentes partes de la aplicación según sea necesario. La forma más común de inyección es a través del constructor (constructor injection), donde las dependencias se pasan como parámetros en el momento de crear una instancia de una clase.

using Microsoft.Extensions.DependencyInjection;

// 1.1 Creación del contenedor de servicios
IServiceCollection serviceCollection = new ServiceCollection();

// 1.2 Registro de dependencias con diferentes ciclos de vida
serviceCollection.AddScoped<IGreetingService, GreetingService>();
serviceCollection.AddTransient<IMyServiceA, MyServiceA>();

No todos los servicios son registrados de la misma manera. Para ello es importante comprender los tres diferentes tipos de ciclo de vida (o lifetimes) disponibles:

  • Singleton: Una única instancia del servicio es creada y reutilizada mientras la aplicación permanezca en ejecución.

  • Scoped: Una nueva instancia es creada para un determinado Scope (usualmente un web request). Los servicios creados en el mismo Scope comparten la misma instancia.

  • Transient: Una nueva instancia es creada cada vez que el servicio es requerido.

// Singleton: Ideal para configuraciones compartidas o servicios de estado único
serviceCollection.AddSingleton<ILogger, ConsoleLogger>();

// Scoped: Útil para servicios dependientes del contexto, como operaciones por request
serviceCollection.AddScoped<IDBConnection, DBConnection>();

// Transient: Adecuado para servicios ligeros que no mantienen estado
serviceCollection.AddTransient<IEmailService, EmailService>();

Para ello, Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions pone a disposición los siguientes métodos de extensión:

  • AddSingleton()

  • AddTransient()

  • AddScoped()

De igual manera, es posible encontrar una lista interesante de métodos en la clase Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions. A continuación, me gustaría destacar los siguientes métodos de extensión:

  • TryAddSingleton()

  • TryAddScoped

  • TryAddTransient()

¿Cuál es la diferencia?

Los métodos, como TryAdd o TryAddSingleton, solo registran un servicio si aún no existe un registro previo para el mismo tipo en el contenedor de inyección de dependencias. Esto evita sobrescribir registros existentes, lo cual es útil en escenarios donde deseas mantener configuraciones previamente definidas.

Por otro lado, los métodos estándar, como AddSingleton, AddScoped o AddTransient, siempre registran el servicio, sobrescribiendo cualquier registro previo para el mismo tipo. En este caso, el último registro realizado será el que se utilice durante la resolución de dependencias.

Es fundamental conocer esta distinción, especialmente en aplicaciones grandes o modulares donde varios componentes pueden registrar servicios. Sobrescribir servicios accidentalmente podría llevar a comportamientos inesperados y dificultar la depuración.

Paso 2. Creación del ServiceProvider.

IServiceProvider es una interfaz en .NET que pertenece al espacio de nombres System y se encarga de resolver los servicios registrados en el contenedor de inyección de dependencias (DI).

Después de registrar todos los servicios en IServiceCollection, el siguiente paso es crear una instancia de IServiceProvider. Una vez que este se ha creado, será posible generar un scope y resolver servicios según sea necesario.

//Paso 2: Creación del IServiceProvider
IServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(validateScopes: true);

El método BuildServiceProvider transforma la configuración almacenada en ServiceCollection en un contenedor de DI real (IServiceProvider), que puede resolver dependencias.

Como medida de seguridad, es importante crear el serviceProvider con validateScopes: true. Esto ayuda a prevenir la resolución accidental de servicios de tipo Scoped directamente desde el contenedor raíz, lo cual puede provocar fugas de memoria (memory leaks) o errores de concurrencia. Es fundamental evitar resolver servicios Scoped desde el contenedor raíz, ya que este no maneja el ciclo de vida para dichos servicios. En su lugar, siempre se debe resolver desde un Scope, como se ilustrará en el paso 3

Pero, ¿Qué es el contenedor raíz o Root Container?

El Root Container es el contenedor principal de inyección de dependencias (DI) que permanece activo durante toda la ejecución de la aplicación. Su responsabilidad principal es gestionar los servicios de tipo Singleton y crear scopes secundarios.

Es importante tener en cuenta que, si se resuelven servicios de tipo Scoped directamente desde el Root Container, estos no se liberarán correctamente, ya que el Root Container no gestiona el ciclo de vida de los servicios Scoped. Por ello, siempre se debe crear un scope para manejar este tipo de servicios de manera adecuada.

Por ejemplo, considera el siguiente escenario.

serviceCollection.TryAddScoped<IGreetingService, GreetingService>();
IServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(validateScopes: true);
IGreetingService greetingService = serviceProvider.GetRequiredService<IGreetingService>();

Al tratar de resolver servicio de tipo scoped IGreetingService desde el Root Container, se genera la siguiente excepción:

System.InvalidOperationException: 'Cannot resolve scoped service 'IGreetingService' from root provider.'

Paso 3. Crear un IServiceScope.

Una vez creado nuestro contenedor de dependencias, procedemos a crear un scope para resolver nuestros servicios de una manera segura.

using (IServiceScope serviceScope = serviceProvider.CreateScope())
{
    // Paso 3: resolución y uso del servicio.
    IGreetingService greetingService = serviceScope.ServiceProvider.GetRequiredService<IGreetingService>();
    greetingService.Greet("Hello World!");
}

serviceProvider.CreateScope() crea un nuevo scope, que es un scope temporal para los servicios con un ciclo de vida scoped. Esto garantiza que cualquier servicio scoped resuelto dentro de este alcance sea gestionado de manera adecuada y se libere automáticamente al finalizar el scope.

El uso de la instrucción using asegura que el alcance se libere correctamente al final del bloque. Esto es especialmente importante para liberar recursos administrados, como conexiones a bases de datos (DbContext), y prevenir fugas de memoria.

En ASP.NET Core, un scope está directamente relacionado con el manejo del ciclo de vida de los servicios en una petición HTTP. Cuando una solicitud HTTP llega a la aplicación, el framework automáticamente crea un nuevo alcance (scope) para manejar los servicios con ciclo de vida scoped. Este alcance vive durante toda la duración de la solicitud y se elimina al final. Por tal motivo, es común decir que el ciclo de vida de un servicio scoped esta íntimamente ligado a la duración de una petición HTTP.

Paso 4. Resolución de servicios

Al momento de resolver servicios, contamos con dos diferentes métodos de extensión contenidos en Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions

  • T GetRequiredService<T>(this IServiceProvider provider): Lanza una excepción cuando el tipo solicitado no puede ser resuelto.

    • System.InvalidOperationException: 'No service for type 'IGreetingService' has been registered.'
  • T? GetService<T>(this IServiceProvider provider): Retorna null cuando el tipo solicitado no puede ser resuelto.

Personalmente, prefiero el uso de GetRequiredService porque asegura que cualquier problema en la configuración de dependencias se detecte rápidamente, evitando excepciones como System.NullReferenceException, que podrían ocultar la verdadera causa del problema.

Conclusión.

En resumen, hemos cubierto los siguientes conceptos clave:

  • IServiceCollection: Es utilizada para registrar servicios en el contenedor de DI, permitiendo configurar diferentes ciclos de vida como Transient, Scoped y Singleton.

  • IServiceProvider: Es la interfaz principal proporcionada por el contenedor de DI, utilizada para resolver instancias de los servicios registrados.

  • IServiceScope: Proporciona un IServiceProvider con un alcance específico (scope) para resolver servicios. Es especialmente útil para manejar servicios scoped, ya que garantiza que estos se eliminen automáticamente al finalizar el scope. IServiceScope es particularmente útil en aplicaciones de consola, procesos en segundo plano, o en servicios de tipo Singleton, donde no hay un ciclo de vida HTTP que gestione automáticamente los scopes.

  • Uso de métodos como AddSingleton y TryAddSingleton para gestionar servicios en aplicaciones más grandes.

  • Importancia de evitar resolver servicios Scoped desde el contenedor raíz.

En el próximo post, me gustaría explorar la interfaz IServiceScopeFactory y cómo podemos aprovecharla para resolver servicios Scoped dentro de servicios de tipo Singleton, evitando problemas comunes como fugas de memoria y gestionando correctamente el ciclo de vida de las dependencias.

Fuentes:

.NET dependency injection Service Lifetimes

ASP.NET Core Dependency Injection: What is the IServiceProvider and how is it Built?

ASP.NET Core Dependency Injection: What is the IServiceCollection?

Dependency injection in ASP.NET Core

Understand dependency injection basics in .NET

"Puedes explorar más sobre este tema en el libro Dependency Injection: Principles, Practices, and Patterns de Steven van Deursen y Mark Seemann.