Skip to main content

Anti-pattern dei lifetime nella DI in .NET

Alessandro Mengoli
Serie: Dependency Injection .NET - Parte 2

Nel precedente articolo abbiamo visto i tre lifetime della DI in .NET e la regola fondamentale: un servizio non deve dipendere da un altro con lifetime più breve del proprio. In questo articolo vediamo cosa succede quando quella regola viene violata.

I problemi legati ai lifetime sono tra i più insidiosi nella Dependency Injection, perché spesso non si manifestano durante lo sviluppo o nei test. Un DbContext catturato in un Singleton, ad esempio, può funzionare per ore prima di mostrare comportamenti anomali. Un memory leak da Transient Disposable cresce lentamente e diventa visibile solo sotto carico. E un servizio Scoped che diventa Singleton passa inosservato finché due request non si trovano a condividere stato che dovrebbe essere isolato.

Vediamo questi tre scenari nel dettaglio.

Captive Dependency

Il termine Captive Dependency è stato coniato da Mark Seemann e indica una situazione in cui un servizio con lifetime lungo (es. Singleton) cattura un servizio con lifetime più breve (es. Scoped). Il servizio catturato sopravvive oltre il suo ciclo di vita previsto.

Il problema

var services = new ServiceCollection();
services.AddSingleton<OrderService>();
services.AddScoped<FakeDbContext>();

public class OrderService(FakeDbContext db)
{
    public void CreateOrder(Order order)
    {
        db.Orders.Add(order);
        db.SaveChanges();
    }
}

OrderService è Singleton, quindi viene creato una sola volta. Il FakeDbContext iniettato al momento della creazione resta lo stesso per tutta la vita dell’applicazione, anche se era registrato come Scoped. Le request successive continuano a usare la stessa istanza di DbContext — con tutto lo stato accumulato dalle operazioni precedenti.

Il risultato: tracking di entità inconsistente, dati stale, possibili eccezioni.

Le soluzioni

1) Usare IServiceScopeFactory per creare scope on-demand:

public class OrderService(IServiceScopeFactory scopeFactory)
{
    public void CreateOrder(Order order)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<FakeDbContext>();
        db.Orders.Add(order);
        db.SaveChanges();
    }
}

Ogni chiamata a CreateOrder crea un nuovo scope con la propria istanza di DbContext, che viene correttamente disposta alla fine del blocco using. IServiceScopeFactory è sempre registrato come Singleton dal container, quindi può essere iniettato senza problemi in un servizio Singleton.

2) Allineare i lifetime:

services.AddScoped<OrderService>();
services.AddScoped<FakeDbContext>();

Se OrderService non ha motivo di essere Singleton, la soluzione più semplice è registrarlo come Scoped. Entrambi i servizi vivranno per la durata della request.

3) Abilitare ValidateScopes:

var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,
    ValidateOnBuild = true
});

Con ValidateOnBuild = true, il problema viene rilevato al momento della build del provider. Con ValidateScopes = true senza ValidateOnBuild, l’eccezione viene lanciata alla prima risoluzione di OrderService:

Cannot consume scoped service 'FakeDbContext' from singleton 'OrderService'.

Transient Disposable catturati dal container

Quando un servizio Transient implementa IDisposable, il container DI mantiene un riferimento a ogni istanza creata per poterla disporre quando lo scope (o il container stesso) viene distrutto. Se queste istanze vengono risolte dal root provider (senza scope), si accumulano in memoria fino allo shutdown dell’applicazione.

Il problema

public class ExpensiveResource : IDisposable
{
    private readonly byte[] _buffer = new byte[1024 * 1024]; // 1MB

    public void DoWork() => Console.WriteLine("Working...");

    public void Dispose()
    {
        Console.WriteLine($"{nameof(ExpensiveResource)} disposed");
    }
}

services.AddTransient<ExpensiveResource>();

var provider = services.BuildServiceProvider();

for (int i = 0; i < 1000; i++)
{
    var resource = provider.GetRequiredService<ExpensiveResource>();
    resource.DoWork();
    // Ogni istanza viene trattenuta dal container
}
// 1000 istanze × 1MB = ~1GB di memoria non rilasciata
// Il Dispose avviene solo quando il provider viene disposto (shutdown dell'app)

Il container tiene traccia di tutte le istanze Transient IDisposable che crea, perché è sua responsabilità chiamare Dispose() su di esse. Se non c’è uno scope che delimita il ciclo di vita, le istanze restano in memoria fino alla fine dell’applicazione.

Le soluzioni

1) Usare uno scope per ogni unità di lavoro:

for (int i = 0; i < 1000; i++)
{
    using var scope = provider.CreateScope();
    var resource = scope.ServiceProvider.GetRequiredService<ExpensiveResource>();
    resource.DoWork();
} // Alla fine di ogni scope, le istanze transient vengono disposte

2) Factory pattern per la gestione manuale del ciclo di vita:

services.AddSingleton<Func<ExpensiveResource>>(() => new ExpensiveResource());

public class MyService(Func<ExpensiveResource> resourceFactory)
{
    public void Process()
    {
        using var resource = resourceFactory();
        resource.DoWork();
    }
}

Con il factory pattern, l’istanza non è gestita dal container — siamo noi a crearla e a chiamare Dispose(). Il container non trattiene alcun riferimento.

⚠️
Attenzione: il consumer di una dipendenza IDisposable non deve mai chiamare Dispose() direttamente su di essa. È il container (o lo scope) che si occupa del cleanup. L'eccezione è il factory pattern, dove l'istanza è creata fuori dal container.

Le linee guida ufficiali di Microsoft su questo punto sono chiare: evitare di registrare servizi IDisposable come Transient. Se necessario, usare il factory pattern.

Servizio Scoped risolto come Singleton

Se un servizio Scoped viene risolto dal root IServiceProvider — cioè senza creare uno scope — il suo lifetime viene di fatto promosso a Singleton. Il servizio viene creato una sola volta e riutilizzato per tutte le richieste successive.

Il problema

public class RequestContext
{
    public Guid RequestId { get; } = Guid.NewGuid();
    public string? UserName { get; private set; }
    public void SetUser(string userName) => UserName = userName;
}

services.AddScoped<RequestContext>();

var provider = services.BuildServiceProvider();

// Risoluzione dal root provider, senza scope
var request1 = provider.GetRequiredService<RequestContext>();
request1.SetUser("alice");

var request2 = provider.GetRequiredService<RequestContext>();
request2.SetUser("bob");

Console.WriteLine(ReferenceEquals(request1, request2)); // true
Console.WriteLine(request1.UserName); // bob
// request1 e request2 sono la stessa istanza:
// stato condiviso accidentalmente tra "request" diverse

In ASP.NET Core questo problema non si presenta per le request HTTP, perché il framework crea automaticamente uno scope per ogni request. Il rischio esiste nei background service, nelle console app e in qualsiasi contesto dove lo scope non viene creato automaticamente.

La soluzione

Creare sempre uno scope esplicito quando si risolvono servizi Scoped fuori da ASP.NET Core:

var provider = services.BuildServiceProvider();

for (int i = 1; i <= 3; i++)
{
    using var scope = provider.CreateScope();
    var request = scope.ServiceProvider.GetRequiredService<RequestContext>();
    request.SetUser($"user-{i}");
    Console.WriteLine($"Scope {i}: {request.RequestId} - {request.UserName}");
}
// Ogni scope ha la propria istanza di RequestContext

Anche in questo caso, ValidateScopes rileva il problema. Se abilitato, risolvere un servizio Scoped dal root provider lancia un’eccezione:

Cannot resolve scoped service 'RequestContext' from root provider.

Conclusione

I tre anti-pattern di questo articolo hanno un denominatore comune: un disallineamento tra il lifetime dichiarato e quello effettivo del servizio. La Captive Dependency cattura un servizio Scoped in un Singleton, i Transient Disposable si accumulano senza essere rilasciati, e un servizio Scoped risolto dal root provider diventa un Singleton accidentale.

Le contromisure sono semplici: usare IServiceScopeFactory quando un Singleton ha bisogno di servizi Scoped, gestire esplicitamente gli scope per i Transient Disposable, e abilitare ValidateScopes e ValidateOnBuild per intercettare questi problemi prima che raggiungano la produzione.

Nel prossimo articolo vedremo il Service Locator pattern, i deadlock nelle factory asincrone e il comportamento delle registrazioni multiple.

Fonti: