Skip to main content

Dependency Injection in .NET: i lifetime e le loro regole

Alessandro Mengoli
Serie: Dependency Injection .NET - Parte 1

La Dependency Injection è uno dei principi fondamentali dello sviluppo software moderno. Permette di disaccoppiare i componenti di un’applicazione, rendendo il codice più testabile, manutenibile e flessibile. Invece di creare le dipendenze direttamente all’interno di una classe, queste vengono fornite dall’esterno — tipicamente attraverso il costruttore — da un container che si occupa di gestirne il ciclo di vita.

In .NET la DI è integrata nel framework e la usiamo quotidianamente. Registriamo servizi, li iniettiamo nei costruttori, e tutto funziona. Fino a quando non funziona più.

Molti dei problemi legati alla DI derivano da una comprensione superficiale dei lifetime — il meccanismo che determina quando un servizio viene creato, condiviso e distrutto. Questo articolo è il punto di partenza della serie: prima di parlare di anti-pattern e pitfall, è necessario avere le basi chiare.

I tre lifetime

Quando registriamo un servizio nel container DI, dobbiamo specificare il suo lifetime. Il lifetime definisce tre cose: quando l’istanza viene creata, se viene condivisa tra più consumer, e quando viene distrutta (disposed).

Scegliere il lifetime corretto non è un dettaglio secondario. Un lifetime sbagliato può portare a stato condiviso tra request diverse, memory leak, o servizi che smettono di funzionare dopo un certo tempo — problemi spesso difficili da diagnosticare perché non si manifestano subito.

Il container DI di .NET offre tre lifetime: Singleton, Scoped e Transient.

Schema dei lifetime DI in .NET con numero di istanze create

Nel diagramma: su 3 request, Singleton crea 1 istanza totale, Scoped 3 istanze (1 per request), Transient 6 istanze (2 risoluzioni per request).

Singleton

builder.Services.AddSingleton<IMyService, MyService>();

Una sola istanza per tutta la durata dell’applicazione. Viene creata alla prima risoluzione e riutilizzata per ogni richiesta successiva. Tutti i consumer ricevono lo stesso oggetto. La memoria occupata non viene rilasciata fino allo shutdown dell’applicazione.

Adatto per: servizi stateless, servizi con stato costoso da creare o condiviso globalmente.

Da considerare: i servizi Singleton devono essere thread-safe, perché possono essere acceduti da più thread contemporaneamente. Un Singleton che mantiene stato mutabile senza sincronizzazione è un bug in attesa di manifestarsi. Inoltre, un Singleton può trattenere in memoria un intero grafo di oggetti per tutta la vita dell’applicazione.

Scoped

builder.Services.AddScoped<IMyService, MyService>();

Un’istanza per ogni scope. In ASP.NET Core, ogni HTTP request crea automaticamente uno scope, quindi un servizio Scoped viene creato una volta per request e condiviso tra tutti i componenti che lo richiedono all’interno della stessa request.

public sealed class RequestContext
{
  public Guid RequestId { get; } = Guid.NewGuid();
}

public sealed class OrderService(RequestContext context)
{
  public Guid RequestId => context.RequestId;
}

public sealed class PaymentService(RequestContext context)
{
  public Guid RequestId => context.RequestId;
}

var services = new ServiceCollection();
services.AddScoped<RequestContext>();
services.AddScoped<OrderService>();
services.AddScoped<PaymentService>();

using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();

var orders = scope.ServiceProvider.GetRequiredService<OrderService>();
var payments = scope.ServiceProvider.GetRequiredService<PaymentService>();

Console.WriteLine(orders.RequestId == payments.RequestId); // true
// Nello stesso scope, i due servizi condividono la stessa RequestContext

Adatto per: DbContext (Entity Framework lo registra come Scoped di default tramite AddDbContext), unit of work, servizi che mantengono stato per-request.

Da considerare: un servizio Scoped deve essere sempre utilizzato all’interno di uno scope — implicito (come quello per-request di ASP.NET Core) o esplicito (creato con IServiceScopeFactory.CreateScope()). Nelle console app e nei background service non esiste uno scope automatico: va creato manualmente.

Transient

builder.Services.AddTransient<IMyService, MyService>();

Una nuova istanza ad ogni risoluzione. Ogni volta che il container deve fornire il servizio, ne crea uno nuovo.

public sealed class Validator
{
    public Guid InstanceId { get; } = Guid.NewGuid();
}

public sealed class ReportGenerator(Validator validator)
{
    public Guid ValidatorId => validator.InstanceId;
}

var services = new ServiceCollection();
services.AddTransient<Validator>();
services.AddTransient<ReportGenerator>();

using var provider = services.BuildServiceProvider();

var report1 = provider.GetRequiredService<ReportGenerator>();
var report2 = provider.GetRequiredService<ReportGenerator>();

Console.WriteLine(report1.ValidatorId == report2.ValidatorId); // false
// Ogni risoluzione crea una nuova istanza transient
}

Adatto per: servizi leggeri e stateless, operazioni isolate che non devono condividere stato.

Da considerare: se il servizio implementa IDisposable, il container mantiene un riferimento a ogni istanza Transient creata per gestirne il dispose. Se queste istanze vengono risolte dal root container (senza scope), si accumulano in memoria fino allo shutdown dell’applicazione — un memory leak. Ne parleremo nel prossimo articolo.

La regola dei lifetime

C’è una regola fondamentale che governa le dipendenze tra servizi con lifetime diversi.

Un servizio non deve dipendere da un altro servizio con lifetime più breve del proprio.

In pratica:

Singleton → può dipendere solo da → Singleton
Scoped    → può dipendere da     → Scoped, Singleton
Transient → può dipendere da     → Transient, Scoped, Singleton

Il motivo è semplice: se un Singleton dipende da un servizio Scoped, il Singleton viene creato una sola volta e con esso viene catturata l’istanza Scoped iniettata al momento della creazione. Quella istanza Scoped non verrà mai rinnovata — sopravvive ben oltre il suo ciclo di vita previsto, portando a stato inconsistente tra request diverse.

Questo problema ha un nome specifico: Captive Dependency, termine coniato da Mark Seemann. Lo vedremo in dettaglio nel prossimo articolo, ma per ora è sufficiente ricordare la regola.

Un modo per visualizzarla: pensate ai lifetime come a una gerarchia di durata.

Singleton  ████████████████████████████████  (tutta l'app)
Scoped     ██████████                        (una request)
Transient  ██                                (una risoluzione)

Un servizio può dipendere solo da servizi che vivono almeno quanto lui. Se un servizio a vita lunga cattura uno a vita breve, lo tiene in vita più del previsto — e i problemi che ne derivano sono spesso subdoli.

Validazione: intercettare i problemi alla build

.NET mette a disposizione due opzioni di validazione che permettono di intercettare violazioni delle regole dei lifetime e registrazioni mancanti prima che l’applicazione sia in esecuzione.

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

ValidateScopes verifica che i servizi Scoped non vengano risolti dal root provider e che non vengano iniettati in Singleton. Se un Singleton prova a consumare un servizio Scoped, viene lanciata un’InvalidOperationException:

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

ValidateOnBuild verifica al momento della build del service provider che tutti i servizi registrati siano effettivamente risolvibili — cioè che tutte le dipendenze richieste nei costruttori siano a loro volta registrate. Se manca una dipendenza, l’errore emerge subito, non alla prima richiesta che prova a usare quel servizio.

Nelle applicazioni che usano WebApplication.CreateBuilder o Host.CreateApplicationBuilder, entrambe le validazioni sono abilitate di default in ambiente Development. Per gli altri scenari, vanno attivate esplicitamente come nell'esempio sopra.

Abilitare queste validazioni è una delle cose più semplici e utili che si possano fare. Molti dei problemi che vedremo nei prossimi articoli della serie vengono intercettati da queste due opzioni — a patto di averle attive.

Conclusione

I tre lifetime — Singleton, Scoped e Transient — sono il meccanismo con cui il container DI gestisce la creazione e la durata dei servizi. La regola fondamentale è: un servizio non deve mai dipendere da un servizio con lifetime più breve del proprio. Le opzioni ValidateScopes e ValidateOnBuild permettono di far rispettare questa regola e di individuare registrazioni mancanti al momento della build.

Nel prossimo articolo vedremo cosa succede quando queste regole vengono violate: Captive Dependency, Transient Disposable catturati dal container e servizi Scoped che diventano Singleton per errore.

Fonti: