Skip to main content

Keyed Services in .NET: pattern e pitfall

Alessandro Mengoli
Serie: Dependency Injection .NET - Parte 4

Nei primi tre articoli della serie abbiamo visto i lifetime, gli anti-pattern dei lifetime e i pitfall su registrazione e risoluzione. In questo ultimo articolo ci concentriamo sui Keyed Services, introdotti in .NET 8.

Prima dei Keyed Services, registrare più implementazioni della stessa interfaccia e scegliere quale iniettare richiedeva workaround: factory manuali, risoluzione tramite IEnumerable<T> con filtro LINQ, o container di terze parti come Autofac. Era un’operazione comune — pensate a scenari con più provider di cache, più strategy di notifica, o più implementazioni di un gateway di pagamento — ma il container nativo non la supportava direttamente.

I Keyed Services risolvono il problema permettendo di associare una chiave a ogni registrazione e di specificare quale implementazione iniettare tramite l’attributo [FromKeyedServices]. Non serve più filtrare a runtime o costruire factory ad hoc: la scelta dell’implementazione è dichiarativa e avviene al momento della risoluzione.

Registrazione e risoluzione

La registrazione avviene con i metodi AddKeyed{Lifetime}, passando una chiave come primo parametro:

var services = new ServiceCollection();
services.AddKeyedSingleton<ICache, RedisCache>("redis");
services.AddKeyedSingleton<ICache, MemoryCache>("memory");

Per iniettare una specifica implementazione, si usa l’attributo [FromKeyedServices] nel costruttore del servizio che ne ha bisogno:

public class ProductService(
    [FromKeyedServices("redis")] ICache cache)
{
    public string GetProducts() => cache.Get("products");
}

Il container sa quale ICache fornire perché la chiave "redis" nella richiesta corrisponde a quella usata nella registrazione. In Minimal API la stessa risoluzione avviene direttamente nei parametri dell’endpoint:

app.MapGet("/products", ([FromKeyedServices("memory")] ICache cache) =>
{
    return cache.Get("products");
});

La risoluzione manuale dal service provider usa GetRequiredKeyedService:

var redis = provider.GetRequiredKeyedService<ICache>("redis");
var memory = provider.GetRequiredKeyedService<ICache>("memory");

Un dettaglio importante: la chiave non è limitata a string. Può essere qualsiasi object che implementa correttamente Equalsint, enum, record, o qualsiasi tipo custom. Questo apre possibilità interessanti che vedremo nella sezione sulle best practice.

AnyKey: registrazione fallback

KeyedService.AnyKey è un valore speciale che permette di registrare un’implementazione di default. Quando il container riceve una richiesta per una chiave che non ha una registrazione esplicita, usa la registrazione AnyKey come fallback:

var services = new ServiceCollection();
services.AddKeyedSingleton<ICache, PremiumCache>("premium");
services.AddKeyedSingleton<ICache, DefaultCache>(KeyedService.AnyKey);

var provider = services.BuildServiceProvider();

var premium = provider.GetRequiredKeyedService<ICache>("premium");
Console.WriteLine(premium.GetType().Name);  // "PremiumCache"

var basic = provider.GetRequiredKeyedService<ICache>("basic");
Console.WriteLine(basic.GetType().Name);    // "DefaultCache" (fallback)

var other = provider.GetRequiredKeyedService<ICache>("anything");
Console.WriteLine(other.GetType().Name);    // "DefaultCache" (fallback)

La chiave "premium" ha una registrazione dedicata, quindi il container restituisce PremiumCache. Le chiavi "basic" e "anything" non hanno registrazioni esplicite, quindi il container usa il fallback AnyKey e restituisce DefaultCache.

La registrazione con AnyKey supporta anche una factory che riceve la chiave richiesta come secondo parametro. Questo permette di creare istanze personalizzate in base alla chiave, senza dover registrare ogni variante singolarmente:

services.AddKeyedSingleton<ICache>(KeyedService.AnyKey, (sp, key) =>
{
    return new DefaultCache(key?.ToString() ?? "unknown");
});

Pitfall: Keyed e Non-Keyed sono registrazioni separate

Un aspetto che sorprende molti sviluppatori è che le registrazioni keyed e non-keyed per la stessa interfaccia vivono in due mondi completamente separati. Registrare una versione keyed non ha alcun effetto sulla risoluzione non-keyed, e viceversa. Non si sovrascrivono e non interagiscono in alcun modo:

services.AddSingleton<ICache, GlobalCache>();              // non-keyed
services.AddKeyedSingleton<ICache, RedisCache>("redis");   // keyed

var nonKeyed = provider.GetRequiredService<ICache>();
Console.WriteLine(nonKeyed.GetType().Name);                // "GlobalCache"

var keyed = provider.GetRequiredKeyedService<ICache>("redis");
Console.WriteLine(keyed.GetType().Name);                   // "RedisCache"

Questo comportamento è by design, ma va tenuto presente quando si progetta la struttura delle registrazioni. Se un servizio non specifica [FromKeyedServices], riceve sempre la registrazione non-keyed — anche se esistono registrazioni keyed per la stessa interfaccia.

Pitfall: typo nella chiave con AnyKey fallback

AnyKey è comodo ma introduce un rischio che vale la pena conoscere: se si sbaglia a scrivere una chiave, il fallback restituisce l’implementazione di default senza segnalare l’errore. Il risultato è un bug silenzioso.

services.AddKeyedSingleton<ICache, PremiumCache>("premium");
services.AddKeyedSingleton<ICache, DefaultCache>(KeyedService.AnyKey);

// Typo: "premiun" invece di "premium"
var cache = provider.GetRequiredKeyedService<ICache>("premiun");
Console.WriteLine(cache.GetType().Name); // "DefaultCache" — non "PremiumCache"

Il codice funziona, non lancia eccezioni, e restituisce un servizio. Ma è il servizio sbagliato. Senza AnyKey, lo stesso typo avrebbe lanciato un’InvalidOperationException — il che è preferibile, perché il problema è visibile e viene intercettato subito.

Come mitigare

La soluzione è usare costanti o enum per le chiavi, evitando stringhe sparse nel codice:

public static class CacheKeys
{
    public const string Redis = "redis";
    public const string Memory = "memory";
    public const string Premium = "premium";
}

services.AddKeyedSingleton<ICache, RedisCache>(CacheKeys.Redis);
services.AddKeyedSingleton<ICache, MemoryCache>(CacheKeys.Memory);
services.AddKeyedSingleton<ICache, PremiumCache>(CacheKeys.Premium);

public class ProductService(
    [FromKeyedServices(CacheKeys.Redis)] ICache cache)
{ }

In alternativa, sfruttando il fatto che le chiavi possono essere qualsiasi tipo con Equals, si può usare un enum:

public enum CacheType { Redis, Memory, Premium }

services.AddKeyedSingleton<ICache, RedisCache>(CacheType.Redis);
services.AddKeyedSingleton<ICache, MemoryCache>(CacheType.Memory);

In entrambi i casi, un typo diventa un errore di compilazione — eliminando il problema alla radice.

Breaking change in .NET 10: GetKeyedService con AnyKey

In .NET 10 il comportamento di GetKeyedService() e GetKeyedServices() con KeyedService.AnyKey è stato modificato per correggere un’inconsistenza semantica che esisteva fin dall’introduzione dei Keyed Services.

Il problema concettuale

AnyKey è concepito come un wildcard per la registrazione — un meccanismo di fallback che dice al container “usa questa implementazione per qualsiasi chiave non registrata esplicitamente”. Non è una chiave di risoluzione. Ma in .NET 8 e 9, il container lo trattava anche come tale, il che portava a comportamenti confusi: GetKeyedService(KeyedService.AnyKey) restituiva il servizio registrato con AnyKey come se fosse una chiave qualsiasi, e GetKeyedServices(KeyedService.AnyKey) restituiva tutte le registrazioni, incluse quelle AnyKey.

Il cambiamento

In .NET 10, GetKeyedService() (singolare) con AnyKey lancia un’InvalidOperationException:

services.AddKeyedSingleton<ICache, DefaultCache>(KeyedService.AnyKey);
services.AddKeyedSingleton<ICache, PremiumCache>("premium");

var provider = services.BuildServiceProvider();

// .NET 8/9: restituiva DefaultCache
// .NET 10: lancia InvalidOperationException
var service = provider.GetKeyedService<ICache>(KeyedService.AnyKey);
// "Cannot resolve a single service using AnyKey."

GetKeyedServices() (plurale) con AnyKey non restituisce più le registrazioni AnyKey — restituisce solo i servizi registrati con chiavi specifiche:

// .NET 8/9
var all = provider.GetKeyedServices<ICache>(KeyedService.AnyKey);
// [DefaultCache, PremiumCache] — tutte le registrazioni

// .NET 10
var all = provider.GetKeyedServices<ICache>(KeyedService.AnyKey);
// [PremiumCache] — solo le chiavi specifiche, AnyKey esclusa

Il razionale dietro il cambiamento è chiaro: AnyKey è un meccanismo di registrazione, non di risoluzione. Usarlo per risolvere un singolo servizio non aveva senso semanticamente e creava ambiguità.

Come aggiornare il codice

Se il codice usa GetKeyedService(KeyedService.AnyKey) per ottenere il fallback, la migrazione è semplice. Si può registrare il default con una chiave esplicita:

services.AddKeyedSingleton<ICache, DefaultCache>("default");
services.AddKeyedSingleton<ICache, PremiumCache>("premium");

var fallback = provider.GetKeyedService<ICache>("default");

Oppure si può sfruttare la separazione tra keyed e non-keyed, registrando il default come servizio non-keyed:

services.AddSingleton<ICache, DefaultCache>();                  // non-keyed
services.AddKeyedSingleton<ICache, PremiumCache>("premium");    // keyed

var fallback = provider.GetService<ICache>();                    // DefaultCache
var premium = provider.GetKeyedService<ICache>("premium");       // PremiumCache

La seconda opzione è particolarmente pulita: il default è il servizio “normale”, le varianti specifiche sono keyed. Qualsiasi classe che non ha bisogno di una variante specifica riceve il default tramite la classica constructor injection, senza attributi.

Conclusione

I Keyed Services sono un’aggiunta al container DI nativo di .NET che copre uno scenario prima gestibile solo con workaround. I punti da tenere a mente: usare costanti o enum per le chiavi per avere la sicurezza del compilatore, valutare i rischi del fallback AnyKey — in particolare i bug silenziosi da typo — e ricordare che keyed e non-keyed sono registrazioni indipendenti che non si influenzano a vicenda. Per chi sta migrando a .NET 10, verificare che il codice non usi GetKeyedService(KeyedService.AnyKey) per risolvere servizi, perché in .NET 10 lancia un’eccezione.

Con questo articolo chiudiamo la serie sulla Dependency Injection in .NET. I temi che abbiamo coperto — lifetime e le loro regole, anti-pattern dei lifetime, pitfall su registrazione e risoluzione, e Keyed Services — sono le basi per usare la DI in modo consapevole ed evitare i problemi più comuni.

Gli esempi di codice di questa serie sono disponibili nel repository GitHub del talk presentato a Sharp Coding Rome.

Fonti: