Dominando Dependency Injection: Transient, Scoped y Singleton en .NET
En .NET Core, AddTransient, AddScoped y AddSingleton no son simples llamadas de configuración, sino los tres métodos del contenedor de Inyección de Dependencias (DI) que controlan el tiempo de vida (lifetime) de los servicios registrados. Elegir el incorrecto es una vía rápida para causar memory leaks, bugs de concurrencia difíciles de reproducir o problemas serios de rendimiento en producción.
El contenedor de DI de ASP.NET Core es el responsable de crear estas instancias, inyectarlas donde se necesiten y destruirlas cuando ya no sean necesarias. El lifetime que elijas define exactamente cuándo se crea una nueva instancia y cuándo se reutiliza una existente. Vamos a ver cada uno en detalle.
AddTransient — Una instancia nueva en cada inyección
Cada vez que el contenedor de DI resuelve el servicio, crea una instancia completamente nueva, independientemente de si ya había creado una antes para la misma petición HTTP.
// Program.cs
builder.Services.AddTransient<IEmailSender, EmailSender>();
// EmailSender.cs
public class EmailSender : IEmailSender
{
private readonly Guid _instanceId = Guid.NewGuid();
public string GetId() => _instanceId.ToString();
public void Send(string to, string body) { /* lógica de envío */ }
}
En el siguiente controlador, si inyectas IEmailSender dos veces, el contenedor instanciará dos objetos distintos con GUIDs diferentes:
public class OrderController : ControllerBase
{
private readonly IEmailSender _sender1;
private readonly IEmailSender _sender2;
public OrderController(IEmailSender sender1, IEmailSender sender2)
{
_sender1 = sender1; // GUID: "abc-123"
_sender2 = sender2; // GUID: "xyz-789" (instancia distinta)
}
}
Cuándo usarlo: Cuando necesites servicios ligeros y sin estado (stateless). Ideal para utilidades, validadores, helpers de formato o manejadores de comandos aislados donde el estado interno no importe.
AddScoped — Una instancia por petición HTTP
Se crea una única instancia por cada petición HTTP. Esta instancia compartirá el mismo estado a lo largo de todos los lugares en los que se solicite durante el flujo del request. Al recibir una nueva petición, se creará una instancia completamente diferente.
// Program.cs
builder.Services.AddScoped<IShoppingCart, ShoppingCart>();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)); // DbContext es Scoped por defecto
Si inyectas IShoppingCart en un controlador y también en un servicio interno de la misma petición, ambos componentes operarán sobre el mismo objeto en memoria:
public class CheckoutService
{
private readonly AppDbContext _db;
private readonly IShoppingCart _cart;
public CheckoutService(AppDbContext db, IShoppingCart cart)
{
_db = db; // misma instancia dentro del request HTTP
_cart = cart; // misma instancia dentro del request HTTP
}
}
⚠️ En aplicaciones de consola o background services (como Worker Services), el concepto de “petición HTTP” no existe de forma predeterminada. En estos escenarios tendrás que abrir el scope manualmente utilizando
IServiceScopeFactory.CreateScope().
Cuándo usarlo: El caso de uso rey es, sin duda, el DbContext de Entity Framework. Fundamental para repositorios, patrones Unit of Work, almacenamiento de datos de sesión por usuario o cualquier otra implementación que exija compartir estado a lo largo de un único request.
AddSingleton — Una instancia para toda la aplicación
Con este lifetime, se creará una sola instancia la primera vez que un componente la solicite (o durante el arranque de la app si lo fuerzas). Esta única instancia sobrevivirá y se reutilizará para todas las peticiones y usuarios mientras la aplicación siga ejecutándose.
// Program.cs
builder.Services.AddSingleton<IConfigurationCache, ConfigurationCache>();
builder.Services.AddSingleton<HttpClient>(); // Uso correcto de cliente HTTP
Dado que es la misma instancia en memoria para un tráfico concurrente enorme, es vital garantizar que sea thread-safe:
// ConfigurationCache.cs — se inicializa UNA VEZ y vive para siempre
public class ConfigurationCache : IConfigurationCache
{
private readonly Dictionary<string, string> _cache;
public ConfigurationCache(IConfiguration config)
{
// Esta inicialización costosa solo se ejecutará una vez
_cache = config.GetSection("AppSettings")
.Get<Dictionary<string, string>>();
}
// ⚠️ DEBE ser thread-safe: múltiples requests acceden simultáneamente
public string Get(string key) => _cache.TryGetValue(key, out var v) ? v : null;
}
Cuándo usarlo: Servicios pesados de instanciar y completamente seguros en entornos multihilo (thread-safe). Cachés en memoria (IMemoryCache), agrupaciones de configuraciones preleídas, contadores de uso global o fábricas de clientes HTTP.
Resumen: Tabla Comparativa
| Característica | AddTransient | AddScoped | AddSingleton |
|---|---|---|---|
| Instancias creadas | Una por cada inyección | Una por petición HTTP | Una para toda la app |
| Retiene estado | ❌ No | ✅ Dentro del request | ✅ Para siempre |
| Thread safety necesario | ❌ No (instancia propia) | ❌ No (un solo hilo por request) | ✅ Sí, obligatorio |
| Casos de uso típicos | Validators, helpers, utilidades | DbContext, repos, Unit of Work | Caché, config, HttpClient |
| Riesgo de memory leak | ⚠️ Si implementa IDisposable | Bajo | Bajo si es stateless |
El anti-patrón más peligroso: Captive Dependency
El problema más oscuro y con peores consecuencias al configurar inyección de dependencias es inyectar un servicio Scoped dentro de un Singleton. A esto se le llama “Captive Dependency”.
// ❌ MAL — Esto rompe el ciclo de vida de Bar
builder.Services.AddSingleton<Foo>();
builder.Services.AddScoped<Bar>();
public class Foo // Singleton
{
private readonly Bar _bar; // Scoped "capturado" ← EL PROBLEMA
public Foo(Bar bar) { _bar = bar; }
}
En el código anterior, Bar debería morir y eliminarse de memoria al terminar el request HTTP. Sin embargo, como Foo es un Singleton, retiene la referencia a esa instancia específica de Bar indefinidamente, pasándola accidentalmente a los siguientes requests de usuarios que no tienen nada que ver.
La solución pasa por inyectar un Scope Factory en el Singleton y abrir el scope localmente:
// ✅ BIEN — Usar IServiceScopeFactory en el Singleton
public class Foo
{
private readonly IServiceScopeFactory _scopeFactory;
public Foo(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public void DoWork()
{
// Abrimos un nuevo entorno scoped contenido
using var scope = _scopeFactory.CreateScope();
var bar = scope.ServiceProvider.GetRequiredService<Bar>();
// bar se usa y se desecha de forma segura al salir de este bloque
}
}
Para detectar esta mala práctica durante la fase de desarrollo, acostúmbrate a configurar la validación de scopes estricta en el Program.cs:
// Program.cs — Detecta captive dependencies al arrancar la app
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
IDisposable y Memory Leaks con Transient
Existe un grave patrón tóxico: registrar un servicio como Transient cuando la clase implementa la interfaz IDisposable.
// ❌ MAL — Memory leak muy previsible
builder.Services.AddTransient<MyDisposableService>();
Por diseño de ASP.NET Core, el contenedor de DI está obligado a rastrear cualquier dependencia inyectada que sea disposable para así llamar a .Dispose() de forma cónsona cuando el lifetime acabe. El problema es que al ser Transient, el framework sigue registrando cada instancia que inyectas para su posterior limpieza (que en rigor solo ocurrirá cuando caiga el scope principal), por lo que se pueden acumular miles de instancias inútiles en memoria.
En este escenario aplica el Factory Pattern, encargante tú mismo del ciclo usando using:
// ✅ BIEN — Usa el factory pattern
builder.Services.AddTransient<Func<MyDisposableService>>(sp =>
() => new MyDisposableService());
public class MyConsumer
{
private readonly Func<MyDisposableService> _factory;
public MyConsumer(Func<MyDisposableService> factory) { _factory = factory; }
public void Execute()
{
using var svc = _factory(); // Instancias y destruyes tú
svc.DoWork();
}
}
Buenas Prácticas
A modo de cierre, anótate estos puntos inamovibles sobre DI en .NET:
- Nunca llames
Dispose()manualmente sobre un servicio inyectado vía constructor. Es propiedad del contenedor, y tú no lo mandas ahí. - Evita el Service Locator pattern. Utilizar abusivamente dependencias del framework como
GetService<T>()en medio del código ensucia y acopla de más tus clases, rompiendo la inyección por constructor. - Evita
BuildServiceProvider()en tuProgram.cs. Solo para resolver los registros, usa las sobrecargas propias del pipeline oIServiceProvider. - Todo Singleton debe blindarse para Thread-Safety. Si mantiene un estado y varios flujos escriben, prepara
ConcurrentDictionaryo asume ellock. - Ante la duda clásica entre
TransientoScopedpara un repositorio de datos, usaScoped. Consumo más óptimo de memoria y persistencia del contexto transaccional en el request HTTP completo.