in Architettura Software, Informatica

Dependency Injection in C# e ASP.NET Core: Una Guida Completa

I. Introduzione alla Dependency Injection (DI) e all’Inversion of Control (IoC)

Nel panorama dell’ingegneria del software, in particolare nell’ecosistema C# e.NET, i concetti di Inversion of Control (IoC) e Dependency Injection (DI) rappresentano principi fondamentali che migliorano la modularità, la testabilità e la manutenibilità del codice. Questi paradigmi di progettazione sono diventati indispensabili per la creazione di applicazioni robuste e scalabili.

A. Comprendere l’Inversion of Control (IoC)

L’Inversion of Control (IoC) è un principio di progettazione in cui il controllo della creazione degli oggetti e del flusso di un programma viene invertito o trasferito dal codice dell’applicazione stessa a un framework o contenitore esterno. In termini più semplici, IoC significa che, anziché il codice controllare direttamente l’istanziamento e la gestione degli oggetti e delle loro dipendenze, questa responsabilità viene delegata a un’entità esterna.

Questo approccio è spesso riassunto con la frase “non chiamarci, ti chiameremo noi”, indicando che il framework detta il flusso di esecuzione, mentre il codice dell’applicazione fornisce solo i dettagli di implementazione richiesti. I framework IoC hanno la capacità di configurare e assemblare dinamicamente i componenti dell’applicazione, favorendo così la creazione di soluzioni più flessibili e scalabili.

B. Definire la Dependency Injection (DI)

La Dependency Injection (DI) è un pattern di progettazione specifico che implementa il principio dell’IoC. Essa implica il passaggio di dipendenze (servizi o oggetti) a una classe, anziché lasciare che la classe le crei autonomamente. Questo approccio promuove attivamente il Principio di Responsabilità Unica (SRP) e il Principio Open/Closed (OCP) dei principi SOLID, contribuendo a rendere la base di codice più manutenibile.

Per esempio, se una classe ha la necessità di inviare un’email, la sua responsabilità non dovrebbe includere la creazione del servizio di invio email. Invece, la classe dovrebbe semplicemente dichiarare di “aver bisogno di qualcosa che possa inviare email”, e il sistema si occuperà di fornirglielo. Questo metodo consente di disaccoppiare un

NotificationService dalle implementazioni concrete come SmtpEmailService o SendGridEmailService, facendo sì che il NotificationService si affidi a un’interfaccia IEmailService.

C. Vantaggi Chiave dell’Utilizzo della DI

L’adozione della Dependency Injection apporta numerosi benefici che migliorano la qualità e la sostenibilità del software.

Accoppiamento loose 

La Dependency Injection assicura che i componenti software dipendano da astrazioni (interfacce) piuttosto che da implementazioni concrete. Questo significa che le modifiche all’implementazione di una dipendenza hanno un impatto minimo sulle classi che ne dipendono. Il passaggio da dipendenze concrete ad astrazioni è un rapporto causale fondamentale nella DI. Non si tratta semplicemente di un “accoppiamento loose” come termine generico, ma di un meccanismo che consente a un sistema di evolvere in modo indipendente senza causare modifiche a cascata. Questo riduce significativamente l’ambito di propagazione delle modifiche.

Per comprendere appieno questo concetto, si consideri uno scenario iniziale in cui la Classe A istanzia direttamente la Classe B. Se il costruttore o la logica interna della Classe B subisce modifiche, la Classe A deve necessariamente essere modificata. Questo crea un accoppiamento stretto. Con l’introduzione della DI, la Classe A accetta un’interfaccia (IInterfaceB) nel suo costruttore, e un contenitore DI fornisce un’istanza concreta di ClasseB (che implementa IInterfaceB) a runtime.

Di conseguenza, se l’implementazione interna di ClasseB cambia, o se ClasseB viene sostituita da ClasseC (un’altra implementazione di IInterfaceB), la Classe A rimane invariata. Questo è possibile perché la Classe A conosce solo l’interfaccia, non il tipo concreto. Questo accoppiamento lasco è il diretto abilitatore di molti altri benefici, come la testabilità e la manutenibilità, poiché i componenti possono essere sviluppati, testati e modificati in isolamento. È il meccanismo primario attraverso il quale la DI migliora la flessibilità del sistema.

Migliore Testabilità

Iniettando le dipendenze, diventa più semplice sostituire gli oggetti reali con mock o test doubles durante i test unitari o di integrazione. Questo permette di testare i componenti in isolamento. La testabilità è una conseguenza diretta dell’accoppiamento lasco. Senza la DI, testare una classe che crea le proprie dipendenze richiederebbe una configurazione complessa per gestire o simulare quelle creazioni interne, spesso trasformando i test unitari in test di integrazione. La DI semplifica questo processo rendendo le dipendenze esplicite e sostituibili.

Si immagini il problema di testare MyClass che crea MyDependency internamente. Senza la DI, il comportamento effettivo di MyDependency (ad esempio, chiamate a database o API esterne) verrebbe eseguito, rendendo il test un test di integrazione anziché un test unitario focalizzato sulla logica di MyClass. Con la DI, utilizzando l’iniezione tramite costruttore (public MyClass(IMyDependency myDependency)), durante i test unitari, un’implementazione mock di IMyDependency può essere facilmente passata a MyClass.

Questa iniezione diretta consente al test di controllare il comportamento di IMyDependency (ad esempio, simulare successo, fallimento o dati specifici) senza interagire con l’implementazione reale di MyDependency. Questo isola la logica di MyClass per un test unitario mirato. Ciò porta a test unitari più veloci, affidabili e focalizzati, essenziali per mantenere la qualità del codice e abilitare l’integrazione/consegna continua.

Facilità di Manutenzione

Le modifiche alle dipendenze o alle loro implementazioni possono essere effettuate con un impatto minimo sulle classi che ne dipendono. Questo riduce il rischio di introdurre regressioni e semplifica l’evoluzione del software.

Modularità e Riutilizzabilità

La DI promuove la scomposizione delle applicazioni in moduli e componenti più piccoli e indipendenti, ciascuno con le proprie dipendenze definite. Questo li rende utilizzabili in vari contesti all’interno della stessa applicazione o in applicazioni diverse, aumentando la riutilizzabilità del codice.

Flessibilità ed Estensibilità

Nuove funzionalità possono essere aggiunte semplicemente introducendo nuove dipendenze e iniettandole, rendendo più agevole l’introduzione di modifiche senza dover modificare il codice esistente. Questo supporta il Principio Open/Closed, consentendo la creazione di versioni specializzate dei servizi senza alterare il codice originale.

Configurazione a Runtime

Le dipendenze possono essere configurate e commutate in base a diverse condizioni di runtime. Ciò offre la possibilità di adattare il comportamento dell’applicazione a seconda dell’ambiente (sviluppo, test, produzione) o di requisiti specifici, senza la necessità di ricompilare il codice.

II. Come Funziona la Dependency Injection: Tipi di Iniezione

Questa sezione esplora le modalità pratiche di implementazione della Dependency Injection in C#, dettagliando i tre tipi principali di iniezione: tramite costruttore, tramite proprietà/setter e tramite metodo, insieme alle loro caratteristiche e casi d’uso.

A. Iniezione tramite Costruttore (Il Metodo Preferito)

L’iniezione tramite costruttore prevede che le dipendenze siano fornite come parametri al costruttore di una classe.1 Questo è il metodo più comune e generalmente raccomandato per la DI in C#.

Un esempio tipico è il seguente:

public class MyClass
{
    private readonly IMyDependency _myDependency;
    public MyClass(IMyDependency myDependency)
    {
        _myDependency = myDependency;
    }
}

Questa tecnica garantisce che tutte le dipendenze necessarie siano presenti e inizializzate al momento della creazione di un oggetto, supportando l’immutabilità. Essa definisce esplicitamente le dipendenze richieste, impedendo che un servizio venga costruito senza di esse.

Le dipendenze sono tipicamente assegnate a campi readonly, rafforzando ulteriormente l’immutabilità.

L’iniezione tramite costruttore impone la completezza delle dipendenze al momento della creazione dell’oggetto. Questa è una decisione di progettazione cruciale perché rende le dipendenze esplicite e impedisce che l’oggetto si trovi in uno stato non valido a causa di dipendenze mancanti. L’uso di campi readonly rafforza ulteriormente l’immutabilità e previene riassegnazioni accidentali, migliorando la robustezza del codice.

Se una classe potesse essere istanziata senza le sue dipendenze essenziali, si potrebbero verificare NullReferenceException a runtime quando tali dipendenze vengono successivamente accedute, rendendo lo stato della classe imprevedibile. Richiedendo IMyDependency nel costruttore, il compilatore assicura che MyClass non possa essere creata senza un’istanza valida di IMyDependency. Questa imposizione in fase di compilazione garantisce che _myDependency sarà sempre inizializzato quando MyClass viene utilizzata, riducendo significativamente gli errori a runtime legati a dipendenze mancanti. La parola chiave readonly previene modifiche successive, assicurando che la dipendenza rimanga coerente per tutta la vita dell’oggetto. Questo approccio promuove un comportamento “fail-fast”, rendendo i problemi evidenti prima nel ciclo di sviluppo (in fase di compilazione o all’inizio del runtime) piuttosto che in produzione. Semplifica inoltre la comprensione del comportamento della classe, poiché le sue dipendenze sono chiaramente definite e immutabili.

B. Iniezione tramite Proprietà/Setter

L’iniezione tramite proprietà o setter prevede che le dipendenze vengano assegnate a proprietà pubbliche o metodi setter dopo che l’oggetto è stato creato.

Questo metodo offre flessibilità, poiché le dipendenze possono essere modificate a runtime, e permette di gestire dipendenze opzionali. Tuttavia, può esporre lo stato interno della classe, riducendo l’incapsulamento.

Sebbene offra flessibilità, l’iniezione tramite proprietà introduce il rischio che un oggetto si trovi in uno stato incompleto o non valido se le dipendenze opzionali non vengono impostate. Questo compromesso tra flessibilità e stato garantito richiede un’attenta considerazione, rendendola adatta principalmente per componenti veramente opzionali.

Se MyClass ha una proprietà pubblica MyDependency, può essere istanziata senza che MyDependency sia impostata. Se un metodo tenta di utilizzare MyDependency prima che sia impostata, si verificherebbe una NullReferenceException. Questo è accettabile per dipendenze opzionali, dove la classe può funzionare (magari con funzionalità ridotte) anche se la dipendenza non viene fornita. La natura “opzionale” implica che la classe deve includere controlli per valori nulli o fornire un comportamento predefinito se la proprietà non è impostata. Questo aggiunge complessità alla logica interna della classe rispetto all’iniezione tramite costruttore, dove le dipendenze sono garantite. L’iniezione tramite proprietà dovrebbe essere utilizzata con parsimonia, solo per dipendenze non critiche. Un uso eccessivo può portare a codice meno robusto, più difficile da comprendere e da debuggare a causa di potenziali valori nulli a runtime.

C. Iniezione tramite Metodo

L’iniezione tramite metodo si verifica quando le dipendenze vengono passate come parametri a metodi specifici.

Un esempio è il seguente:

public class MyClass
{
    public void MyMethod(IMyDependency myDependency)
    {
        // Usa myDependency qui
    }
}

Questa tecnica è utile per iniettare dipendenze necessarie solo per metodi specifici.

L’iniezione tramite metodo è la forma più granulare, ideale quando una dipendenza è richiesta solo per una singola operazione e non deve essere mantenuta come parte dello stato dell’oggetto. Evita di ingombrare il costruttore o le proprietà con dipendenze transitorie.

Una classe potrebbe aver bisogno di una dipendenza specifica solo per una particolare chiamata di metodo, e non per tutta la sua durata o per altri metodi. L’uso dell’iniezione tramite costruttore o proprietà implicherebbe che la dipendenza sia mantenuta dalla classe inutilmente.

Passando la dipendenza direttamente al metodo, l’ambito della dipendenza è limitato all’esecuzione di quel metodo. Questo approccio mantiene lo stato complessivo della classe più pulito e riduce l’ingombro di memoria se la dipendenza è intensiva in termini di risorse e di breve durata.

Rende inoltre espliciti i requisiti immediati del metodo nella sua firma. Sebbene meno comune dell’iniezione tramite costruttore, l’iniezione tramite metodo è preziosa per operazioni “una tantum” o quando il ciclo di vita di una dipendenza è strettamente legato a una singola chiamata di metodo, promuovendo un controllo ancora più granulare sull’utilizzo delle risorse.

Tabella 1: Comparazione dei Tipi di Iniezione DI

Tipo di IniezioneMeccanismoVantaggiSvantaggiCasi d’Uso Tipici
CostruttoreLe dipendenze sono passate come parametri al costruttore della classe. Garantisce che tutte le dipendenze richieste siano presenti al momento della creazione dell’oggetto; promuove l’immutabilità; ideale per dipendenze obbligatorie; migliora la testabilità. Può portare a un “proliferare di costruttori” se ci sono troppe dipendenze; richiede tutte le dipendenze al momento della creazione dell’oggetto.Metodo più comune e raccomandato per dipendenze obbligatorie (es. contesti di database, servizi core).
Proprietà/SetterLe dipendenze sono assegnate a proprietà pubbliche o metodi setter dopo la creazione dell’oggetto. Permette dipendenze opzionali; le dipendenze possono essere modificate a runtime; utile per dipendenze circolari (spesso un segnale di cattiva progettazione). L’oggetto può trovarsi in uno stato non valido se le dipendenze opzionali non sono impostate (richiede controlli nulli); riduce l’incapsulamento. Dipendenze opzionali; configurazioni che possono cambiare dopo la costruzione.
MetodoLe dipendenze sono passate come parametri a metodi specifici. Le dipendenze sono disponibili solo per l’ambito del metodo; evita di ingombrare costruttori/proprietà; ideale per dipendenze transitorie, monouso. Richiede il passaggio esplicito delle dipendenze a ogni chiamata di metodo; può rendere le firme dei metodi lunghe.Dipendenze necessarie solo per una singola operazione all’interno di un metodo; pattern factory.

III. Dependency Injection in ASP.NET Core

ASP.NET Core ha integrato la Dependency Injection direttamente nel suo framework, rendendola una parte fondamentale della sua architettura.

A. Il Contenitore DI Integrato (IServiceProvider e IServiceCollection)

ASP.NET Core include la DI direttamente nel suo framework. Fornisce un contenitore di servizi integrato,

IServiceProvider, che gestisce la creazione e la gestione dei servizi. I servizi sono tipicamente registrati nel file

Program.cs (o Startup.cs nelle versioni precedenti) utilizzando l’interfaccia IServiceCollection.

La funzionalità principale di questo contenitore è la risoluzione e l’iniezione automatica delle dipendenze ovunque siano necessarie. Supporta l’iniezione tramite costruttore come forma raccomandata e si integra con funzionalità come il pattern

IOptions<T> per configurazioni fortemente tipizzate.

Il contenitore DI integrato in ASP.NET Core abbassa significativamente la barriera all’ingresso per l’adozione della DI. Fornendo un approccio basato sulla convenzione piuttosto che sulla configurazione, incoraggia gli sviluppatori a seguire le migliori pratiche senza la necessità di integrare e configurare framework DI di terze parti da zero. Questa integrazione è un fattore chiave nella progettazione modulare e testabile di ASP.NET Core.

Prima dell’integrazione nativa della DI, gli sviluppatori dovevano scegliere, integrare e configurare contenitori DI esterni (ad esempio, Autofac, Ninject), il che aggiungeva complessità e codice boilerplate. Adottando IServiceCollection e IServiceProvider direttamente nel framework, ASP.NET Core ha reso la DI un elemento di prima classe. Questa integrazione significa che i servizi comuni del framework (ILogger<T>, IConfiguration, IWebHostEnvironment, IHttpContextAccessor, IOptions<T>) sono già registrati e disponibili per l’iniezione per impostazione predefinita.

Questo semplifica lo sviluppo e incoraggia l’uso di pattern coerenti. Il contenitore integrato è una testimonianza dell’impegno del framework verso i principi moderni di progettazione del software. Semplifica la configurazione per gli scenari comuni, permettendo agli sviluppatori di concentrarsi sulla logica di business piuttosto che sulla gestione dell’infrastruttura. Sebbene possa mancare di alcune funzionalità avanzate dei contenitori di terze parti (ad esempio, l’iniezione di proprietà per impostazione predefinita, i contenitori figlio o la registrazione basata su convenzioni), copre efficacemente la stragrande maggioranza dei casi d’uso.

B. Registrazione dei Servizi in Program.cs (o Startup.cs)

La registrazione dei servizi avviene tramite metodi di estensione sull’oggetto IServiceCollection, come AddSingleton, AddScoped e AddTransient.

Un esempio di registrazione è il seguente:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ILoggerService, LoggerService>();
builder.Services.AddControllers();
var app = builder.Build();

Per i servizi personalizzati, gli sviluppatori definiscono un’interfaccia (astrazione) e un’implementazione concreta, quindi registrano la mappatura.

Un esempio pratico è la gestione dei servizi di notifica:

// Passo 1: Definire un'astrazione
public interface IEmailService { void Send(string to, string subject, string body); }

// Passo 2: Creare implementazioni concrete
public class SmtpEmailService : IEmailService { /* Logica SMTP */ }
public class SendGridEmailService : IEmailService { /* Logica SendGrid */ }

// Passo 3: Registrare in Program.cs
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
// OPPURE: builder.Services.AddScoped<IEmailService, SendGridEmailService>();

I servizi possono anche essere registrati utilizzando delegati factory (espressioni lambda) per un controllo più granulare, consentendo una logica di istanziazione personalizzata e l’accesso ad altri servizi già registrati.2

Ad esempio, per un servizio di fatturazione:

builder.Services.AddScoped<IInvoiceService>(sp =>
{
    var logger = sp.GetRequiredService<ILogger<InvoiceService>>();
    return new InvoiceService(logger, "INVOICE_PREFIX");
});

Il processo di registrazione in Program.cs funge da “superficie di configurazione” centrale per le dipendenze dell’applicazione. Questo approccio centralizzato, combinato con la capacità di scambiare implementazioni con una singola riga di codice, evidenzia la potenza della DI per la flessibilità architetturale e le configurazioni specifiche dell’ambiente.

Senza un luogo centrale per registrare i servizi, ogni componente sarebbe responsabile della creazione delle proprie dipendenze, portando a una logica di istanziazione dispersa, duplicata e difficile da modificare. IServiceCollection in Program.cs fornisce un’unica e chiara posizione per definire tutte le mappature dei servizi. Questa centralizzazione consente un facile scambio di implementazioni (ad esempio, passare da SmtpEmailService a SendGridEmailService modificando una sola riga). Permette anche configurazioni specifiche dell’ambiente (ad esempio, utilizzare un servizio mock in sviluppo e uno reale in produzione) senza alterare la logica di business principale. I delegati factory offrono un controllo ancora più granulare, consentendo alle dipendenze di essere costruite in base alla configurazione di runtime o ad altri servizi. Questo pattern è fondamentale per costruire applicazioni ASP.NET Core adattabili e scalabili. Supporta pratiche come i feature toggles, i test A/B e le distribuzioni multi-ambiente, rendendo la risoluzione delle dipendenze un aspetto configurabile dell’avvio dell’applicazione.

C. Comprendere le Durate dei Servizi

Il contenitore DI gestisce il ciclo di vita dei servizi registrati. Comprendere queste durate è fondamentale per gestire la memoria, garantire lo stato corretto ed evitare problemi come condizioni di gara o perdite di memoria.

1. Servizi Transient

Un servizio registrato come Transient viene creato ogni volta che viene richiesto dal contenitore DI. Non c’è riutilizzo né caching.

La sintassi di registrazione è: builder.Services.AddTransient<IMyService, MyService>();

L’utilizzo è indicato per servizi leggeri, stateless e thread-safe, dove ogni operazione richiede un’istanza pulita e indipendente.10 Esempi includono

IEmailBuilder e IPasswordHasher, o servizi senza stato da preservare tra gli utilizzi.

Una considerazione chiave è che i servizi transient IDisposable vengono catturati dal contenitore per lo smaltimento. Se risolti dal contenitore di livello superiore, ciò può portare a perdite di memoria. È consigliabile evitare la risoluzione manuale quando possibile e non registrare istanze IDisposable con una durata transient se risolte dall’ambito radice.

2. Servizi Scoped

Un servizio Scoped viene creato una volta per richiesta (o ambito) e riutilizzato per tutta la durata di quella richiesta. Una nuova istanza viene creata per la richiesta successiva. Nelle applicazioni web, ciò significa tipicamente un’istanza per ogni richiesta HTTP.

La sintassi di registrazione è: builder.Services.AddScoped<IMyService, MyService>();

Sono perfetti per operazioni specifiche della richiesta, servizi che devono mantenere uno stato all’interno di una singola richiesta ma non oltre. Esempi includono

ICurrentUserService, ApplicationDbContext, IUnitOfWork e un carrello della spesa in un sito di e-commerce.

Una considerazione importante è che l’iniezione di un servizio Scoped in un Singleton può generare eccezioni a runtime o portare a comportamenti inattesi, poiché il servizio Scoped diventa effettivamente un Singleton. Il contenitore DI predefinito di ASP.NET Core spesso lancia eccezioni in questi casi. I servizi Scoped sono legati alla cache di

IServiceProvider dell’ambito.

3. Servizi Singleton

Un servizio Singleton viene creato una sola volta per l’intera durata dell’applicazione e condiviso tra tutte le richieste e gli ambiti. Viene registrato una volta, costruito una volta e riutilizzato per sempre.

La sintassi di registrazione è: builder.Services.AddSingleton<IMyService, MyService>();

Sono più adatti per servizi stateless, thread-safe e a basso consumo di memoria che non dipendono dal contesto della richiesta o dell’utente. Esempi includono oggetti di configurazione, servizi di logging (se stateless), servizi di caching e factory di connessione a database (non la connessione stessa).

Una considerazione fondamentale è che i Singleton devono essere thread-safe, poiché tutte le richieste utilizzano contemporaneamente la stessa istanza. Possono causare perdite di memoria se istanziano o iniettano classi

IDisposable ma non le rilasciano/smaltiscono, poiché i Singleton non vengono rilasciati fino alla fine dell’applicazione. È fondamentale evitare di dipendere da servizi Transient o Scoped da un servizio Singleton.È inoltre consigliabile evitare classi statiche con stato; utilizzare invece servizi Singleton.

La scelta della durata del servizio è una decisione architetturale critica con implicazioni di vasta portata per le prestazioni dell’applicazione, l’utilizzo della memoria, la gestione dello stato e la sicurezza dei thread. L’uso improprio delle durate, in particolare l’iniezione di servizi con durata più breve in servizi con durata più lunga, crea un problema di “dipendenza catturata”, portando a bug sottili, perdite di memoria o condizioni di gara difficili da diagnosticare. Questo evidenzia l’importanza di una rigorosa aderenza alle regole di durata e ai meccanismi di validazione del framework.

Gli sviluppatori spesso scelgono intuitivamente “Singleton” per le prestazioni, o “Transient” per la semplicità, senza comprendere appieno le implicazioni. Le regole di durata sono chiare: Transient per nuove istanze ogni volta, Scoped per un’istanza per ambito logico (es. richiesta HTTP), e Singleton per un’unica istanza per tutta la durata dell’applicazione. Il problema della “dipendenza catturata” si verifica se un servizio Singleton (S1) dipende da un servizio Scoped (S2).

S2 viene istanziato una sola volta quando S1 viene creato (all’avvio dell’applicazione) e diventa effettivamente un Singleton, anche se registrato come Scoped. Se S2 contiene uno stato specifico della richiesta (ad esempio, HttpContextAccessor, DbContext), tale stato sarà condiviso tra tutte le richieste, portando a condizioni di gara, dati errati o vulnerabilità di sicurezza. Il comportamento predefinito del framework è quello di lanciare un’eccezione in questi casi per prevenire questi problemi. Allo stesso modo, un Singleton che detiene un Transient

IDisposable può portare a perdite di memoria se il Transient non viene mai smaltito. Questo problema della “dipendenza catturata” è una classica insidia della DI. Sottolinea che le durate dei servizi non riguardano solo la frequenza di istanziazione, ma anche il modo in cui lo stato viene gestito e condiviso tra le operazioni concorrenti dell’applicazione. Una corretta gestione delle durate è fondamentale per costruire applicazioni robuste, performanti e sicure. La validazione del framework funge da protezione contro gli errori comuni.

Tabella 2: Durate dei Servizi DI in ASP.NET Core

DurataDefinizioneMetodo di RegistrazioneQuando Usare (Esempi)Considerazioni Chiave/Implicazioni
TransientUna nuova istanza del servizio viene creata ogni volta che viene richiesta dal contenitore DI. AddTransient<TService, TImplementation>() Servizi stateless, leggeri e “usa e getta” (es. IEmailBuilder, IPasswordHasher). Nessun riutilizzo. Può portare a problemi di prestazioni se pesante. I transient IDisposable risolti dalla root possono causare perdite di memoria.
ScopedUn’istanza viene creata una volta per richiesta (o ambito) e riutilizzata per tutta la durata di quella richiesta. AddScoped<TService, TImplementation>() Servizi che tracciano il contesto specifico della richiesta (es. ApplicationDbContext, ICurrentUserService, IUnitOfWork). Ideale per lo stato all’interno di una richiesta. Non può essere iniettato in servizi Singleton (lancia eccezione a runtime).
SingletonUna singola istanza del servizio viene creata una volta per l’intera durata dell’applicazione.AddSingleton<TService, TImplementation>() Servizi infrastrutturali globali, stateless e thread-safe (es. configurazione, logging, caching). Deve essere thread-safe. Potenziale per perdite di memoria se le dipendenze IDisposable non sono gestite. Non può dipendere da servizi Scoped o Transient.

D. Scenari Pratici di Iniezione

La Dependency Injection in ASP.NET Core si manifesta in diversi contesti operativi, ciascuno con le proprie peculiarità.

1. Iniezione di Servizi nei Controller

Nei controller MVC/API tradizionali, l’iniezione tramite costruttore è l’approccio standard e raccomandato. Le dipendenze sono definite come parametri nel costruttore del controller, e ASP.NET Core le inietta automaticamente.

Un esempio di questo è visibile in un controller di gestione ordini:

public class OrdersController : ControllerBase
{
    private readonly ILoggerService _loggerService;
    public OrdersController(ILoggerService loggerService)
    {
        _loggerService = loggerService;
    }
    //... azioni che utilizzano _loggerService
}

Con i costruttori primari di C# 12, questa sintassi diventa ancora più pulita, eliminando la necessità di campi privati e boilerplate di assegnazione. Ad esempio, un controller può essere definito in modo più conciso come:

public class OrdersController(IOrderService orderService) : ControllerBase.

2. Iniezione di Servizi negli Endpoint Minimal API

Le Minimal API non utilizzano classi o costruttori nello stesso modo dei controller, ma la DI funziona comunque tramite l’iniezione di parametri direttamente negli handler degli endpoint. ASP.NET Core ispeziona i parametri e inietta tutti i servizi che riconosce dal contenitore. Sebbene non sia fornito un esempio di codice esplicito, la logica sottostante è che un endpoint come

app.MapGet("/orders/{id}", (int id, IOrderService orderService) =>...); riceverebbe automaticamente un’istanza di IOrderService.

3. Dependency Injection nel Middleware (Considerazioni e Pattern Speciali)

L’iniezione di dipendenze nel middleware presenta sfide particolari. I componenti middleware sono tipicamente creati una sola volta all’avvio dell’applicazione (comportandosi come singleton) quando configurati nella pipeline di richiesta (app.UseMiddleware<MyMiddleware>()). Questo significa che l’iniezione diretta tramite costruttore di servizi

scoped nel middleware è problematica, poiché costringerebbe il servizio scoped a comportarsi come un singleton, portando a potenziali problemi come condizioni di gara o stato non corretto. Il framework lancerà un’eccezione a runtime in questi casi.

La soluzione raccomandata è l’iniezione di servizi scoped nel metodo Invoke o InvokeAsync del middleware. Questo permette al servizio di essere risolto come un servizio scoped per ogni richiesta, anche se il middleware stesso è un singleton.

Un esempio concettuale di questo approccio:

public class MyMiddleware
{
    private readonly RequestDelegate _next;
    public MyMiddleware(RequestDelegate next) { _next = next; }

    public async Task InvokeAsync(HttpContext context, ApplicationDbContext dbContext) // dbContext è scoped
    {
        // Utilizza dbContext per operazioni specifiche della richiesta
        await _next(context);
    }
}

Un’altra soluzione è l’uso di middleware basato su factory, che viene attivato per ogni richiesta del client (connessione), consentendo l’iniezione di servizi scoped nel costruttore del middleware.11

La gestione speciale della DI nel middleware evidenzia una sfumatura critica dell’architettura di ASP.NET Core: la natura singleton della pipeline di richiesta rispetto all’ambito per-richiesta di molti servizi. Comprendere questa distinzione e applicare l’iniezione tramite metodo in InvokeAsync è cruciale per evitare problemi di stato nascosti e utilizzare correttamente i servizi scoped all’interno della catena del middleware. È un esempio lampante di come la gestione della durata influenzi direttamente i pattern architetturali.

Il ciclo di vita del middleware è tipicamente singleton, essendo istanziato una volta all’avvio dell’applicazione. Se un servizio Scoped (come DbContext) viene iniettato nel costruttore del middleware, quella istanza di DbContext diventerebbe anch’essa un singleton, condivisa tra tutte le richieste. Questo violerebbe l’intento della durata Scoped (una istanza per richiesta) e potrebbe portare a problemi di integrità dei dati o condizioni di gara. Il framework previene esplicitamente questo comportamento lanciando un’eccezione se un servizio Scoped viene iniettato tramite costruttore in un Singleton (come il middleware). Iniettando il servizio Scoped nel metodo InvokeAsync, il contenitore DI risolve una nuova istanza Scoped per ogni richiesta in arrivo. Questo si allinea con la definizione della durata Scoped e assicura un comportamento corretto. Questo pattern è una best practice fondamentale per il middleware. Dimostra che la DI non è una soluzione “taglia e cuci” e richiede una comprensione dei cicli di vita dei componenti sottostanti per essere applicata correttamente. Rafforza l’importanza di abbinare la durata del servizio al contesto di utilizzo.

IV. Best Practice per la Dependency Injection in ASP.NET Core

Per costruire applicazioni robuste e manutenibili, è essenziale aderire a determinate best practice e riconoscere le trappole comuni nella Dependency Injection in ASP.NET Core.

A. Progettazione di Servizi per la DI

La progettazione efficace dei servizi è cruciale per massimizzare i benefici della DI.

È fondamentale dipendere sempre da interfacce o classi base astratte, non da implementazioni concrete.Questo costituisce la pietra angolare dell’accoppiamento lasco.

I servizi dovrebbero essere piccoli, ben strutturati e facilmente testabili, aderendo al Principio di Responsabilità Unica (SRP). Se una classe presenta molte dipendenze iniettate, ciò potrebbe indicare un eccesso di responsabilità; in tal caso, è consigliabile rifattorizzare la classe spostando alcune delle sue responsabilità in nuove classi. Il “proliferare di costruttori” (un costruttore con molti parametri) è un forte segnale di violazione del SRP. È un indicatore visivo che la classe sta facendo troppo, e la DI rende questo problema esplicito forzando tutte le dipendenze nella firma del costruttore. Se una classe necessita di molti tipi diversi di dipendenze, probabilmente sta eseguendo più compiti non correlati. Ad esempio, se un

UserService ha bisogno di IDatabaseRepository, IEmailSender e IFileLogger, potrebbe essere responsabile dell’accesso ai dati, delle notifiche e della registrazione, violando il SRP. La violazione del SRP rende le classi più difficili da comprendere, testare e mantenere. Le modifiche a una responsabilità potrebbero inavvertitamente influenzarne un’altra. La DI, rendendo esplicite le dipendenze, agisce come un “campanello d’allarme” per i difetti di progettazione. Un costruttore complesso segnala la necessità di rifattorizzazione, portando a componenti più piccoli, più focalizzati e veramente riutilizzabili.

È consigliabile evitare classi e membri statici con stato, per non creare uno stato globale. Invece, le applicazioni dovrebbero essere progettate per utilizzare servizi singleton per dati condivisi e stateless. Non si dovrebbe istanziare direttamente classi dipendenti all’interno dei servizi, poiché questo accoppia il codice a una particolare implementazione. Le dipendenze richieste dovrebbero essere definite esplicitamente nel costruttore del servizio, garantendo che il servizio non possa essere costruito senza di esse.10 Le dipendenze iniettate dovrebbero essere assegnate a campi/proprietà

readonly per prevenire riassegnazioni accidentali. Infine, per la configurazione, si raccomanda l’uso del pattern

IOptions<T> per associare la configurazione a oggetti fortemente tipizzati, anziché memorizzare la configurazione direttamente nel contenitore di servizi.

B. Scelta della Durata del Servizio Appropriata

La scelta della durata del servizio è una decisione cruciale che influisce sulle prestazioni e sulla gestione dello stato dell’applicazione. Non si dovrebbe scegliere Singleton solo perché è percepito come “più veloce”; la durata deve corrispondere alla responsabilità e allo stato del servizio.

  • Transient: Da utilizzare per servizi stateless, leggeri e “usa e getta” che necessitano di un’istanza pulita ogni volta.
  • Scoped: Da utilizzare per tutto ciò che è legato a una richiesta o a un’operazione logica, dove lo stato deve essere mantenuto all’interno di quell’ambito.
  • Singleton: Da utilizzare solo per servizi globali, stateless e thread-safe.

Un errore comune da evitare sono le “dipendenze catturate”: non si dovrebbe mai far dipendere un servizio singleton da un servizio transient o scoped. Il contenitore DI predefinito di ASP.NET Core lancia eccezioni in tali casi per prevenire problemi. Questa è una regola critica per prevenire bug sottili e perdite di memoria. La decisione del framework di lanciare proattivamente eccezioni per le dipendenze catturate è una scelta di progettazione che privilegia la correttezza e la stabilità rispetto al fallimento silenzioso. Questo meccanismo “fail-fast”, sebbene a volte inizialmente frustrante per gli sviluppatori, previene problemi di runtime difficili da diagnosticare relativi a una gestione errata dello stato in ambienti concorrenti. Se il contenitore DI consentisse a un Singleton di contenere un servizio Scoped senza avvertimenti, l’applicazione potrebbe inizialmente sembrare funzionare.

Tuttavia, nel tempo, sotto carico o con schemi di richiesta specifici, l’istanza Scoped condivisa (ora effettivamente un Singleton) porterebbe a corruzione dei dati, condizioni di gara o comportamenti errati perché non viene reimpostata per ogni richiesta come previsto. Questi bug sono notoriamente difficili da debuggare. Lanciando un’eccezione precocemente (all’avvio o alla prima risoluzione), il framework costringe lo sviluppatore a risolvere immediatamente la mancata corrispondenza della durata. Questa decisione di progettazione è una potente protezione. Insegna agli sviluppatori l’importanza della coerenza della durata e li aiuta a costruire sistemi più robusti e prevedibili prevenendo una classe comune di errori relativi alla DI.

C. Evitare Problemi Comuni e Anti-Pattern

Per una corretta implementazione della DI, è importante evitare alcuni pattern che ne minano i benefici.

Il pattern Service Locator dovrebbe essere evitato. Non si dovrebbe utilizzare GetService o GetRequiredService direttamente per ottenere servizi quando la DI può essere utilizzata in modo più appropriato. Questo approccio rende le dipendenze implicite e più difficili da individuare, specialmente durante i test unitari. Il pattern Service Locator, sebbene apparentemente conveniente, mina i benefici fondamentali della DI reintroducendo dipendenze nascoste e rendendo il codice più difficile da rifattorizzare e testare. È una regressione verso una progettazione strettamente accoppiata, annullando i vantaggi ottenuti dall’uso di un contenitore DI. Se una classe ha bisogno di

IMyService ma, invece di prenderlo nel costruttore, chiama _serviceProvider.GetService<IMyService>() all’interno di un metodo, la dipendenza da IMyService non è più esplicita nel costruttore della classe. Quando si guarda la firma della classe, non è immediatamente ovvio di cosa abbia bisogno. Questo rende i test unitari più difficili, poiché non è possibile simulare facilmente IMyService semplicemente passando un mock al costruttore. Si dovrebbe simulare IServiceProvider stesso, il che è più complesso. Rende anche la rifattorizzazione più difficile perché le dipendenze sono nascoste all’interno dei corpi dei metodi. Il pattern Service Locator è un anti-pattern perché reintroduce l’accoppiamento e la mancanza di testabilità che la DI mira a risolvere. È un segno che i benefici della DI non vengono pienamente sfruttati.

Si dovrebbe evitare di chiamare BuildServiceProvider durante la configurazione dei servizi. Invece, è preferibile utilizzare overload che includono

IServiceProvider se è necessario risolvere un servizio durante la registrazione di un altro servizio.

L’accesso statico ai servizi è un altro anti-pattern. Non si dovrebbe catturare IApplicationBuilder.ApplicationServices come campo o proprietà statica per un uso altrove. Questo crea uno stato globale e accoppia strettamente parti dell’applicazione.

È importante essere consapevoli che i servizi transient IDisposable risolti dal contenitore di livello superiore vengono catturati per lo smaltimento, il che può portare a perdite di memoria. Per tali casi, è consigliabile utilizzare il pattern factory.

Infine, C# non supporta i costruttori asincroni. Per i servizi che richiedono un’inizializzazione asincrona, è necessario utilizzare metodi asincroni dopo la risoluzione sincrona del servizio.

V. Conclusione

La Dependency Injection (DI), in quanto implementazione del principio di Inversion of Control (IoC), è un pilastro fondamentale per la costruzione di applicazioni C# e ASP.NET Core che siano disaccoppiate, facilmente testabili e manutenibili. La sua adozione trasforma radicalmente il modo in cui i componenti interagiscono, promuovendo un’architettura più flessibile e resiliente.

Il contenitore DI integrato in ASP.NET Core ha notevolmente semplificato l’adozione di questo pattern, incoraggiando le migliori pratiche attraverso meccanismi di registrazione intuitivi e una gestione del ciclo di vita dei servizi ben definita. La facilità con cui è possibile configurare e iniettare dipendenze ha reso la DI una componente naturale e potente dello sviluppo moderno con.NET.

Tuttavia, una comprensione approfondita delle durate dei servizi – Transient, Scoped e Singleton – è assolutamente cruciale. La scelta errata della durata può portare a problemi sottili ma gravi, come condizioni di gara, perdite di memoria o comportamenti inattesi, specialmente in ambienti concorrenti. Il framework ASP.NET Core, con i suoi meccanismi di validazione, agisce come una salvaguardia essenziale, prevenendo attivamente errori comuni e forzando gli sviluppatori a confrontarsi con le implicazioni delle loro scelte di durata.

Sebbene il contenitore DI integrato sia sufficiente per la maggior parte delle esigenze applicative, scenari più avanzati potrebbero giustificare l’esplorazione di contenitori DI di terze parti, che offrono funzionalità aggiuntive come l’iniezione di proprietà per impostazione predefinita, i contenitori figlio o la registrazione basata su convenzioni.

In definitiva, padroneggiare la Dependency Injection non è solo una questione di apprendere un pattern tecnico; si tratta di adottare una mentalità che conduce a architetture software più pulite, più resilienti e più adattabili. Questa mentalità è indispensabile per navigare con successo le complessità dello sviluppo di applicazioni moderne e per costruire sistemi che possano evolvere e scalare con efficacia nel tempo.