Ecco una classe che non puoi testare senza un database live e un server SMTP funzionante:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository("Server=prod-db;Database=Orders;...");
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.company.com");

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService costruisce da sé SqlOrderRepository e SmtpEmailSender con new, connection string compresa. Ogni test che tocca PlaceOrder richiede un vero database Orders e un vero server di posta, altrimenti fallisce prima ancora che la tua assertion venga eseguita. La logica che vuoi testare sta in due righe: salva l’ordine, invia la conferma. Ma è saldata a due classi concrete che non puoi sostituire.

Cosa significa dependency injection in pratica

La dependency injection consiste nel passare a una classe gli oggetti di cui ha bisogno, invece di lasciare che se li costruisca da sola. La classe dichiara da cosa dipende. Il codice esterno alla classe decide quale implementazione concreta riceve. Container e lifetime sono gli strumenti costruiti attorno a questa singola idea.

Riscrivi OrderService in modo che riceva le sue dipendenze tramite il costruttore, come interfacce invece che come classi concrete:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService non sa più se il repository scrive su SQL Server o su una lista in memoria, né se l’email parte via SMTP o non parte affatto. Conosce due interfacce e ne chiama i metodi. Chi crea l’OrderService decide cosa collegare: un test passa dei fake, l’applicazione web passa l’implementazione reale.

Questa si chiama constructor injection, ed è la forma che userai per quasi tutto. La property injection (impostare una proprietà pubblica dopo la costruzione) e la method injection (passare una dipendenza al singolo metodo che ne ha bisogno) esistono per le dipendenze opzionali e i valori una tantum. Il container integrato di ASP.NET Core non supporta affatto la property injection. La regola è semplice: se una classe non può fare il suo lavoro senza qualcosa, quel qualcosa va nel costruttore.

Come la dependency injection rende una classe facile da testare

Con le dipendenze che arrivano come parametri a interfaccia, un test può passare a OrderService quello che vuole:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FakeOrderRepository : IOrderRepository
{
    public List<Order> SavedOrders { get; } = new();
    public void Save(Order order) => SavedOrders.Add(order);
}

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public void Send(string to, string subject) => SentTo.Add(to);
}

[Fact]
public void PlaceOrder_SavesOrderAndSendsConfirmation()
{
    var repository = new FakeOrderRepository();
    var emailSender = new FakeEmailSender();
    var service = new OrderService(repository, emailSender);

    service.PlaceOrder(new Order { CustomerEmail = "[email protected]" });

    Assert.Single(repository.SavedOrders);
    Assert.Equal("[email protected]", emailSender.SentTo.Single());
}

Il test gira in millisecondi e non tocca né database né rete. Si colloca all’estremità unitaria dello spettro descritto in Test Unitari vs Test di Integrazione vs Test End-to-End: una classe, i collaboratori sostituiti, un comportamento verificato. Queste classi scritte a mano sono fake, piccole implementazioni funzionanti con un proprio stato. Una libreria di mocking come Moq o NSubstitute genera lo stesso tipo di sostituto per te, così non devi scrivere una classe per ogni interfaccia.

Cosa fa un container DI, e perché non ne hai sempre bisogno

Niente di quanto visto finora richiedeva un framework. Puoi collegare a mano le dipendenze reali nell’entry point della tua applicazione:

1
2
3
var repository = new SqlOrderRepository(connectionString);
var emailSender = new SmtpEmailSender(smtpHost);
var orderService = new OrderService(repository, emailSender);

Questo si chiama “pure DI” (o “DI del poveraccio”), e per un’app piccola basta e avanza. Lo schema è passare le dipendenze dall’esterno. Un container automatizza il collegamento, e inizia a ripagarsi quando hai decine di classi con dipendenze che si sovrappongono, e cambiare un costruttore significa modificare il codice di composizione in cinque punti diversi.

ASP.NET Core ha un container integrato. Registri ogni interfaccia contro un’implementazione una sola volta, all’avvio, e il framework risolve il grafo ogni volta che costruisce un controller, un handler di una minimal API o qualsiasi altro servizio registrato:

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository>(sp =>
    new SqlOrderRepository(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddTransient<IEmailSender>(sp =>
    new SmtpEmailSender(builder.Configuration["Smtp:Host"]));

var app = builder.Build();

SqlOrderRepository e SmtpEmailSender richiedono una connection string e un host name, non un costruttore senza parametri, quindi ogni registrazione è una factory: una lambda che riceve il container (sp) e restituisce l’istanza. Quando nessuna delle due implementazioni ha bisogno di un valore del genere, la forma con due argomenti di tipo è più breve: AddScoped<IOrderRepository, SqlOrderRepository>().

Registra OrderService stesso allo stesso modo (builder.Services.AddScoped<OrderService>()) e potrai richiederlo nel costruttore di un controller. Il container lo costruisce, risolve IOrderRepository e IEmailSender a partire da queste registrazioni, e tu non chiami mai new.

I lifetime dei servizi in ASP.NET Core: singleton, scoped, transient

AddScoped, AddTransient e AddSingleton si registrano allo stesso modo; il nome del metodo è il lifetime, e scegliere quello sbagliato causa la maggior parte dei bug di DI in ASP.NET Core.

LifetimeNuova istanza creataUso tipico
TransientOgni volta che viene richiestaServizi stateless ed economici (un formatter, un validator)
ScopedUna volta per ogni richiesta HTTPTutto ciò che è legato alla richiesta corrente: un DbContext, una unit of work
SingletonUna sola volta, per tutta la vita dell’appStato condiviso e thread-safe: configurazione, un connection pool, una cache

La registrazione AddScoped qui sopra dà a ogni richiesta il proprio SqlOrderRepository. Se il repository incapsula un DbContext, due richieste concorrenti non potranno mai condividere una transazione a metà. La registrazione AddTransient crea un nuovo SmtpEmailSender a ogni punto di injection. Va bene così, perché il sender non mantiene alcuno stato legato alla singola richiesta.

Captive dependency: un servizio scoped intrappolato in un singleton

Questa è la regola sui lifetime che morde. Un servizio può dipendere in sicurezza solo da cose che vivono quanto lui, o più a lungo. Un singleton può ricevere altri singleton. Un servizio scoped può ricevere scoped o singleton. Un transient può ricevere qualsiasi cosa. Il fallimento si ha quando un singleton riceve nel costruttore una dipendenza scoped:

1
2
3
4
5
6
7
8
9
public class ReportCache // registrato come singleton
{
    private readonly AppDbContext _db; // registrato come scoped

    public ReportCache(AppDbContext db)
    {
        _db = db;
    }
}

Il container costruisce ReportCache una sola volta, e l’AppDbContext che ha ricevuto resta in quel campo per tutta la vita dell’applicazione. Questa è una captive dependency. Ogni richiesta successiva condivide un unico DbContext, una classe che non è thread-safe né pensata per vivere a lungo. Lo vedi sotto forma di letture obsolete, oppure di InvalidOperationException che si lamentano perché una seconda operazione è partita su questo context prima che la precedente fosse completata.

ASP.NET Core intercetta il problema in sviluppo. Con la scope validation attiva, che è il comportamento predefinito nell’ambiente Development, risolvere un singleton che ha bisogno di un servizio scoped lancia una InvalidOperationException all’avvio, invece di corrompere i dati in produzione. Se un singleton ha davvero bisogno ogni tanto di un servizio scoped, inietta IServiceScopeFactory, crea uno scope dove ti serve, e risolvi il servizio da quello scope invece di tenerlo in un campo.

Anti-pattern della dependency injection: service locator e costruttori sovraccarichi

Service locator travestito. Iniettare il container stesso ed estrarne le dipendenze dentro un metodo sembra DI, ma butta via esattamente quello che la DI ti dà:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class OrderService
{
    private readonly IServiceProvider _provider;

    public OrderService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void PlaceOrder(Order order)
    {
        var repository = _provider.GetService<IOrderRepository>();
        repository.Save(order);
    }
}

Il costruttore non dice più di cosa ha bisogno OrderService. Devi leggere ogni metodo per scoprirlo, e una registrazione mancante emerge come un null nel mezzo di PlaceOrder, invece che come un errore nel momento in cui la classe viene costruita. Inietta le interfacce specifiche che usi, non l’oggetto che le distribuisce.

Il costruttore con dodici parametri. Una classe che ha bisogno di IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger e altri sette è una classe che fa troppe cose. Non è stata la DI a causarlo; lo ha solo reso visibile. È lo stesso sintomo descritto in Cos’è lo Spaghetti Code e Come Rifattorizzarlo. Dividi la classe per responsabilità e il costruttore si riduce da solo. Raggruppare le dodici dipendenze in un unico oggetto “services” nasconde il problema senza risolverlo.

Entrambi passano facilmente la code review. Smettono di passare inosservati la prima volta che qualcuno prova a scrivere un unit test per quella classe.

Da dove iniziare con la dependency injection

Scegli una classe nel tuo codebase che chiama new su un client di database, un client HTTP o un mail sender. Cambia il suo costruttore in modo che riceva un’interfaccia, collega a mano l’implementazione reale in Program.cs, e scrivi un test che passi un fake. In un’app ASP.NET Core, salta il collegamento manuale e registrala in builder.Services, dato che il container è già lì. Scegli il lifetime in modo deliberato per tutto ciò che mantiene uno stato, e lascia attiva la scope validation, così il framework intercetta una captive dependency prima dei tuoi utenti.

Articoli correlati