C#: Explorando Inyección de Dependencias con IServiceCollection y Proveedores de Servicio
Este tutorial te guiará a través del concepto fundamental de la Inyección de Dependencias (DI) en C# y .NET Core, explorando cómo IServiceCollection y los proveedores de servicios se utilizan para construir aplicaciones modulares y mantenibles. Aprenderás las diferentes estrategias de tiempo de vida (scoped, singleton, transient) y cómo aplicarlas con ejemplos prácticos. Al finalizar, tendrás una comprensión sólida para aplicar DI en tus proyectos.
La Inyección de Dependencias (DI) es un patrón de diseño fundamental en el desarrollo de software moderno, especialmente relevante en el ecosistema de C# y .NET Core. Permite construir aplicaciones flexibles, escalables y fáciles de probar al desacoplar los componentes. En esencia, DI invierte la responsabilidad de cómo un objeto obtiene sus dependencias, en lugar de que el objeto las cree por sí mismo, se le inyectan externamente.
Este tutorial se centrará en cómo .NET Core implementa DI a través de IServiceCollection y los distintos tiempos de vida de los servicios, proporcionando ejemplos claros y explicaciones detalladas para que puedas dominar esta técnica esencial.
🚀 ¿Qué es la Inyección de Dependencias (DI)?
Imagina que tienes una clase ReportGenerator que necesita una base de datos para funcionar. Sin DI, ReportGenerator podría crear una instancia de DatabaseService directamente:
public class ReportGenerator
{
private DatabaseService _databaseService;
public ReportGenerator()
{
// ReportGenerator es responsable de crear DatabaseService
_databaseService = new DatabaseService();
}
public void GenerateReport()
{
// Usa _databaseService
_databaseService.GetData();
Console.WriteLine("Reporte generado.");
}
}
public class DatabaseService
{
public void GetData()
{
Console.WriteLine("Obteniendo datos de la base de datos...");
}
}
Este enfoque tiene problemas:
- Acoplamiento Fuerte:
ReportGeneratorestá fuertemente acoplado aDatabaseService. Si cambiasDatabaseService(por ejemplo, usas otra base de datos),ReportGeneratortambién debe cambiar. - Dificultad para Probar: Para probar
ReportGenerator, necesitas una instancia real deDatabaseService, lo que puede ser lento o requerir una base de datos real. Es difícil "simular" elDatabaseService. - Rigidez: Si
DatabaseServicenecesita otras dependencias,ReportGeneratortendría que gestionarlas también.
La Inyección de Dependencias resuelve esto invirtiendo el control. En lugar de que ReportGenerator cree DatabaseService, se le pasa una instancia de DatabaseService (o una abstracción de este) cuando se crea ReportGenerator. La forma más común de hacerlo es a través del constructor.
public interface IDatabaseService
{
void GetData();
}
public class DatabaseService : IDatabaseService
{
public void GetData()
{
Console.WriteLine("Obteniendo datos de la base de datos...");
}
}
public class ReportGenerator
{
private readonly IDatabaseService _databaseService;
public ReportGenerator(IDatabaseService databaseService)
{
// La dependencia se 'inyecta' a través del constructor
_databaseService = databaseService;
}
public void GenerateReport()
{
_databaseService.GetData();
Console.WriteLine("Reporte generado con DI.");
}
}
Ahora, ReportGenerator no sabe cómo se crea IDatabaseService, solo sabe que necesita una instancia que implemente esa interfaz. Esto nos lleva a los beneficios clave:
- Acoplamiento Débil:
ReportGeneratordepende de una abstracción (IDatabaseService), no de una implementación concreta. Esto facilita el cambio de implementaciones. - Testabilidad Mejorada: Puedes inyectar un
MockDatabaseService(una implementación deIDatabaseServicepara pruebas) sin afectar el código deReportGenerator. - Reusabilidad: Los componentes son más fáciles de reusar en diferentes contextos.
🎯 El Contenedor de Inversión de Control (IoC)
Entonces, ¿quién es el encargado de crear y pasar las dependencias? Aquí es donde entra en juego el Contenedor de Inversión de Control (IoC), también conocido como Contenedor DI. En .NET Core, el IoC Container es una parte integral del framework.
El contenedor DI es un framework que se encarga de:
- Registrar Tipos: Le dices al contenedor qué interfaz debe asociarse con qué implementación concreta (ej.
IDatabaseServiceconDatabaseService). - Resolver Dependencias: Cuando una clase requiere una dependencia (ej.
ReportGeneratornecesitaIDatabaseService), el contenedor crea la instancia apropiada y la inyecta. - Gestionar el Tiempo de Vida: El contenedor decide cuándo se crea una instancia, cuándo se reutiliza y cuándo se destruye (esto es crucial y lo veremos en detalle).
✨ IServiceCollection y IServiceProvider en .NET Core
En .NET Core, la Inyección de Dependencias es una característica de primera clase. Los dos componentes clave para entender cómo funciona son IServiceCollection y IServiceProvider.
IServiceCollection
IServiceCollection es una colección de descriptores de servicio. Es donde registras tus servicios y sus dependencias al inicio de tu aplicación (normalmente en el método ConfigureServices de Startup.cs o en Program.cs en .NET 6+).
Piensa en IServiceCollection como una lista de recetas que le das al chef (el IServiceProvider). Cada receta describe cómo crear una instancia de un servicio determinado.
Aquí es donde usas métodos como AddTransient, AddScoped y AddSingleton para registrar tus servicios, que determinan su tiempo de vida.
IServiceProvider
IServiceProvider es el contenedor DI en sí mismo. Es el objeto responsable de resolver las dependencias, es decir, de crear y proporcionar las instancias de los servicios registrados cuando se solicitan.
Una vez que todos los servicios han sido registrados en IServiceCollection, se construye un IServiceProvider a partir de ella. Este IServiceProvider es el que realmente distribuye los servicios a tu aplicación.
// En un entorno de consola, para ilustrar:
using Microsoft.Extensions.DependencyInjection;
// 1. Crear IServiceCollection y registrar servicios
var services = new ServiceCollection();
services.AddSingleton<IDatabaseService, DatabaseService>();
services.AddTransient<ReportGenerator>();
// 2. Construir el IServiceProvider
var serviceProvider = services.BuildServiceProvider();
// 3. Obtener una instancia de ReportGenerator, que resolverá IDatabaseService
var reportGenerator = serviceProvider.GetRequiredService<ReportGenerator>();
reportGenerator.GenerateReport();
// En aplicaciones ASP.NET Core, esto se gestiona automáticamente por el framework
🔄 Tiempos de Vida de los Servicios: Transient, Scoped y Singleton
El tiempo de vida de un servicio registrado con DI define cuándo se crea una instancia del servicio, cuánto tiempo existe y cuándo se destruye. Comprender los diferentes tiempos de vida es crucial para evitar problemas de rendimiento, inconsistencias de estado y fugas de memoria.
AddTransient()
- Definición:
AddTransientcrea una nueva instancia del servicio cada vez que se solicita. - Uso Ideal: Para servicios ligeros y sin estado, o para servicios que necesitan ser completamente independientes de otras instancias.
- Ejemplo: Operaciones únicas, servicios que realizan un solo trabajo o clases utilitarias sin estado.
| Característica | Descripción |
|---|---|
| Instanciación | Cada solicitud obtiene una nueva instancia |
| Estado | Sin estado (o estado efímero dentro de la llamada) |
| Costo de creación | Bajo |
| Memoria | Potencialmente más uso si se solicita mucho |
| Hilos | Seguro para hilos (cada hilo obtiene su propia instancia) |
Escenario: Un servicio de cálculo que procesa datos de forma independiente en cada llamada.
// Registro
services.AddTransient<ITransientService, TransientService>();
// Uso (cada vez que se pide, se crea uno nuevo)
var service1 = serviceProvider.GetRequiredService<ITransientService>();
var service2 = serviceProvider.GetRequiredService<ITransientService>();
Console.WriteLine($"Transient 1 Hash: {service1.GetHashCode()}"); // Hash único
Console.WriteLine($"Transient 2 Hash: {service2.GetHashCode()}"); // Hash único y diferente
AddScoped()
- Definición:
AddScopedcrea una instancia del servicio una vez por alcance (scope). En el contexto de ASP.NET Core, un alcance generalmente corresponde a una solicitud HTTP. - Uso Ideal: Para servicios que necesitan mantener un estado por solicitud, como servicios de contexto de base de datos (
DbContext) o servicios que encapsulan una transacción unitaria. - Ejemplo: Un servicio que gestiona la información del usuario actual para una solicitud web.
| Característica | Descripción |
|---|---|
| Instanciación | Una vez por alcance (ej. por solicitud HTTP) |
| Estado | Puede mantener estado dentro del alcance |
| Costo de creación | Moderado (una vez por alcance) |
| Memoria | Liberado al finalizar el alcance |
| Hilos | Seguro para hilos dentro de un mismo alcance |
Escenario: Un UserService que carga los detalles del usuario al inicio de una solicitud y los reutiliza durante toda esa solicitud.
// Registro
services.AddScoped<IScopedService, ScopedService>();
// Uso (simulando un alcance de solicitud)
using (var scope = serviceProvider.CreateScope())
{
var scopedServiceProvider = scope.ServiceProvider;
var serviceA = scopedServiceProvider.GetRequiredService<IScopedService>();
var serviceB = scopedServiceProvider.GetRequiredService<IScopedService>();
Console.WriteLine($"Scoped A Hash: {serviceA.GetHashCode()}"); // Mismo hash
Console.WriteLine($"Scoped B Hash: {serviceB.GetHashCode()}"); // Mismo hash
}
using (var scope = serviceProvider.CreateScope())
{
var scopedServiceProvider = scope.ServiceProvider;
var serviceC = scopedServiceProvider.GetRequiredService<IScopedService>();
Console.WriteLine($"Scoped C Hash (nuevo scope): {serviceC.GetHashCode()}"); // Nuevo hash, diferente a A y B
}
AddSingleton()
- Definición:
AddSingletoncrea una única instancia del servicio para toda la vida de la aplicación. - Uso Ideal: Para servicios que son costosos de crear, que no tienen estado o que necesitan compartir un estado global a través de toda la aplicación.
- Ejemplo: Configuraciones de aplicación, caches de datos, servicios de logging, o servicios de autenticación que no dependen del contexto de una solicitud específica.
| Característica | Descripción |
|---|---|
| Instanciación | Una única instancia para toda la vida de la aplicación |
| Estado | Puede mantener estado global (¡cuidado con la concurrencia!) |
| Costo de creación | Alto (se crea una sola vez al inicio) |
| Memoria | Persiste durante toda la vida de la app |
| Hilos | No seguro para hilos si mantiene estado modificable. Requiere sincronización explícita. |
Escenario: Un servicio de configuración que carga parámetros al inicio de la aplicación y los sirve a todas las partes que los necesiten.
// Registro
services.AddSingleton<ISingletonService, SingletonService>();
// Uso (siempre la misma instancia)
var serviceX = serviceProvider.GetRequiredService<ISingletonService>();
var serviceY = serviceProvider.GetRequiredService<ISingletonService>();
Console.WriteLine($"Singleton X Hash: {serviceX.GetHashCode()}"); // Mismo hash
Console.WriteLine($"Singleton Y Hash: {serviceY.GetHashCode()}"); // Mismo hash
🛠️ Implementación Práctica en ASP.NET Core
Vamos a ver cómo se aplican estos conceptos en un proyecto real de ASP.NET Core. Crearemos una API simple y registraremos servicios con diferentes tiempos de vida.
Paso 1: Crear un nuevo proyecto ASP.NET Core Web API
Abra Visual Studio o use la CLI de .NET:
dotnet new webapi -n DependencyInjectionDemo
cd DependencyInjectionDemo
Paso 2: Definir las interfaces y las implementaciones de los servicios
Cree una nueva carpeta Services y dentro de ella, los siguientes archivos:
ITimeService.cs
namespace DependencyInjectionDemo.Services;
public interface ITimeService
{
string GetCurrentTime();
}
TransientTimeService.cs
namespace DependencyInjectionDemo.Services;
public class TransientTimeService : ITimeService
{
private readonly Guid _id;
public TransientTimeService()
{
_id = Guid.NewGuid();
}
public string GetCurrentTime()
{
return $"Transient Time: {DateTime.Now:HH:mm:ss.fff} - ID: {_id}";
}
}
ScopedTimeService.cs
namespace DependencyInjectionDemo.Services;
public class ScopedTimeService : ITimeService
{
private readonly Guid _id;
public ScopedTimeService()
{
_id = Guid.NewGuid();
}
public string GetCurrentTime()
{
return $"Scoped Time: {DateTime.Now:HH:mm:ss.fff} - ID: {_id}";
}
}
SingletonTimeService.cs
namespace DependencyInjectionDemo.Services;
public class SingletonTimeService : ITimeService
{
private readonly Guid _id;
public SingletonTimeService()
{
_id = Guid.NewGuid();
}
public string GetCurrentTime()
{
return $"Singleton Time: {DateTime.Now:HH:mm:ss.fff} - ID: {_id}";
}
}
Paso 3: Registrar los servicios en Program.cs
Modifique el archivo Program.cs para registrar estos servicios con sus respectivos tiempos de vida:
using DependencyInjectionDemo.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Registro de servicios con diferentes tiempos de vida
builder.Services.AddTransient<ITimeService, TransientTimeService>();
builder.Services.AddScoped<ITimeService, ScopedTimeService>();
builder.Services.AddSingleton<ITimeService, SingletonTimeService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Paso 4: Crear un controlador para probar los servicios
Modifique el controlador WeatherForecastController.cs o cree uno nuevo llamado TimeController.cs.
TimeController.cs
using DependencyInjectionDemo.Services;
using Microsoft.AspNetCore.Mvc;
namespace DependencyInjectionDemo.Controllers;
[ApiController]
[Route("[controller]")]
public class TimeController : ControllerBase
{
private readonly TransientTimeService _transientService1;
private readonly TransientTimeService _transientService2;
private readonly ScopedTimeService _scopedService1;
private readonly ScopedTimeService _scopedService2;
private readonly SingletonTimeService _singletonService1;
private readonly SingletonTimeService _singletonService2;
public TimeController(
TransientTimeService transientService1,
TransientTimeService transientService2,
ScopedTimeService scopedService1,
ScopedTimeService scopedService2,
SingletonTimeService singletonService1,
SingletonTimeService singletonService2)
{
_transientService1 = transientService1;
_transientService2 = transientService2;
_scopedService1 = scopedService1;
_scopedService2 = scopedService2;
_singletonService1 = singletonService1;
_singletonService2 = singletonService2;
}
[HttpGet]
public IActionResult Get()
{
var results = new List<string>
{
_transientService1.GetCurrentTime(),
_transientService2.GetCurrentTime(),
_scopedService1.GetCurrentTime(),
_scopedService2.GetCurrentTime(),
_singletonService1.GetCurrentTime(),
_singletonService2.GetCurrentTime()
};
return Ok(results);
}
}
Aquí, estamos inyectando dos instancias de cada tipo para demostrar sus comportamientos.
Paso 5: Ejecutar la aplicación y observar los resultados
Ejecute la aplicación (dotnet run o desde Visual Studio) y navegue a la URL del TimeController (ej. https://localhost:XXXX/Time).
Resultados esperados de una solicitud (GET /Time):
[
"Transient Time: 10:30:01.123 - ID: 0a1b2c3d-....-0001",
"Transient Time: 10:30:01.123 - ID: e4f5g6h7-....-0002",
"Scoped Time: 10:30:01.123 - ID: 11223344-....-abcd",
"Scoped Time: 10:30:01.123 - ID: 11223344-....-abcd",
"Singleton Time: 10:30:01.123 - ID: ffgg7788-....-99aa",
"Singleton Time: 10:30:01.123 - ID: ffgg7788-....-99aa"
]
Observaciones:
- Transient: Las dos instancias
_transientService1y_transientService2tienen IDs diferentes. Esto confirma que se crea una nueva instancia cada vez que se solicita. - Scoped: Las dos instancias
_scopedService1y_scopedService2tienen el mismo ID. Esto es porque ambas se solicitaron dentro del mismo alcance (la misma solicitud HTTP). Si hicieras otra solicitud HTTP, verías un nuevo ID de alcance diferente al anterior. - Singleton: Las dos instancias
_singletonService1y_singletonService2tienen el mismo ID. Este ID será el mismo para todas las solicitudes durante la vida de la aplicación.
💡 Patrones Avanzados y Consideraciones
Inyección de IEnumerable<T>
Puedes inyectar una colección de todas las implementaciones registradas para una interfaz específica. Esto es útil para implementar el patrón de estrategia o para tener múltiples manejadores para un evento.
// En Program.cs
builder.Services.AddTransient<INotificationSender, EmailSender>();
builder.Services.AddTransient<INotificationSender, SmsSender>();
// En una clase cliente:
public class NotificationProcessor
{
private readonly IEnumerable<INotificationSender> _senders;
public NotificationProcessor(IEnumerable<INotificationSender> senders)
{
_senders = senders;
}
public void SendNotifications(string message)
{
foreach (var sender in _senders)
{
sender.Send(message);
}
}
}
Fábricas de Servicios (AddFactory) o Funciones de Fábrica
Para escenarios más complejos donde la creación de un servicio depende de lógica en tiempo de ejecución (ej. un parámetro), puedes usar funciones de fábrica.
// En Program.cs
builder.Services.AddTransient<IDatabaseConnection>(serviceProvider =>
{
var config = serviceProvider.GetRequiredService<IConfiguration>();
var connectionString = config.GetConnectionString("DefaultConnection");
return new SqlServerConnection(connectionString);
});
Inyección de Dependencias y IHttpClientFactory
En .NET Core, IHttpClientFactory es la forma recomendada de trabajar con HttpClient. IHttpClientFactory se integra perfectamente con DI y resuelve problemas comunes como el agotamiento de sockets. Debes registrar tus HttpClients usando AddHttpClient.
// En Program.cs
builder.Services.AddHttpClient<IMyApiService, MyApiService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// En MyApiService.cs
public class MyApiService : IMyApiService
{
private readonly HttpClient _httpClient;
public MyApiService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetDataAsync()
{
return await _httpClient.GetStringAsync("data");
}
}
Evitar la "Service Location" Anti-patrón
Aunque IServiceProvider puede usarse para resolver servicios manualmente (serviceProvider.GetRequiredService<T>()), esto debe evitarse en el código de aplicación principal. Se conoce como el anti-patrón "Service Location". La inyección a través del constructor es preferible porque hace que las dependencias sean explícitas y facilita la prueba.
¿Cuándo es aceptable usar Service Location?
En unos pocos escenarios específicos, puede ser necesario usar `IServiceProvider` directamente:- En la raíz de la aplicación: Por ejemplo, en el método
MainoProgram.cspara obtener el punto de entrada de la aplicación. - Middleware o filtros: Donde la inyección de constructor directo puede no ser posible o deseable para cada dependencia.
- Fábricas personalizadas: Cuando necesitas crear objetos complejos y sus dependencias de forma dinámica.
Incluso en estos casos, es preferible inyectar un IServiceProvider (o IServiceScopeFactory) y luego crear un nuevo alcance (CreateScope()) para resolver los servicios dentro de él, especialmente para servicios Scoped y Transient.
✅ Buenas Prácticas y Consejos
- Principio de Responsabilidad Única (SRP): Cada clase debe tener una sola razón para cambiar. DI te ayuda a lograrlo al inyectar las dependencias, manteniendo tus clases enfocadas.
- Principio de Inversión de Dependencias (DIP): Depende de abstracciones, no de implementaciones concretas. Usa interfaces siempre que sea posible para tus servicios.
- Evita el anti-patrón "Constructor Over-Injection": Si una clase tiene demasiadas dependencias en su constructor, es una señal de que probablemente tiene demasiadas responsabilidades y debería ser refactorizada. Más de 3-5 dependencias es una señal de alerta.
- Entiende los Tiempos de Vida: Asegúrate de que el tiempo de vida de un servicio sea apropiado para su uso. Inyectar un servicio
ScopedoTransienten unSingletonpuede llevar a errores sutiles (el problema de captura), ya que la instanciaSingletonretendrá la referencia a la instancia de vida más corta más allá de su alcance previsto. - Disponibilidad de
IDisposable: Los servicios que implementanIDisposableserán correctamente gestionados y eliminados por el contenedor DI cuando su alcance finalice (paraScopedyTransient) o cuando la aplicación se detenga (paraSingleton). - Registra solo lo necesario: No registres todas las clases de tu aplicación si no se van a inyectar. Esto puede reducir el tiempo de inicio y el uso de memoria.
📝 Resumen de Tiempos de Vida
| Tiempo de Vida | Cuándo se crea | Cuándo se elimina | Uso Típico |
|---|---|---|---|
| Transient | Cada vez que se solicita | Al final de la solicitud/alcance que lo pidió | Servicios ligeros sin estado, operaciones únicas |
| Scoped | Una vez por alcance (ej. solicitud HTTP) | Al finalizar el alcance (ej. fin de solicitud) | DbContext, servicios por solicitud con estado |
| Singleton | Una única vez al inicio de la aplicación | Al apagarse la aplicación | IConfiguration, caches, loggers, servicios globales |
¡Felicidades! Has llegado al final de este tutorial sobre Inyección de Dependencias en C# y .NET Core. Ahora tienes una base sólida para entender y aplicar este patrón esencial en tus proyectos.
Preguntas Frecuentes:
¿Por qué la DI es tan importante para la testabilidad?
Porque permite reemplazar fácilmente las dependencias reales por *mocks* o *stubs* (versiones simuladas) en las pruebas unitarias. Por ejemplo, en lugar de una `IDatabaseService` real que interactúa con una base de datos, puedes inyectar un `MockDatabaseService` que devuelve datos predefinidos, haciendo que tus pruebas sean rápidas, aisladas y deterministas.¿Puedo usar mi propio contenedor DI en .NET Core?
Sí, .NET Core está diseñado para ser extensible. Puedes reemplazar el contenedor DI predeterminado por uno de terceros como Autofac, Ninject, o DryIoc. Esto se hace llamando a `UseServiceProviderFactory` en tu `HostBuilder`.¿Qué es el problema de "captura" (_capturing_)?
Ocurre cuando un servicio con un tiempo de vida más largo (ej. `Singleton`) inyecta y retiene una referencia a un servicio con un tiempo de vida más corto (ej. `Scoped` o `Transient`). Esto significa que la instancia de vida más corta vivirá tanto como la `Singleton`, lo cual puede llevar a problemas de estado, recursos no liberados o comportamientos inesperados, ya que la instancia `Scoped` o `Transient` no se recreará como se espera en cada nuevo alcance o solicitud.Tutoriales relacionados
- Delegados y Eventos en C#: Construyendo Arquitecturas Flexibles y Reactivasintermediate18 min
- Programación Asíncrona en C#: Desmitificando async/await para un Rendimiento Óptimointermediate20 min
- Dominando LINQ en C#: Consultas Potentes y Eficientes para Manipular Datosintermediate20 min
- C#: Construyendo APIs RESTful con ASP.NET Core y Entity Framework Coreintermediate25 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!