Dependency Injection en .NET: errores que vemos seguido
Dependencias cautivas, DbContext compartido entre hilos y constructores con diez parámetros: errores de inyección de dependencias que fallan en producción.
El contenedor de dependencias de ASP.NET Core es simple a propósito, y eso es una virtud. El problema es que esa simplicidad hace fácil registrar algo mal sin que nada falle al compilar ni en las primeras pruebas.
Los errores de esta lista tienen algo en común: funcionan en desarrollo. Fallan con varios usuarios a la vez, después de horas de ejecución o solo en ciertos endpoints.
Repaso rápido de los tiempos de vida
| Tiempo de vida | Una instancia por... | Ejemplo típico |
|---|---|---|
Singleton |
Toda la aplicación | Configuración, cachés, clientes sin estado |
Scoped |
Petición HTTP | DbContext, unidad de trabajo, usuario actual |
Transient |
Cada vez que se resuelve | Servicios livianos sin estado |
La regla de oro: un servicio no debe depender de otro con un tiempo de vida más corto.
Error 1: la dependencia cautiva
// ❌ Singleton que recibe un servicio scoped
builder.Services.AddSingleton<PriceCalculator>();
builder.Services.AddScoped<AppDbContext>();
public class PriceCalculator(AppDbContext db) { ... }
El singleton se crea una vez y guarda para siempre el DbContext que recibió en la primera petición. Desde ese momento:
- Todas las peticiones comparten el mismo
DbContext, que no es thread-safe. - Su change tracker acumula entidades de horas de tráfico.
- Si el contexto se libera al terminar el primer scope, las siguientes llamadas fallan con
ObjectDisposedException.
En desarrollo, ASP.NET Core detecta esto al arrancar porque ValidateScopes está activo por defecto en el entorno Development. En producción no. Conviene activarlo también al construir el host en las pruebas de integración:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true; // falla al arrancar si falta un registro
});
ValidateOnBuild tiene un costo al iniciar la aplicación, pero convierte un error de ejecución en uno de arranque, que es mucho más fácil de diagnosticar.
Error 2: un singleton que necesita algo scoped
A veces el singleton es correcto (un servicio en segundo plano, una caché) y sí necesita acceso a la base de datos. La solución no es cambiar el DbContext a singleton, sino crear un scope cuando se necesite:
public class NightlyCleanup(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Un scope por ejecución: DbContext nuevo y liberado al terminar
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Sessions.Where(s => s.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken);
}
}
}
Error 3: transient desechables resueltos desde la raíz
Si un servicio Transient implementa IDisposable y lo resuelves desde el proveedor raíz (no desde un scope), el contenedor guarda una referencia para liberarlo cuando se apague la aplicación. Cada resolución deja una instancia viva en memoria. Es una fuga de memoria lenta, de las que aparecen después de días.
Ocurre sobre todo con app.Services.GetRequiredService<T>() en código que se ejecuta muchas veces. Fuera del arranque, resuelve siempre desde un scope.
Error 4: new HttpClient() en cada llamada
// ❌ Cada instancia abre sus propios sockets
using var client = new HttpClient();
var response = await client.GetAsync(url);
Bajo carga, esto agota los puertos disponibles (socket exhaustion). Y un HttpClient estático para siempre no respeta los cambios de DNS. La solución está en el contenedor:
builder.Services.AddHttpClient<PaymentsClient>(client =>
{
client.BaseAddress = new Uri("https://api.pagos.example/");
client.Timeout = TimeSpan.FromSeconds(10);
});
IHttpClientFactory reutiliza y renueva las conexiones por ti.
Error 5: el service locator disfrazado
// ❌ Las dependencias reales no se ven en el constructor
public class OrderService(IServiceProvider provider)
{
public async Task CreateAsync(Order order)
{
var repo = provider.GetRequiredService<IOrderRepository>();
var mail = provider.GetRequiredService<IEmailSender>();
...
}
}
Compila, funciona y esconde las dependencias. Las pruebas necesitan un contenedor completo y el error de un registro faltante aparece en ejecución, no al arrancar. Hay casos legítimos (fábricas, resolución condicional), pero en un servicio de negocio las dependencias van en el constructor.
Error 6: el constructor con diez parámetros
Si un servicio necesita diez dependencias, el problema no es la inyección: es que el servicio hace demasiadas cosas. Agrupar parámetros en un objeto o inyectar IServiceProvider solo esconde el síntoma. Lo que suele funcionar es dividirlo por caso de uso: CreateOrder, CancelOrder, ShipOrder, cada uno con las tres o cuatro dependencias que realmente usa.
Error 7: registrar dos veces la misma interfaz sin saberlo
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SmsNotifier>(); // gana el último
Quien pide INotifier recibe SmsNotifier. Quien pide IEnumerable<INotifier> recibe los dos. Si la intención era reemplazar, está bien; si era agregar, alguien se va a llevar una sorpresa. Para distinguir implementaciones de forma explícita, .NET 8 agregó los servicios con clave:
builder.Services.AddKeyedScoped<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedScoped<INotifier, SmsNotifier>("sms");
public class AlertService([FromKeyedServices("sms")] INotifier notifier) { ... }
Y si una biblioteca puede registrar un servicio que tú también registras, TryAddScoped solo lo agrega si no existe.
El trade-off de inyectar todo
La inyección de dependencias facilita las pruebas y desacopla, pero no todo necesita una interfaz. Una clase utilitaria sin estado ni dependencias externas, como un formateador o un cálculo puro, puede instanciarse directamente o ser estática. Crear IDateFormatter con una sola implementación solo agrega un archivo más que mantener.
Cuándo no complicarse
En una API pequeña, AddScoped para casi todo es una elección razonable y segura. Los tiempos de vida más cortos rara vez causan errores; los más largos sí. Si dudas, scoped.
Conclusión
Casi todos estos errores tienen la misma raíz: un objeto vive más de lo que debería o comparte estado que no debería compartir. Activa la validación de scopes, revisa con cuidado cada singleton y deja que el constructor diga honestamente lo que necesita cada servicio.
¿Necesitas un equipo .NET para tu proyecto?
Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.
Ver desarrollo a medida