La dependency injection in C# e .NET
Nell'articolo precedente abbiamo incontrato il principio di inversione delle dipendenze, secondo cui il codice dovrebbe dipendere da astrazioni anziché da implementazioni concrete. La dependency injection, spesso abbreviata in DI, è la tecnica che mette in pratica questo principio: invece di creare da sé le proprie dipendenze, una classe le riceve dall'esterno. La piattaforma .NET include un contenitore di dependency injection integrato, ormai centrale in quasi ogni applicazione moderna. In questo articolo vediamo come funziona e come usarlo.
Il problema dell'accoppiamento forte
Consideriamo una classe che crea direttamente le proprie dipendenze. Questo crea un accoppiamento forte: la classe è legata a un'implementazione specifica, difficile da sostituire e da testare in isolamento.
class OrderService
{
// Accoppiamento forte: la dipendenza e creata internamente
private readonly EmailSender _sender = new EmailSender();
public void PlaceOrder()
{
// logica dell'ordine
_sender.Send("Ordine confermato");
}
}
Per testare OrderService senza inviare email reali, o per sostituire l'invio con un altro canale, saremmo costretti a modificare la classe. La dependency injection risolve questo problema.
Iniettare le dipendenze
La forma più diffusa di DI è l'iniezione tramite costruttore: la classe dichiara le proprie dipendenze come parametri del costruttore, tipizzate su interfacce, e qualcun altro si occupa di fornirle. La classe diventa così disaccoppiata dall'implementazione concreta.
interface IMessageSender
{
void Send(string message);
}
class OrderService
{
private readonly IMessageSender _sender;
// La dipendenza viene iniettata dall'esterno tramite il costruttore
public OrderService(IMessageSender sender)
{
_sender = sender;
}
public void PlaceOrder()
{
_sender.Send("Ordine confermato");
}
}
Il contenitore di servizi
Fornire manualmente ogni dipendenza a ogni oggetto sarebbe faticoso. Qui entra in gioco il contenitore di dependency injection, un componente che sa come costruire gli oggetti e risolvere automaticamente le loro dipendenze. In .NET si configura registrando le associazioni tra interfacce e implementazioni.
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Registra l'implementazione da usare per ciascuna astrazione
services.AddTransient<IMessageSender, EmailSender>();
services.AddTransient<OrderService>();
// Costruisce il contenitore
var provider = services.BuildServiceProvider();
// Il contenitore crea OrderService iniettando automaticamente EmailSender
var orderService = provider.GetRequiredService<OrderService>();
orderService.PlaceOrder();
Il contenitore analizza il costruttore di OrderService, vede che richiede un IMessageSender, cerca la registrazione corrispondente, crea un'istanza di EmailSender e la passa. Se anche EmailSender avesse dipendenze, verrebbero risolte a loro volta, ricorsivamente.
I cicli di vita dei servizi
Quando si registra un servizio, si sceglie anche il suo ciclo di vita, ovvero per quanto tempo un'istanza viene riutilizzata. La scelta corretta è importante per il comportamento e le prestazioni dell'applicazione. Esistono tre cicli di vita principali.
| Ciclo di vita | Metodo | Comportamento |
|---|---|---|
| Transient | AddTransient | Una nuova istanza a ogni richiesta |
| Scoped | AddScoped | Una istanza per ambito, tipicamente per richiesta web |
| Singleton | AddSingleton | Una sola istanza per l'intera applicazione |
// Servizio senza stato: una nuova istanza ogni volta
services.AddTransient<IMessageSender, EmailSender>();
// Servizio legato a una richiesta: condiviso all'interno dell'ambito
services.AddScoped<IUserRepository, UserRepository>();
// Servizio condiviso globalmente, adatto a stato immutabile o cache
services.AddSingleton<IConfigurationCache, ConfigurationCache>();
Va prestata attenzione a non iniettare un servizio con ciclo di vita breve in uno con ciclo di vita più lungo: ad esempio, iniettare uno scoped in un singleton catturerebbe l'istanza scoped per sempre, causando comportamenti errati. Il contenitore rileva alcuni di questi errori in fase di validazione.
Registrare implementazioni multiple e istanze
Il contenitore supporta scenari più articolati. Si possono registrare più implementazioni della stessa interfaccia e riceverle tutte come collezione, oppure fornire un'istanza già creata o una funzione factory che il contenitore userà per costruire il servizio.
// Piu implementazioni della stessa interfaccia
services.AddTransient<INotifier, EmailNotifier>();
services.AddTransient<INotifier, SmsNotifier>();
// Registrazione tramite factory con logica personalizzata
services.AddSingleton<IClock>(provider => new SystemClock());
// Un consumatore puo ricevere tutte le implementazioni
class NotificationHub
{
public NotificationHub(IEnumerable<INotifier> notifiers)
{
// notifiers contiene sia EmailNotifier sia SmsNotifier
}
}
La dependency injection nelle applicazioni reali
Negli esempi abbiamo configurato manualmente il contenitore, ma nelle applicazioni ASP.NET Core, che vedremo nel prossimo articolo, gran parte di questa infrastruttura è già predisposta. Il framework crea il contenitore, registra i propri servizi e inietta automaticamente le dipendenze nei controller e negli altri componenti. Registrare i propri servizi si riduce a poche righe nel punto di avvio dell'applicazione.
I vantaggi
Adottare la dependency injection porta benefici che si accumulano man mano che l'applicazione cresce:
- Testabilità: nei test si possono iniettare implementazioni finte al posto di quelle reali, isolando il codice da testare.
- Flessibilità: cambiare implementazione richiede di modificare una sola registrazione, non decine di punti nel codice.
- Chiarezza: le dipendenze di una classe sono esplicite nel suo costruttore, rendendo evidente di cosa ha bisogno per funzionare.
Conclusione
Abbiamo visto come la dependency injection metta in pratica il principio di inversione delle dipendenze, spostando la creazione delle dipendenze all'esterno delle classi. Abbiamo usato il contenitore integrato di .NET per registrare e risolvere i servizi, compreso il significato dei cicli di vita transient, scoped e singleton, e gli scenari con implementazioni multiple e factory. Nel prossimo articolo metteremo tutto questo al servizio della costruzione di una Web API con ASP.NET Core.