Dependency injection in C#: guida per .NET 10
Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 27 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Ha guidato progetti enterprise, formato centinaia di sviluppatori e aiutato aziende di ogni dimensione a semplificare la complessità trasformando il software in guadagni per il business.

Giacomo lavora da otto mesi in una software house di Brescia. Il primo compito serio che gli assegnano è una modifica al calcolo degli sconti di un gestionale ordini, e la classe da toccare ha un costruttore con nove righe di new: una connessione a SQL Server, un client di posta, un registro degli eventi su file. Giacomo aggiunge la regola in venti minuti. Poi prova a scrivere il test e si ferma per un'ora, perché per far girare quella classe servono un database vero, un server di posta raggiungibile e una cartella dove scrivere.

Il collega anziano passa dietro la scrivania, guarda lo schermo e dice una frase sola: "Qui manca la dependency injection". Giacomo la conosce di nome. L'ha letta tre volte in tre tutorial diversi e ogni volta ha chiuso la pagina con la sensazione di aver capito la definizione e nient'altro. Il problema sta nella definizione, che suona più difficile di quanto sia. L'iniezione delle dipendenze si riassume in una riga: una classe non crea gli oggetti di cui ha bisogno, li riceve da fuori.

In .NET quel "da fuori" ha un nome preciso, il contenitore di dipendenze integrato nel framework, e lo usi già ogni volta che scrivi builder.Services.Add... in Program.cs. Questa guida parte dalla scena di Giacomo e ti porta fino al codice che regge in produzione: che cosa significa davvero, come funziona il contenitore di .NET 10, la differenza fra transient, scoped e singleton, l'errore sullo scoped dentro il singleton che prima o poi vedrai, le implementazioni multiple e i servizi con chiave, le impostazioni, i test, gli errori più frequenti, dove mettere le registrazioni in un progetto a strati e quando serve un contenitore esterno.

Scrivo software dal 1999 e in aula la dependency injection è l'argomento in cui vedo il salto più netto fra chi scrive codice che funziona oggi e chi scrive codice che si può ancora cambiare fra due anni. Gli esempi sono in C# con ASP.NET Core su .NET 10. Se ti interessa il quadro dei livelli in cui questa tecnica si inserisce, lo trovi in Clean Architecture in C#.

Che cos'è la dependency injection in C# e a cosa serve?

La dependency injection, in italiano iniezione delle dipendenze, è una tecnica in cui una classe riceve dall'esterno gli oggetti di cui ha bisogno, di solito attraverso il costruttore, invece di crearli da sola con new. Serve a sostituire una parte del sistema senza toccare le altre.

Lo stesso servizio, prima e dopo

Ecco la classe di Giacomo ridotta all'osso. Nella prima versione la classe costruisce da sola la connessione e il client di posta. Nella versione corretta li chiede nel costruttore, e lo fa tramite interfacce, cioè contratti che dicono cosa si può fare senza dire come.

// Prima: la classe decide da sola con chi parlare
public class ScontiServizio
{
    private readonly OrdiniRepository _ordini = new OrdiniRepository("Server=prod;...");
    private readonly SmtpClient _posta = new SmtpClient("smtp.azienda.it");
}

// Dopo: la classe dichiara cosa le serve
public class ScontiServizio(IOrdiniRepository ordini, INotificatore notificatore)
{
    public async Task ApplicaAsync(int ordineId) { /* usa ordini e notificatore */ }
}

La seconda versione non sa se i dati vengono da SQL Server, da un file o da una lista in memoria. Non sa se la notifica parte per posta o per messaggio. Sa solo che qualcuno le consegnerà qualcosa che rispetta quei due contratti.

Perché la sostituzione conta

I vantaggi concreti sono tre, e li vedi tutti nel primo mese di lavoro su un gestionale. Il primo è il test: nel test passi un repository finto che ritorna tre ordini fissi, e la regola degli sconti si verifica in dieci millisecondi, senza database. Il secondo è il cambio di implementazione: se l'azienda passa dal server SMTP interno a un servizio di posta cloud, scrivi una nuova classe che rispetta INotificatore e cambi una riga di registrazione, mentre il resto del codice resta com'è. Il terzo è la configurazione per ambiente: in sviluppo la notifica scrive su console, in produzione manda davvero il messaggio, e la scelta sta in un posto solo.

Tre nomi per tre cose diverse

Nei testi trovi mescolati tre termini. L'inversione delle dipendenze è il quinto principio SOLID: i moduli di alto livello dipendono da astrazioni, non da dettagli. L'inversione del controllo (IoC) descrive il fatto che la creazione degli oggetti passa dalla tua classe a un chiamante o a un framework. La dependency injection è la tecnica concreta che realizza le prime due: il costruttore dichiara, il contenitore consegna. Per le basi sulle interfacce, che sono il materiale di cui è fatta tutta questa storia, trovi un ripasso in interfacce e classi astratte in C#.

Come funziona il contenitore di dipendenze integrato in .NET 10?

Il contenitore integrato, nel pacchetto Microsoft.Extensions.DependencyInjection, è una tabella che associa un tipo richiesto, di solito un'interfaccia, al tipo concreto da creare. Si riempie in Program.cs tramite builder.Services e viene consultato ogni volta che il framework costruisce un controller, un gestore o un servizio.

Registrare: dire al contenitore cosa esiste

Una registrazione ha tre ingredienti: il tipo che qualcuno chiederà, il tipo concreto da creare e il ciclo di vita, di cui parliamo nella prossima sezione. Il metodo si chiama AddTransient, AddScoped o AddSingleton a seconda di quest'ultimo. Un'applicazione ASP.NET Core minima ha già decine di registrazioni fatte dal framework (registro eventi, configurazione, ambiente), e le tue si aggiungono nella stessa tabella.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrdiniRepository, OrdiniRepository>();
builder.Services.AddScoped<INotificatore, NotificatorePosta>();
builder.Services.AddScoped<ScontiServizio>();

var app = builder.Build();

Risolvere: il costruttore chiede, il contenitore consegna

Quando arriva una richiesta che coinvolge ScontiServizio, il contenitore guarda il suo costruttore, vede che servono IOrdiniRepository e INotificatore, li crea a loro volta (guardando i loro costruttori, e così via) e consegna l'oggetto finito. Il risultato è un grafo di oggetti costruito da solo, di qualsiasi profondità. Se una classe ha più costruttori, il contenitore sceglie quello con più parametri che riesce a soddisfare. I costruttori primari di C# 12, quelli con i parametri accanto al nome della classe, riducono molto il rumore e sono la forma che trovi negli esempi di questa guida.

Che cosa succede quando una registrazione manca

Dimenticare una riga in Program.cs è l'errore più comune di chi comincia. Il sintomo è un'eccezione InvalidOperationException con un testo del tipo "Unable to resolve service for type 'IOrdiniRepository' while attempting to activate 'ScontiServizio'". Il messaggio è leggibile e dice due cose utili: il tipo mancante e la classe che lo chiedeva. Il dettaglio da ricordare è il momento in cui compare: di default l'eccezione arriva alla prima richiesta che usa quella classe, non all'avvio. Quindi un'applicazione può partire senza errori, funzionare per ore, e fallire solo quando qualcuno apre per la prima volta la schermata rara. Più avanti vedremo come anticipare l'errore al momento dell'avvio.

Un'ultima nota per chi arriva da altri framework: il contenitore di .NET non usa attributi sulle classi né file di configurazione XML. Tutto è codice C# esplicito, e questo ha un effetto pratico. Premi F12 su una registrazione e arrivi alla classe concreta, senza magie da inseguire.

Transient, Scoped o Singleton: quale ciclo di vita scegliere?

Un servizio transient viene creato di nuovo a ogni richiesta al contenitore, uno scoped una volta per ogni ambito (in ASP.NET Core, una volta per richiesta HTTP), uno singleton una volta sola per tutta la vita dell'applicazione. La scelta dipende da due domande: il servizio ha uno stato, e quello stato è condiviso fra più utenti?

Transient: oggetti leggeri e senza stato

Un servizio transient nasce e muore insieme a chi lo usa. È la scelta giusta per calcolatori, validatori, formattatori: classi senza campi che cambiano nel tempo, dove creare una nuova istanza costa pochi byte. Il difetto compare quando un transient tiene risorse pesanti, per esempio un oggetto che apre file, e viene creato cinquanta volte in una richiesta. In quel caso conviene salire a scoped.

Scoped: il ciclo di vita della richiesta

In un'applicazione web lo scoped coincide con la richiesta. Tutte le classi coinvolte nella stessa richiesta ricevono la stessa istanza, e alla fine viene eliminata. È il ciclo di vita del DbContext di Entity Framework Core, e non per caso: il contesto traccia le modifiche di una sola unità di lavoro, e quell'unità coincide con la richiesta. Anche l'utente corrente, il carrello e qualsiasi cosa legata a "chi sta facendo questa operazione ora" vive qui.

Singleton: una sola istanza per tutti

Il singleton vive finché vive il processo, e tutti gli utenti lo condividono nello stesso istante. Va bene per una cache in memoria, per un client da riusare, per TimeProvider.System. La regola d'oro è una: un singleton deve essere sicuro quando due thread lo usano insieme. Un campo List<T> modificato da due richieste contemporanee è una corruzione di dati che compare una volta al mese, e non la riproduci mai quando serve.

Tre domande per decidere

  1. La classe ha campi che cambiano dopo la costruzione? Se no, transient va benissimo.
  2. Quello stato appartiene a una singola richiesta o operazione? Se sì, scoped.
  3. È condiviso fra tutti e protetto per l'uso concorrente? Solo allora singleton.
Istanze create da transient, scoped e singleton in due richieste HTTP consecutive in ASP.NET Core

Lo scoped fuori da una richiesta web

I servizi in background (i BackgroundService) sono singleton per natura, e non hanno una richiesta. Per usare un servizio scoped si crea un ambito a mano con IServiceScopeFactory, dentro un blocco using, a ogni giro di lavoro.

using var ambito = _fabbrica.CreateScope();
var ordini = ambito.ServiceProvider.GetRequiredService<IOrdiniRepository>();
await ordini.ElaboraCodaAsync(stoppingToken);

Perché compare l'errore "Cannot consume scoped service from singleton"?

L'errore compare quando un servizio singleton riceve nel costruttore un servizio scoped. Il singleton vive quanto l'applicazione, lo scoped dovrebbe morire con la richiesta: il contenitore rifiuta l'accoppiamento perché terrebbe in vita lo scoped per sempre. Il problema ha un nome tecnico, captive dependency, cioè dipendenza prigioniera.

Perché sarebbe pericoloso lasciarlo passare

Immagina una cache di listini in memoria registrata come singleton, che riceve nel costruttore un repository costruito sopra un DbContext. Alla prima richiesta il repository e il suo contesto vengono creati e rimangono legati alla cache. Dalla seconda richiesta in poi, tutti gli utenti usano lo stesso contesto: i dati letti restano vecchi, il tracciamento delle modifiche si accumula senza fine e due richieste contemporanee usano lo stesso oggetto non pensato per i thread. Le eccezioni che ne escono hanno messaggi strani, come "A second operation was started on this context instance", e non puntano alla causa.

Come lo scopri prima della produzione

Il generic host di ASP.NET Core abilita nell'ambiente di sviluppo due controlli, ValidateScopes e ValidateOnBuild. Il primo produce l'errore del titolo nel momento in cui un singleton chiede uno scoped. Il secondo controlla all'avvio che ogni servizio registrato possa essere costruito, e anticipa le registrazioni mancanti viste prima. In produzione sono spenti per risparmiare tempo di avvio, e questo spiega la sorpresa classica: il codice funziona sul portatile e si rompe in test o in produzione. Si accendono ovunque con una riga.

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

Il costo è un avvio più lento di qualche decina di millisecondi in un'applicazione media, un prezzo basso rispetto a una captive dependency scoperta il venerdì sera.

Come si risolve davvero

Ci sono tre strade, in ordine di preferenza. La prima è rivedere il ciclo di vita del consumatore: se la classe usa dati della richiesta, probabilmente non doveva essere singleton. La seconda è iniettare IServiceScopeFactory e creare un ambito breve ogni volta che serve il servizio scoped, come nel servizio in background della sezione precedente. La terza, per il solo caso di Entity Framework Core, è registrare IDbContextFactory<T>, che consegna un contesto nuovo a ogni chiamata ed è pensato proprio per i servizi di lunga durata e per Blazor Server. La scorciatoia da evitare è trasformare tutto in scoped per far sparire l'errore: si perde la cache, e si nasconde il disegno sbagliato.

Come si registrano più implementazioni della stessa interfaccia?

Ci sono tre strumenti: registrare più tipi per la stessa interfaccia e chiedere IEnumerable<T> per averli tutti, usare i servizi con chiave introdotti in .NET 8, oppure scrivere una factory a mano. Se chiedi la sola interfaccia, vince l'ultima registrazione. Questo ultimo dettaglio è la fonte di molti bug silenziosi.

Tutte le implementazioni insieme con IEnumerable

Il caso tipico è un insieme di regole che si applicano in sequenza: sconto per quantità, sconto cliente, promozione stagionale. Ognuna è una classe che rispetta IRegolaSconto, tutte registrate con AddScoped. Il servizio che calcola il prezzo chiede nel costruttore un IEnumerable<IRegolaSconto> e le riceve tutte, nell'ordine di registrazione. Aggiungere una regola nuova significa scrivere una classe e una riga in Program.cs, senza toccare il calcolo. È il pattern strategia, e nei gestionali con regole commerciali che cambiano ogni trimestre fa risparmiare ore di lavoro.

I servizi con chiave

Quando le implementazioni non vanno usate tutte insieme ma scelte una per volta, per esempio due fornitori di pagamento, .NET 8 ha introdotto i servizi con chiave. Si registrano con AddKeyedScoped<IPagamenti, PagamentiStripe>("stripe") e si chiedono nel costruttore con l'attributo [FromKeyedServices("stripe")]. Prima di questa funzione si scriveva una factory con un dizionario, e si poteva sbagliare. Un consiglio che ripeto in aula: la chiave non deve essere una stringa scritta in dieci punti diversi. Mettila in una costante o in un'enumerazione, altrimenti il refactoring di un nome diventa una ricerca testuale.

TryAdd, decorazione e generici aperti

I metodi TryAddScoped e simili registrano il servizio solo se non esiste già un'altra registrazione per quel tipo. Servono a chi scrive librerie, perché lasciano all'applicazione la possibilità di sostituire il comportamento predefinito. Il contenitore integrato non sa decorare un servizio, cioè avvolgerlo in un altro che aggiunge un comportamento come la registrazione dei tempi. Si ottiene con una registrazione a espressione, in cui si costruisce a mano il decoratore passandogli l'originale, oppure con la libreria Scrutor, di cui parlo nell'ultima sezione. Infine, per le classi generiche come IRepository<T> basta una registrazione sola con i tipi aperti: AddScoped(typeof(IRepository<>), typeof(Repository<>)). Da quel momento chiunque chieda IRepository<Cliente> ottiene la versione corretta.

Come si leggono le impostazioni con IOptions, IOptionsSnapshot e IOptionsMonitor?

Il modello delle opzioni lega una sezione di appsettings.json a una classe e la consegna come IOptions<T> (letta una volta), IOptionsSnapshot<T> (riletta a ogni richiesta) o IOptionsMonitor<T> (con notifica del cambiamento, utilizzabile anche nei singleton). Si evita così di passare ovunque la configurazione grezza e le stringhe magiche.

Il binding con la validazione all'avvio

Per ogni gruppo di impostazioni si scrive una piccola classe, per esempio PostaOpzioni con server, porta e mittente, e la si lega alla sezione del file. La parte che molti saltano è la validazione: con ValidateDataAnnotations e ValidateOnStart l'applicazione rifiuta di partire se la porta è zero o il server è vuoto. Un errore di configurazione che si scopre al deploy, con un messaggio chiaro, costa un minuto. Lo stesso errore che si scopre alla prima email del lunedì costa un'ora e una telefonata.

builder.Services.AddOptions<PostaOpzioni>()
    .Bind(builder.Configuration.GetSection("Posta"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Quale delle tre interfacce scegliere

  • IOptions<T> è un singleton: legge i valori all'avvio e non li rilegge. Va bene per tutto ciò che cambia solo con un riavvio.
  • IOptionsSnapshot<T> è scoped: a ogni richiesta rilegge il file. Serve quando vuoi cambiare un valore, per esempio una soglia di sconto, senza riavviare. Non si può iniettare in un singleton, per la regola della sezione precedente.
  • IOptionsMonitor<T> è un singleton che conosce sempre il valore corrente e avvisa chi si iscrive quando cambia. È la scelta per i servizi in background e per i singleton che devono reagire a una modifica.

Dove stanno i segreti

Le password e le chiavi non vanno in appsettings.json sotto controllo di versione. In sviluppo si usano gli user secrets, in produzione le variabili d'ambiente o un archivio come Azure Key Vault. Il bello del modello delle opzioni è che la classe resta la stessa: cambia solo il provider di configurazione, e il codice che legge PostaOpzioni non lo nota.

Come si testa il codice che usa la dependency injection?

Nei test unitari si crea la classe con new, passando al costruttore doppi di test al posto delle dipendenze reali, senza alcun contenitore. Nei test di integrazione si usa WebApplicationFactory e si sostituiscono le registrazioni con ConfigureTestServices. Il contenitore serve all'applicazione, non ai test unitari.

Il test unitario: nessun contenitore, solo il costruttore

Torniamo a Giacomo. La regola degli sconti dipende da un repository e da un orologio, perché alcune promozioni valgono solo in certi mesi. Nel test crea un repository finto, scritto a mano in cinque righe o generato con una libreria come NSubstitute, e un FakeTimeProvider del pacchetto Microsoft.Extensions.TimeProvider.Testing con la data fissata al 15 dicembre. Il servizio viene costruito con quelle due cose, e il test verifica che la promozione natalizia si applichi. Niente rete, niente database, tempo di esecuzione di pochi millisecondi. Se vuoi il quadro completo di scrittura dei test in .NET, c'è testare il codice in .NET con xUnit.

Questo esempio mostra anche perché conviene iniettare TimeProvider, introdotto in .NET 8, invece di chiamare DateTime.Now dentro la classe. Un'ora che non controlli rende i test fragili: funzionano il martedì e si rompono a fine mese.

Il test di integrazione: sostituire un pezzo del sistema vero

Per provare un endpoint reale, WebApplicationFactory<Program> avvia l'applicazione in memoria. Il metodo ConfigureTestServices permette di sostituire una sola registrazione, tipicamente quella che parla con il mondo esterno: il notificatore di posta diventa un finto che salva i messaggi in una lista, e il test verifica che, dopo la chiamata all'endpoint, nella lista ci sia il messaggio atteso. Per far vedere la classe Program al progetto di test, nei progetti con istruzioni di primo livello si aggiunge in fondo al file public partial class Program { }.

var fabbrica = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
    b.ConfigureTestServices(s => s.AddSingleton<INotificatore, NotificatoreFinto>()));

Quando il test è difficile, la classe ha un problema

Un test che richiede dieci doppi da configurare è quasi sempre un segnale sul codice, non sul test. Una classe con otto dipendenze fa troppe cose, e ne conosce troppe. Il rimedio non sta in un contenitore più potente: sta nello spezzare la classe in due, ciascuna con tre dipendenze, ciascuna con una responsabilità chiara.

Quali errori di dependency injection si vedono più spesso nei progetti reali?

Gli errori che incontro più spesso sono cinque: il service locator, il costruttore con otto parametri, new HttpClient() dentro i metodi, il DbContext in un singleton e l'uso di servizi scoped dopo la fine della richiesta. Tutti si riconoscono dal sintomo, e tutti hanno una correzione di poche righe.

Il service locator: chiedere il contenitore invece del servizio

Succede quando una classe riceve IServiceProvider e dentro i metodi chiama GetService<IOrdiniRepository>(). Il codice funziona, ma le dipendenze reali spariscono dal costruttore e diventano invisibili: per capire cosa usa la classe devi leggere ogni riga. I test diventano più difficili, perché devi costruire un contenitore intero. L'eccezione legittima è la fabbrica di ambiti vista nei servizi in background, che è un servizio dedicato e non un localizzatore generico.

Il costruttore da otto parametri

Il contenitore rende così facile aggiungere un parametro che nessuno si accorge di quando la classe è diventata un ammasso. Da cinque dipendenze in su, fermati e guarda se due gruppi di parametri non formano una classe per conto loro. È lo stesso ragionamento fatto nei progetti con regole commerciali pesanti, dove il servizio unico "Ordini" è diventato quattro servizi più piccoli.

HttpClient creato a mano

Scrivere new HttpClient() in un metodo chiamato mille volte al minuto esaurisce le connessioni di rete del server, perché ogni istanza lascia un socket aperto per un po'. L'altro estremo, un HttpClient statico, non rispetta i cambi di DNS. La soluzione sta nel contenitore: AddHttpClient registra una fabbrica che gestisce le connessioni, e un client tipizzato riceve il suo HttpClient già configurato con indirizzo base e intestazioni. È anche il punto in cui si aggancia la resilienza descritta in Polly in .NET.

Lo scoped usato dopo la fine della richiesta

Un metodo lancia un lavoro con Task.Run e ritorna la risposta all'utente. Il lavoro continua a usare il DbContext della richiesta, ma la richiesta è finita e il contesto è stato eliminato: arriva un'ObjectDisposedException che compare a intermittenza, perché dipende dalla velocità del lavoro. Per lavori che sopravvivono alla richiesta serve una coda e un servizio in background con il proprio ambito, non un task sparato nel vuoto.

Come funziona la dependency injection in minimal API, controller e Blazor?

Ogni punto d'ingresso di ASP.NET Core riceve i servizi a modo suo: i controller e le Razor Pages dal costruttore, le minimal API dai parametri della funzione, i componenti Blazor con la direttiva @inject. Il contenitore è lo stesso, cambia il punto in cui lo si interroga e, in Blazor, cambia la durata dello scoped.

Minimal API e controller

Nelle minimal API basta dichiarare il servizio come parametro della funzione. Il framework riconosce i tipi registrati nel contenitore e li consegna, senza attributi, mentre gli altri parametri vengono letti da percorso, query o corpo della richiesta. L'attributo [FromServices] serve solo nei rari casi di ambiguità.

app.MapPost("/ordini/{id}/sconto", async (int id, ScontiServizio sconti) =>
{
    await sconti.ApplicaAsync(id);
    return Results.NoContent();
});

Nei controller la strada normale è il costruttore. Se un servizio serve a una sola azione, per esempio un generatore di PDF usato in un solo endpoint, l'attributo [FromServices] sul parametro dell'azione evita di caricare quella dipendenza in tutte le altre chiamate del controller.

Blazor: lo scoped dura quanto la connessione

In un componente Blazor si scrive @inject IOrdiniRepository Ordini in cima al file. La sorpresa sta nella durata. In Blazor Server non esiste una richiesta per ogni azione dell'utente: esiste un circuito, la connessione SignalR che dura finché la scheda resta aperta. Un servizio scoped vive quindi per tutta la sessione, e un DbContext scoped accumula modifiche e dati vecchi per ore. La correzione è IDbContextFactory<T>, che crea un contesto breve a ogni operazione. In Blazor WebAssembly le cose vanno al contrario: il browser è l'unico utente, e uno scoped si comporta come un singleton. Il confronto fra le modalità di esecuzione lo trovi in Blazor in produzione.

Una conseguenza pratica per chi lavora su portali con più tecnologie nella stessa soluzione: la stessa classe di servizio può girare in un'API, in un componente Blazor e in un servizio in background, con tre durate di vita diverse. Scrivere i servizi senza stato nascosto, e dichiarare le dipendenze dal costruttore, è la sola difesa che regge in tutti e tre i casi.

Dove si mettono le registrazioni in un progetto organizzato a strati?

Ogni progetto della soluzione espone un metodo di estensione, per esempio AddInfrastruttura, che registra i propri servizi. Il progetto di avvio li chiama in fila e Program.cs resta di venti righe. Il progetto di avvio è l'unico a conoscere tutti gli altri.

Un metodo di estensione per progetto

In un gestionale a strati i progetti sono tipicamente quattro: dominio, applicazione, infrastruttura e web. Quello di infrastruttura contiene i repository, l'accesso ai dati e le integrazioni, e sa come costruirli. Quindi è lui a offrire il metodo che li registra, e il progetto web lo chiama senza sapere quali classi ci siano dentro.

public static class InfrastrutturaExtensions
{
    public static IServiceCollection AddInfrastruttura(
        this IServiceCollection servizi, IConfiguration configurazione)
    {
        servizi.AddDbContext<GestionaleContext>(o =>
            o.UseSqlServer(configurazione.GetConnectionString("Gestionale")));
        servizi.AddScoped<IOrdiniRepository, OrdiniRepository>();
        return servizi;
    }
}

Il punto di composizione

Nella letteratura si chiama composition root: il posto, uno solo, in cui le astrazioni vengono collegate alle classi concrete. Il dominio non sa che esiste un contenitore, né che esiste SQL Server. Questa regola ha un effetto pratico enorme: puoi sostituire l'infrastruttura intera con una finta nei test, e puoi cambiare il database senza aprire il progetto del dominio. È la ragione per cui la dependency injection si trova in ogni guida all'architettura a strati, a partire da Clean Architecture in C#.

Accesso ai dati e CQRS

Il metodo di estensione è anche il punto dove decidi come convivono gli strumenti per i dati. Se usi Entity Framework Core per i comandi e Dapper per le letture, qui registri il contesto come scoped e una fabbrica di connessioni per le query, come ho spiegato in Dapper o Entity Framework Core. Se separi comandi e query con gestori dedicati, la registrazione dei gestori vive qui, e il vantaggio di avere tutto in un posto è che il costo di ogni gestore aggiunto è una riga. Se vuoi valutare quando la separazione vale il lavoro, c'è CQRS e quando conviene.

Serve un contenitore esterno come Autofac o Scrutor?

Quasi mai. Il contenitore integrato copre la grande maggioranza dei progetti, e le librerie esterne servono solo per tre bisogni: decorare servizi in serie, registrare classi per convenzione scansionando gli assembly, organizzare le registrazioni in moduli. Per i primi due basta Scrutor, che si appoggia al contenitore integrato.

Che cosa manca davvero al contenitore di .NET

Il contenitore integrato è volutamente minimo. Non conosce l'iniezione tramite proprietà, i contenitori figli, i decoratori e l'intercettazione dei metodi. Dalla versione 8 non gli manca più la selezione per chiave. Per la maggioranza dei gestionali le lacune non si sentono: dopo anni di lavoro su sistemi anche grandi, l'unica che ho visto pesare è la decorazione, usata per aggiungere cache e registro dei tempi attorno a un servizio senza modificarlo.

Scrutor: scansione e decorazione

Scrutor è una libreria piccola che aggiunge due cose al contenitore integrato. La prima è la scansione: una riga registra tutte le classi di un assembly che finiscono per "Repository" sull'interfaccia corrispondente. La seconda è Decorate, che avvolge una registrazione esistente in un'altra. Con venti repository la prima fa risparmiare venti righe di Program.cs, ma attenzione al prezzo: le registrazioni non si leggono più una per una, e un errore di nome si scopre con una ricerca nel codice.

Un criterio per decidere in cinque minuti

Prima di aggiungere una libreria, scrivi su un foglio il problema in una frase. "Ho venti repository da registrare uno per uno" si risolve con una scansione di dieci righe scritte da te, che il team legge senza fatica. "Devo aggiungere un registro dei tempi attorno a trenta servizi" giustifica Scrutor. "Mi servono moduli separati per quattro prodotti che condividono il nucleo" apre la discussione su Autofac. Se non riesci a scrivere la frase, la libreria non risolve niente: aggiunge una cosa in più da imparare al prossimo sviluppatore che entra nel progetto.

Autofac e le prestazioni

Autofac sostituisce il contenitore integrato e aggiunge moduli, ambiti nominati e iniezione per proprietà. Ha senso quando erediti un progetto già costruito su di esso, o quando hai bisogno di quei moduli in una soluzione con decine di componenti. Per tutti gli altri casi, il costo di una dipendenza in più nel progetto supera il vantaggio. Sulle prestazioni, la risoluzione di un servizio costa microsecondi, trascurabili rispetto a una query a SQL Server: se il profilatore ti dice che il contenitore è un problema, quasi sempre il vero problema è che stai risolvendo servizi dentro un ciclo.

Giacomo ha tolto i nove new dal costruttore, ha scritto due interfacce da tre metodi e ha messo due righe in Program.cs. Il test per la regola degli sconti gira in otto millisecondi. Il collega anziano non ha detto niente, ma il giorno dopo gli ha passato la classe delle spedizioni, che aveva lo stesso difetto e quattro anni di polvere sopra.

Se vuoi arrivare a progettare codice così con la tua testa, scegliere il ciclo di vita giusto, scrivere servizi che si provano senza database e organizzare le registrazioni di una soluzione che deve durare, nel corso C# di Sviluppatore Migliore lavoriamo su progetti reali con il mio feedback diretto, in lezioni registrate e live che si incastrano con il lavoro. Porta la classe con il costruttore più lungo che hai: la smontiamo insieme.

Domande frequenti

È una tecnica in cui una classe riceve dall'esterno gli oggetti che le servono, di solito nel costruttore, invece di crearli con new. In .NET lo fa un contenitore integrato, configurato in Program.cs. Il risultato pratico è codice che si prova senza database e si modifica toccando una riga.

Transient crea una nuova istanza a ogni richiesta al contenitore, scoped una per ambito (in ASP.NET Core, una per richiesta HTTP), singleton una sola per tutta l'applicazione. Per scegliere guarda lo stato: se non ne ha, transient; se è legato a una richiesta, scoped; se è condiviso e sicuro per i thread, singleton.

Perché un singleton ha ricevuto nel costruttore un servizio scoped, che resterebbe in vita per sempre. Si risolve rendendo scoped il consumatore, creando un ambito breve con IServiceScopeFactory oppure, per Entity Framework Core, usando IDbContextFactory. Attivare ValidateScopes aiuta a scoprirlo in sviluppo.

Quasi mai. Il contenitore integrato di .NET 10 copre la maggior parte dei progetti, compresi i servizi con chiave. Scrutor aggiunge scansione degli assembly e decoratori sopra di esso. Autofac ha senso per moduli complessi o progetti ereditati.

Nei test unitari si crea la classe con new e si passano al costruttore doppi di test, senza contenitore. Nei test di integrazione si usa WebApplicationFactory e si sostituiscono i servizi esterni con ConfigureTestServices. Per il tempo conviene iniettare TimeProvider.

IOptions legge i valori una volta all'avvio. IOptionsSnapshot li rilegge a ogni richiesta ed è scoped. IOptionsMonitor segnala i cambiamenti ed è utilizzabile nei singleton. Se la configurazione cambia solo con un riavvio, IOptions basta.

Lascia i tuoi dati nel form qui sotto

Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 27 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Nel corso della sua carriera ha collaborato con realtà come Cotonella, Il Sole 24 Ore, FIAT e NATO, guidando team nello sviluppo di piattaforme scalabili e modernizzando ecosistemi legacy complessi.

Ha formato centinaia di sviluppatori e affiancato aziende di ogni dimensione nel trasformare il software in un vantaggio competitivo, riducendo il debito tecnico e portando risultati concreti in tempi misurabili.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.