Skip to main content

Service Locator, factory asincrone e registrazioni multiple in .NET

Alessandro Mengoli
Serie: Dependency Injection .NET - Parte 3

Nei primi due articoli della serie abbiamo visto i lifetime della DI e i relativi anti-pattern. In questo articolo ci spostiamo su tre problemi che riguardano la registrazione e la risoluzione dei servizi: dipendenze nascoste, factory che bloccano il thread e override silenziosi.

Sono pitfall meno discussi rispetto alla Captive Dependency, ma altrettanto frequenti — soprattutto nelle applicazioni che crescono, dove le registrazioni si distribuiscono tra Program.cs, extension method di librerie interne e pacchetti NuGet.

Service Locator

Il Service Locator è un pattern in cui una classe riceve IServiceProvider nel costruttore e lo usa per risolvere le proprie dipendenze internamente, invece di dichiararle esplicitamente.

Il problema

public class NotificationService(IServiceProvider provider)
{
    public void SendNotification(string userId, string message)
    {
        var emailSender = provider.GetRequiredService<IEmailSender>();
        var userRepo = provider.GetRequiredService<IUserRepository>();

        var user = userRepo.GetById(userId);
        emailSender.Send(user.Email, message);
    }
}

Guardando il costruttore, l’unica dipendenza visibile è IServiceProvider. Per sapere che NotificationService ha bisogno di IEmailSender e IUserRepository, bisogna leggere l’implementazione di ogni metodo. In una classe con più metodi, il numero di dipendenze nascoste può crescere senza controllo.

Anche i test diventano più complicati: invece di passare direttamente i mock delle dipendenze, bisogna costruire e configurare un intero IServiceProvider.

// Test con Service Locator: serve mockare IServiceProvider
var mockProvider = new Mock<IServiceProvider>();
mockProvider.Setup(p => p.GetService(typeof(IEmailSender)))
            .Returns(new Mock<IEmailSender>().Object);
mockProvider.Setup(p => p.GetService(typeof(IUserRepository)))
            .Returns(new Mock<IUserRepository>().Object);
// ... e così via per ogni dipendenza nascosta

Le linee guida Microsoft sono esplicite: “Avoid using the service locator pattern. For example, don’t invoke GetService to obtain a service instance when you can use DI instead.”

La soluzione

Dichiarare le dipendenze nel costruttore:

public class NotificationService(
    IEmailSender emailSender,
    IUserRepository userRepo)
{
    public void SendNotification(string userId, string message)
    {
        var user = userRepo.GetById(userId);
        emailSender.Send(user.Email, message);
    }
}

Le dipendenze sono visibili, il costruttore documenta ciò che serve alla classe, e i test sono diretti:

var mockEmail = new Mock<IEmailSender>();
var sut = new NotificationService(
    mockEmail.Object,
    Mock.Of<IUserRepository>());

sut.SendNotification("user1", "Hello");

mockEmail.Verify(e => e.Send(It.IsAny<string>(), "Hello"), Times.Once);

Ci sono casi in cui iniettare IServiceProvider è legittimo — ad esempio nei middleware di ASP.NET Core, in scenari di plugin/estensione dinamici, o quando si usa IServiceScopeFactory per creare scope in servizi Singleton (come visto nell'articolo precedente). La differenza è tra usarlo come meccanismo infrastrutturale e usarlo come sostituto della constructor injection.

Factory asincrone e deadlock

Il container DI di .NET risolve i servizi in modo sincrono. Non esiste un GetRequiredServiceAsync. Questo significa che le factory passate a AddSingleton, AddScoped e AddTransient devono essere sincrone.

Il problema

services.AddSingleton<IConnection>(sp =>
{
    return CreateConnectionAsync(sp).Result; // blocca il thread
});

async Task<IConnection> CreateConnectionAsync(IServiceProvider sp)
{
    var config = sp.GetRequiredService<IConfiguration>();
    var connection = new SqlConnection(config.GetConnectionString("Default"));
    await connection.OpenAsync();
    return connection;
}

Chiamare .Result su un Task all’interno di una factory DI blocca il thread corrente fino al completamento dell’operazione asincrona. Questo può causare un deadlock per due motivi:

  • Se esiste un SynchronizationContext (es. in WPF, WinForms, o Blazor Server), il Task potrebbe aver bisogno di riprendere sullo stesso thread che è bloccato in attesa.
  • Se il metodo asincrono chiama a sua volta GetRequiredService sul container, questo può entrare in conflitto con la risoluzione già in corso, che detiene un lock interno.

La documentazione Microsoft classifica questo come un anti-pattern esplicito: “If the factory is asynchronous, and you use Task<TResult>.Result, it will cause a deadlock.” La raccomandazione è: “Keep DI factories fast and synchronous.”

Le soluzioni

1) Lazy async initialization:

public class ConnectionWrapper(IConfiguration config)
{
    private readonly Lazy<Task<IConnection>> _connection = new(async () =>
    {
        var conn = new SqlConnection(config.GetConnectionString("Default"));
        await conn.OpenAsync();
        return conn;
    });

    public Task<IConnection> GetConnectionAsync() => _connection.Value;
}

services.AddSingleton<ConnectionWrapper>();

La factory registrata nel container è sincrona (crea un ConnectionWrapper). L’inizializzazione asincrona avviene alla prima chiamata di GetConnectionAsync(), fuori dal contesto di risoluzione del container.

2) Inizializzazione prima della build:

var builder = Host.CreateApplicationBuilder(args);

var connection = await CreateConnectionAsync(builder.Configuration);
builder.Services.AddSingleton<IConnection>(connection);

L’istanza viene creata in modo asincrono prima di essere registrata. Non c’è nessuna factory — si registra direttamente l’oggetto già inizializzato.

3) IHostedService per il warm-up:

public class ConnectionInitializer(ConnectionWrapper wrapper) : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        await wrapper.GetConnectionAsync();
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Combinato con la soluzione 1, l’IHostedService forza l’inizializzazione della connessione all’avvio dell’applicazione, prima che arrivi la prima richiesta.

Registrazioni multiple: l’ultima vince

Quando si registra la stessa interfaccia più volte nel container, il comportamento è diverso a seconda di come si risolve il servizio.

Il problema

services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
services.AddSingleton<IMessageWriter, FileMessageWriter>();

var provider = services.BuildServiceProvider();

// Risoluzione singola: l'ultimo registrato
var writer = provider.GetRequiredService<IMessageWriter>();
Console.WriteLine(writer.GetType().Name); // "FileMessageWriter"

// Risoluzione multipla: tutti, in ordine di registrazione
var all = provider.GetRequiredService<IEnumerable<IMessageWriter>>();
// [ConsoleMessageWriter, LoggingMessageWriter, FileMessageWriter]

Se si risolve un singolo IMessageWriter, il container restituisce l’ultima implementazione registrata. Le registrazioni precedenti non vengono sovrascritte — restano disponibili tramite IEnumerable<IMessageWriter> — ma per la risoluzione singola, l’ultima vince.

Questo è il comportamento documentato: “The second call to AddSingleton overrides the previous one when resolved as IMyDependency and adds to the previous one when multiple services are resolved via IEnumerable<IMyDependency>. Services appear in the order they were registered when resolved via IEnumerable<{SERVICE}>.”

Il container non lancia alcun avviso. Se una extension method di una libreria registra la propria implementazione di IMessageWriter dopo la nostra, il nostro servizio viene sostituito senza alcuna segnalazione. Questo succede facilmente quando le registrazioni sono distribuite tra più file o pacchetti.

Le soluzioni

TryAdd — registra solo se il service type non è già presente:

using Microsoft.Extensions.DependencyInjection.Extensions;

services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.TryAddSingleton<IMessageWriter, LoggingMessageWriter>();
// LoggingMessageWriter viene ignorato: IMessageWriter è già registrato

var writer = provider.GetRequiredService<IMessageWriter>();
Console.WriteLine(writer.GetType().Name); // "ConsoleMessageWriter"

Con TryAdd, la prima registrazione vince. Le successive per lo stesso service type vengono ignorate.

TryAddEnumerable — evita duplicati nelle registrazioni multiple:

services.TryAddEnumerable(
    ServiceDescriptor.Singleton<IMessageWriter, ConsoleMessageWriter>());
services.TryAddEnumerable(
    ServiceDescriptor.Singleton<IMessageWriter, LoggingMessageWriter>());
services.TryAddEnumerable(
    ServiceDescriptor.Singleton<IMessageWriter, ConsoleMessageWriter>());
// ^ Ignorata: la coppia (IMessageWriter, ConsoleMessageWriter) è già presente

var all = provider.GetRequiredService<IEnumerable<IMessageWriter>>();
// [ConsoleMessageWriter, LoggingMessageWriter] — senza duplicati

TryAddEnumerable controlla sia il service type che l’implementation type: aggiunge solo se quella specifica coppia non è già registrata.

Riepilogo:

Add<T, Impl>()        → aggiunge sempre (l'ultimo vince per singolo, tutti per IEnumerable)
TryAdd<T, Impl>()     → aggiunge solo se T non è già registrato
TryAddEnumerable(...)  → aggiunge solo se la coppia (T, Impl) non è già presente

Se sviluppate una libreria che espone una extension method tipo AddMyLibrary(), usate TryAdd nelle registrazioni. In questo modo chi consuma la libreria può fare override registrando la propria implementazione prima di chiamare AddMyLibrary(), senza conflitti.

Conclusione

I tre problemi di questo articolo hanno in comune il fatto di manifestarsi in silenzio. Il Service Locator nasconde le dipendenze e non produce errori. Una factory asincrona con .Result può funzionare in assenza di SynchronizationContext e poi bloccarsi in un contesto diverso. E una registrazione sovrascritta passa inosservata finché il servizio non si comporta in modo inatteso.

Le contromisure: constructor injection esplicita, factory sincrone (con lazy initialization o IHostedService per i casi asincroni), e TryAdd/TryAddEnumerable per registrazioni sicure.

Nel prossimo articolo chiudiamo la serie con i Keyed Services: registrazione con chiave, AnyKey, e la breaking change in .NET 10.

Fonti: