.NET · La plateforme

Injection de dépendances

Singleton, scoped, transient, dépendance captive.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 14 min

Une classe qui construit elle-même ses collaborateurs fixe leur type concret, leur durée de vie et leur nombre ; l'injection de dépendances les lui fournit de l'extérieur, et un conteneur se charge de les construire. Dans .NET, ce conteneur est celui de Microsoft.Extensions.DependencyInjection, celui qu'ASP.NET Core, l'hôte générique et les services hébergés utilisent tous. Il sert dès qu'une application dépasse quelques classes, et l'on s'y trompe surtout sur un point : la durée de vie de ce qu'il fournit. L'injection n'est pas l'inversion des dépendances, qui décide à qui appartient une abstraction : le cours SOLID fait la différence. Les exemples tournent sur le runtime .NET 10.0.8 installé, alors que la version courante au 26 septembre 2026 est la 10.0.12, celle du paquet Microsoft.Extensions.Hosting qu'utilisent les exemples console. Ceux qui n'ont pas d'hôte sont des classes statiques dont un Program.cs appelle Executer ou ExecuterAsync.

Enregistrer, construire, résoudre

Le conteneur vit en deux temps. D'abord une ServiceCollection, simple liste où chaque Add dépose un ServiceDescriptor : le type demandé, de quoi le construire, une durée de vie. Rien n'y est créé. Puis BuildServiceProvider fige cette liste en un fournisseur, qui construit à la demande : pour un type enregistré, il lit le constructeur, résout chaque paramètre par le même procédé, et remonte ainsi tout le graphe.

using System;
using Microsoft.Extensions.DependencyInjection;

interface IHorloge
{
    DateTimeOffset Maintenant { get; }
}

sealed class HorlogeSysteme : IHorloge
{
    public DateTimeOffset Maintenant => DateTimeOffset.UtcNow;
}

interface IDepotCommandes
{
    void Enregistrer(string article);
}

sealed class ServiceCommandes(IHorloge horloge, IDepotCommandes depot)
{
    public void Passer(string article) => depot.Enregistrer($"{article} a {horloge.Maintenant:HH:mm}");
}

sealed class Rapport
{
    public string Origine { get; }

    public Rapport() => Origine = "constructeur vide";
    public Rapport(IHorloge horloge) => Origine = "constructeur (IHorloge)";

    // Le plus long, mais IDepotCommandes n'est pas enregistre : ecarte.
    public Rapport(IHorloge horloge, IDepotCommandes depot) => Origine = "constructeur (IHorloge, IDepotCommandes)";
}

static class Conteneur
{
    public static void Executer()
    {
        var services = new ServiceCollection();

        // Chaque Add depose un ServiceDescriptor dans la collection : le type
        // demande, de quoi le construire, et une duree de vie. Rien n'est cree.
        services.AddSingleton<IHorloge, HorlogeSysteme>();
        services.AddTransient<ServiceCommandes>();
        services.AddTransient<Rapport>();

        foreach (var descripteur in services)
        {
            Console.WriteLine($"{descripteur.ServiceType.Name} -> {descripteur.ImplementationType?.Name} ({descripteur.Lifetime})");
        }
        // IHorloge -> HorlogeSysteme (Singleton)
        // ServiceCommandes -> ServiceCommandes (Transient)
        // Rapport -> Rapport (Transient)

        using var fournisseur = services.BuildServiceProvider();

        // Le conteneur prend le constructeur public le plus long dont il sait
        // fournir tous les parametres.
        Console.WriteLine(fournisseur.GetRequiredService<Rapport>().Origine); // constructeur (IHorloge)

        // GetService rend null pour un type inconnu, GetRequiredService leve.
        Console.WriteLine(fournisseur.GetService<IDepotCommandes>() is null); // True

        try
        {
            fournisseur.GetRequiredService<ServiceCommandes>();
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
            // Unable to resolve service for type 'IDepotCommandes' while attempting
            // to activate 'ServiceCommandes'.
        }
    }
}

Quand une classe a plusieurs constructeurs publics, le conteneur prend le plus long dont il sait fournir tous les paramètres, et lève The following constructors are ambiguous si un autre constructeur satisfaisable demande un paramètre que celui-là n'a pas. GetService rend null pour un type inconnu, GetRequiredService lève : c'est la seconde qu'on veut presque toujours, car un null reporte la panne loin de sa cause. Le message Unable to resolve service for type ... while attempting to activate ... est l'erreur la plus fréquente du conteneur ; il nomme le type que personne n'a enregistré et la classe qui le demandait. Le conteneur reste volontairement simple : sa documentation cite, parmi ce qu'il ne fait pas, l'injection par propriété, les conteneurs enfants et l'enregistrement par convention, qui sont les raisons de lui préférer Autofac ou un autre. Dans ASP.NET Core, le code métier n'appelle ni BuildServiceProvider ni GetRequiredService : c'est builder.Build() qui construit le fournisseur, et le framework résout les paramètres des endpoints comme les constructeurs des contrôleurs.

Trois durées de vie, et ce qu'est une portée

Un singleton est créé une fois, à sa première demande, et sert toute l'application. Un transient est créé à chaque résolution. Entre les deux, un service scoped est créé une fois par portée. Une portée est une sous-durée de vie que quelqu'un ouvre et ferme : ASP.NET Core en ouvre une par requête HTTP et l'expose sur HttpContext.RequestServices ; un service hébergé ou une console l'ouvre à la main par CreateScope. Tout ce qui est résolu dans une portée y partage la même instance scoped, et la fin de la portée la libère.

using System;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;

// Chaque instance recoit un numero a sa creation : on voit qui est neuf.
abstract class Numerote
{
    private static int dernier;
    public int Numero { get; } = Interlocked.Increment(ref dernier);
    public override string ToString() => $"{GetType().Name} {Numero}";
}

sealed class Tarifs : Numerote;          // singleton
sealed class UniteDeTravail : Numerote;  // scoped
sealed class Calcul : Numerote;          // transient

sealed class ServicePanier(Tarifs tarifs, UniteDeTravail unite, Calcul calcul)
{
    public string Dependances => $"{tarifs}, {unite}, {calcul}";
}

static class Durees
{
    public static void Executer()
    {
        var services = new ServiceCollection();
        services.AddSingleton<Tarifs>();
        services.AddScoped<UniteDeTravail>();
        services.AddTransient<Calcul>();
        services.AddScoped<ServicePanier>();

        using var racine = services.BuildServiceProvider();

        for (var requete = 1; requete <= 2; requete++)
        {
            // Une portee, c'est ce qu'ASP.NET Core ouvre pour chaque requete ;
            // ici, on l'ouvre a la main.
            using var portee = racine.CreateScope();
            var fournisseur = portee.ServiceProvider;

            var panier = fournisseur.GetRequiredService<ServicePanier>();
            Console.WriteLine($"requete {requete} : {panier.Dependances}");
            Console.WriteLine($"  resolus a part : {fournisseur.GetRequiredService<UniteDeTravail>()}, {fournisseur.GetRequiredService<Calcul>()}");
        }
        // requete 1 : Tarifs 1, UniteDeTravail 2, Calcul 3
        //   resolus a part : UniteDeTravail 2, Calcul 4
        // requete 2 : Tarifs 1, UniteDeTravail 5, Calcul 6
        //   resolus a part : UniteDeTravail 5, Calcul 7
    }
}

Dans une même requête, ServicePanier et le code qui demande l'unité de travail à part reçoivent la même : c'est ce qui permet à plusieurs dépôts de partager un DbContext et donc une transaction. Le calcul, transient, est neuf à chaque fois ; les tarifs ne changent pas d'une requête à l'autre. Le choix se fait sur ce que l'instance retient, critère que pose déjà la section « Démarrage minimal » du cours Construire une API. Sans état, ou avec un état partagé et sûr sous concurrence : singleton. Un état qui appartient à une requête, une connexion, un utilisateur : scoped. Un objet léger qu'on ne veut pas partager : transient.

Le singleton a une obligation que les deux autres n'ont pas : toutes les requêtes simultanées l'utilisent en même temps, sur des threads différents. Le conteneur garantit que sa construction n'a lieu qu'une fois, pas que ses méthodes sont sûres ; un Dictionary modifié dans un singleton appelle les protections du cours Multithreading et concurrence. Hors requête, la portée s'ouvre à la main : au démarrage, app.Services.CreateScope() fournit par exemple le DbContext qui prépare la base, puis le libère : c'est ce que fait l'exemple du cours Couches, oignon, hexagonale, Clean.

La dépendance captive

Un service ne doit pas vivre plus longtemps que ce dont il dépend. Un singleton qui reçoit un service scoped le garde dans un champ, et la portée qui devait le libérer n'a plus prise sur lui : c'est la dépendance captive. Ici, le service de commandes, déclaré singleton, capture une unité de travail qui devait n'en suivre qu'une requête.

var builder = WebApplication.CreateBuilder(args);

// L'unite de travail suit les entites d'une requete, comme un DbContext.
builder.Services.AddScoped<UniteDeTravail>();

// Le service de commandes est declare singleton : construit une fois, il
// recoit une unite de travail et la garde aussi longtemps que lui.
builder.Services.AddSingleton<ServiceCommandes>();

var app = builder.Build();

app.MapPost("/commandes/{article}", (string article, ServiceCommandes commandes) => commandes.Passer(article));

app.Run();

sealed class UniteDeTravail
{
    private static int derniere;
    public int Numero { get; } = Interlocked.Increment(ref derniere);
    public List<string> Suivies { get; } = [];
}

sealed class ServiceCommandes(UniteDeTravail unite)
{
    public object Passer(string article)
    {
        unite.Suivies.Add(article);
        return new { unite = unite.Numero, suivies = unite.Suivies };
    }
}

// ASPNETCORE_ENVIRONMENT=Production : l'application demarre.
// POST /commandes/livre -> {"unite":1,"suivies":["livre"]}
// POST /commandes/stylo -> {"unite":1,"suivies":["livre","stylo"]}
//
// ASPNETCORE_ENVIRONMENT=Development : builder.Build() leve, rien ne demarre.
// System.AggregateException: Some services are not able to be constructed
// (Error while validating the service descriptor 'ServiceType: ServiceCommandes
// Lifetime: Singleton ImplementationType: ServiceCommandes': Cannot consume
// scoped service 'UniteDeTravail' from singleton 'ServiceCommandes'.)

En production, l'application démarre et la seconde requête voit l'article de la première : une seule unité de travail sert tout le monde, grossit sans fin, et un vrai DbContext, qui n'est pas conçu pour être partagé entre threads, finirait par être utilisé par deux requêtes simultanées. L'instance capturée n'est même pas celle de la première requête : le conteneur construit un singleton et ses dépendances dans la racine, et c'est l'unité de travail de la racine qu'il lui donne. En Development, le même code ne démarre pas. La correction porte sur l'enregistrement du consommateur, qui prend la durée de vie de sa dépendance la plus courte ; le singleton ajouté montre que l'autre sens reste permis.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<UniteDeTravail>();

// Le service ne vit pas plus longtemps que ce dont il depend.
builder.Services.AddScoped<ServiceCommandes>();

// Un scoped peut dependre d'un singleton : l'inverse seul est interdit.
builder.Services.AddSingleton(TimeProvider.System);

var app = builder.Build();

app.MapPost("/commandes/{article}", (string article, ServiceCommandes commandes) => commandes.Passer(article));

app.Run();

sealed class UniteDeTravail
{
    private static int derniere;
    public int Numero { get; } = Interlocked.Increment(ref derniere);
    public List<string> Suivies { get; } = [];
}

sealed class ServiceCommandes(UniteDeTravail unite, TimeProvider horloge)
{
    public object Passer(string article)
    {
        unite.Suivies.Add(article);
        return new { unite = unite.Numero, suivies = unite.Suivies, le = horloge.GetUtcNow().ToString("HH:mm") };
    }
}

// En Development comme en Production :
// POST /commandes/livre -> {"unite":1,"suivies":["livre"],"le":"23:46"}
// POST /commandes/stylo -> {"unite":2,"suivies":["stylo"],"le":"23:46"}
// ("le" est l'heure UTC de l'appel : elle change d'une execution a l'autre.)
Le consommateur estIl peut dépendre de
singletonsingleton ; transient, qui vivra alors aussi longtemps que lui
scopedsingleton, scoped, transient
transientsingleton, scoped, transient

Deux options de ServiceProviderOptions gardent la règle. ValidateScopes refuse qu'un singleton consomme un scoped, même à travers un transient intermédiaire, et qu'un scoped soit résolu depuis la racine ; seule, elle ne lève qu'à la première résolution fautive. ValidateOnBuild construit, pendant Build(), le plan de résolution de chaque enregistrement, sans rien instancier : seule, elle trouve les dépendances manquantes, pas les captives, et un constructeur qui lève ne se découvre qu'à la première résolution ; avec ValidateScopes, elle fait échouer le démarrage, comme dans l'exemple. Toutes deux valent false dans un ServiceProviderOptions neuf, donc pour un BuildServiceProvider() nu. WebApplication.CreateBuilder et Host.CreateApplicationBuilder les passent à true quand l'environnement est Development, et les laissent à false ailleurs, pour des raisons de performance selon la documentation. La protection ne vaut donc que si l'application démarre au moins une fois en Development avant la production, ou si on les active partout : builder.Host.UseDefaultServiceProvider sur un WebApplicationBuilder, builder.ConfigureContainer(new DefaultServiceProviderFactory(options)) sur l'hôte de Host.CreateApplicationBuilder. Elle a une limite : une fabrique est une fonction opaque, et ValidateOnBuild ne voit pas ce qu'elle résout.

Résoudre depuis un singleton

Certains services sont singletons par nature et travaillent hors de toute requête : un BackgroundService vit autant que l'hôte, un minuteur déclenche un traitement sans que personne ait ouvert de portée. Quand un tel service a besoin d'un scoped, le réflexe d'injecter IServiceProvider pour le résoudre soi-même est faux, pour une raison précise : le fournisseur reçu par un singleton est la racine, et la racine n'est dans aucune portée. Le middleware écrit comme une classe par convention, construit une seule fois lui aussi, est un autre cas : une requête est en cours et sa portée existe, et le cours Construire une API montre, dans sa section « Le pipeline de middlewares », comment la rejoindre, par IMiddleware ou par les paramètres d'InvokeAsync.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddScoped<UniteDeTravail>();
builder.Services.AddHostedService<Relances>();

await builder.Build().RunAsync();

sealed class UniteDeTravail : IDisposable
{
    private static int derniere;
    public int Numero { get; } = Interlocked.Increment(ref derniere);
    public List<string> Suivies { get; } = [];
    public void Dispose() => Console.WriteLine($"unite {Numero} liberee");
}

// Un service heberge est un singleton : il vit autant que l'application.
sealed class Relances(IServiceProvider services, IHostApplicationLifetime application) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken arret)
    {
        for (var tour = 1; tour <= 3; tour++)
        {
            // Le fournisseur recu par un singleton est la racine. Aucune requete,
            // aucune portee : l'unite de travail demandee ici est celle de la
            // racine, la meme a chaque tour, jamais liberee avant l'arret.
            var unite = services.GetRequiredService<UniteDeTravail>();
            unite.Suivies.Add($"relance {tour}");
            Console.WriteLine($"tour {tour} : unite {unite.Numero}, {unite.Suivies.Count} entite(s) suivie(s)");

            await Task.Delay(100, arret);
        }

        application.StopApplication();
    }
}

// Production, l'environnement par defaut quand DOTNET_ENVIRONMENT n'est pas
// defini :
// tour 1 : unite 1, 1 entite(s) suivie(s)
// tour 2 : unite 1, 2 entite(s) suivie(s)
// tour 3 : unite 1, 3 entite(s) suivie(s)
// unite 1 liberee                          <- a l'arret de l'application
//
// DOTNET_ENVIRONMENT=Development, validation active : des le premier tour,
// System.InvalidOperationException: Cannot resolve scoped service
// 'UniteDeTravail' from root provider.
// Le service echoue, et l'hote s'arrete (BackgroundServiceExceptionBehavior
// vaut StopHost par defaut).

Sans validation, la racine sert une unité de travail scoped comme un singleton : la même à chaque tour, libérée seulement à l'arrêt. Avec validation, l'appel lève. La bonne dépendance est IServiceScopeFactory, singleton lui-même, qui ouvre une portée par unité de travail, exactement comme ASP.NET Core le fait par requête. Elle ne vaut que là où aucune portée n'existe : ouverte dans un middleware, elle donnerait une unité de travail distincte de celle de la requête.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddScoped<UniteDeTravail>();
builder.Services.AddHostedService<Relances>();

await builder.Build().RunAsync();

sealed class UniteDeTravail : IDisposable
{
    private static int derniere;
    public int Numero { get; } = Interlocked.Increment(ref derniere);
    public List<string> Suivies { get; } = [];
    public void Dispose() => Console.WriteLine($"unite {Numero} liberee");
}

sealed class Relances(IServiceScopeFactory portees, IHostApplicationLifetime application) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken arret)
    {
        for (var tour = 1; tour <= 3; tour++)
        {
            // Une portee par unite de travail, comme ASP.NET Core en ouvre une
            // par requete. CreateAsyncScope, parce qu'un service de la portee
            // peut n'etre qu'IAsyncDisposable (un DbContext est les deux).
            await using (var portee = portees.CreateAsyncScope())
            {
                var unite = portee.ServiceProvider.GetRequiredService<UniteDeTravail>();
                unite.Suivies.Add($"relance {tour}");
                Console.WriteLine($"tour {tour} : unite {unite.Numero}, {unite.Suivies.Count} entite(s) suivie(s)");
            }

            await Task.Delay(100, arret);
        }

        application.StopApplication();
    }
}

// En Development comme en Production :
// tour 1 : unite 1, 1 entite(s) suivie(s)
// unite 1 liberee
// tour 2 : unite 2, 1 entite(s) suivie(s)
// unite 2 liberee
// tour 3 : unite 3, 1 entite(s) suivie(s)
// unite 3 liberee

Tout ce qui est résolu depuis portee.ServiceProvider appartient à la portée, et le await using le libère à la fin du tour. À l'inverse, IServiceProvider injecté dans un service scoped est bien le fournisseur de sa portée ; résoudre depuis lui fonctionne, mais cache les dépendances réelles de la classe, qu'un constructeur aurait affichées.

Plusieurs enregistrements, IEnumerable et fabriques

Enregistrer deux fois le même type ne remplace rien : les deux descripteurs restent dans la collection. Une demande simple reçoit le dernier ; une demande de IEnumerable<T> les reçoit tous, dans l'ordre d'enregistrement. C'est la forme naturelle d'une liste de gestionnaires, de règles ou de canaux de notification, à laquelle un module ajoute le sien sans toucher aux autres.

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

interface INotification
{
    string Canal { get; }
}

sealed class NotificationCourriel : INotification { public string Canal => "courriel"; }
sealed class NotificationSms : INotification { public string Canal => "sms"; }
sealed class NotificationPush : INotification { public string Canal => "push"; }

// Toutes les notifications enregistrees, dans l'ordre d'enregistrement.
sealed class Diffusion(IEnumerable<INotification> canaux)
{
    public string Canaux => string.Join(", ", canaux.Select(c => c.Canal));
}

interface ILectureStock { int Quantite(string article); }
interface IEcritureStock { void Retirer(string article, int quantite); }

sealed class Stock : ILectureStock, IEcritureStock
{
    private readonly Dictionary<string, int> quantites = new() { ["livre"] = 10 };
    public int Quantite(string article) => quantites.GetValueOrDefault(article);
    public void Retirer(string article, int quantite) => quantites[article] -= quantite;
}

static class Multiples
{
    public static void Executer()
    {
        var services = new ServiceCollection();

        services.AddSingleton<INotification, NotificationCourriel>();
        services.AddSingleton<INotification, NotificationSms>();

        // TryAdd n'ajoute que si le type demande n'a encore aucun enregistrement :
        // c'est la maniere polie, pour une bibliotheque, de proposer un defaut.
        services.TryAddSingleton<INotification, NotificationPush>();

        // TryAddEnumerable n'ajoute que si ce couple (type, implementation)
        // manque : sms est deja la, push ne l'est pas.
        services.TryAddEnumerable(ServiceDescriptor.Singleton<INotification, NotificationSms>());
        services.TryAddEnumerable(ServiceDescriptor.Singleton<INotification, NotificationPush>());

        services.AddSingleton<Diffusion>();

        // Une fabrique : le conteneur appelle la fonction au lieu d'un
        // constructeur. Ici, elle fait pointer deux interfaces vers une seule
        // instance ; AddSingleton<ILectureStock, Stock>() suivi de
        // AddSingleton<IEcritureStock, Stock>() en aurait cree deux.
        services.AddSingleton<Stock>();
        services.AddSingleton<ILectureStock>(fournisseur => fournisseur.GetRequiredService<Stock>());
        services.AddSingleton<IEcritureStock>(fournisseur => fournisseur.GetRequiredService<Stock>());

        using var fournisseur = services.BuildServiceProvider();

        // Une seule demandee : le dernier enregistrement l'emporte.
        Console.WriteLine(fournisseur.GetRequiredService<INotification>().Canal); // push
        // Toutes demandees : chacune, dans l'ordre.
        Console.WriteLine(fournisseur.GetRequiredService<Diffusion>().Canaux); // courriel, sms, push

        fournisseur.GetRequiredService<IEcritureStock>().Retirer("livre", 3);
        Console.WriteLine(fournisseur.GetRequiredService<ILectureStock>().Quantite("livre")); // 7
    }
}

TryAdd et TryAddEnumerable, de l'espace de noms Microsoft.Extensions.DependencyInjection.Extensions, sont les formes que les bibliothèques emploient : la première propose un défaut que l'application peut avoir déjà remplacé, la seconde évite d'ajouter deux fois la même implémentation quand une méthode AddQuelqueChose est appelée deux fois. Une fabrique, enfin, remplace le constructeur par une fonction qui reçoit le fournisseur : elle sert à passer une valeur qui n'est pas un service, à choisir une implémentation selon la configuration, ou, comme ici, à faire pointer deux interfaces vers une seule instance. Le cours SOLID emploie la même redirection, de son interface vers le dépôt de factures enregistré. Reste le générique ouvert, qui enregistre une famille entière de types d'un coup : AddSingleton(typeof(IDepot<>), typeof(Depot<>)) sert IDepot<Client> comme IDepot<Commande>. C'est ainsi que chaque classe reçoit son ILogger<T> sans que personne ait écrit un enregistrement pour elle.

Services avec clé

Depuis .NET 8, un enregistrement peut porter une clé : AddKeyedSingleton, AddKeyedScoped, AddKeyedTransient. Le consommateur désigne celle qu'il veut par [FromKeyedServices] sur un paramètre de constructeur, ou d'endpoint dans une Minimal API ; l'implémentation peut recevoir sa propre clé par [ServiceKey]. C'est la réponse au cas où deux implémentations d'une même interface servent des consommateurs différents, là où IEnumerable<T> sert un consommateur qui les veut toutes.

using System;
using Microsoft.Extensions.DependencyInjection;

interface IStockage
{
    string Ecrire(string document);
}

sealed class StockageDisque : IStockage
{
    public string Ecrire(string document) => $"{document} ecrit sur disque";
}

// [ServiceKey] donne a l'instance la cle sous laquelle on l'a demandee.
sealed class StockageMemoire([ServiceKey] string cle) : IStockage
{
    public string Ecrire(string document) => $"{document} garde en memoire ({cle})";
}

// Le consommateur dit laquelle il veut, par sa cle.
sealed class Archivage([FromKeyedServices("disque")] IStockage stockage)
{
    public string Archiver(string facture) => stockage.Ecrire(facture);
}

static class Cles
{
    public static void Executer()
    {
        var services = new ServiceCollection();
        services.AddKeyedSingleton<IStockage, StockageDisque>("disque");
        services.AddKeyedSingleton<IStockage, StockageMemoire>("cache");
        services.AddSingleton<Archivage>();

        using var fournisseur = services.BuildServiceProvider();

        Console.WriteLine(fournisseur.GetRequiredService<Archivage>().Archiver("F-12")); // F-12 ecrit sur disque
        Console.WriteLine(fournisseur.GetRequiredKeyedService<IStockage>("cache").Ecrire("F-13")); // F-13 garde en memoire (cache)

        // Sans cle, un service a cle n'existe pas.
        Console.WriteLine(fournisseur.GetService<IStockage>() is null); // True

        try
        {
            fournisseur.GetRequiredKeyedService<IStockage>("nuage");
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
            // No keyed service for type 'IStockage' using key type 'System.String'
            // has been registered.
        }
    }
}

Une clé n'est pas une option : un service enregistré avec clé est invisible sans elle, et une clé inconnue lève. Depuis .NET 9 (et .NET 8.0.9), [FromKeyedServices] ne se rabat plus sur l'enregistrement sans clé quand la clé manque : il lève. La clé peut être n'importe quel objet qui implémente correctement Equals et GetHashCode : la documentation ne cite que le premier, mais une clé qui redéfinit Equals seul n'est pas retrouvée par une instance égale ; une constante ou une énumération évite les fautes de frappe qu'une chaîne laisse passer. Enregistrer avec KeyedService.AnyKey donne un repli pour toute clé sans enregistrement propre ; depuis .NET 10, demander un seul service avec AnyKey lève au lieu de rendre ce repli. Si la clé ne se connaît qu'à l'exécution, d'après une donnée, appeler GetRequiredKeyedService dans le code métier revient à un localisateur de services ; une fabrique qui encapsule ce choix, injectée, garde les dépendances visibles.

Qui libère quoi

La règle tient en une phrase : le conteneur libère ce qu'il a créé, quand se termine la portée qui le possède. Une instance scoped ou transient résolue dans une portée est libérée à la fin de cette portée, dans l'ordre inverse de création ; un singleton, à la libération de la racine, c'est-à-dire à l'arrêt de l'application. Une instance construite par le code puis donnée à AddSingleton n'a pas été créée par le conteneur : il ne la libère pas.

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;

// Une ressource qui annonce sa liberation.
abstract class Ressource(string nom) : IDisposable
{
    public void Dispose() => Console.WriteLine($"  {nom} liberee");
}

sealed class Journal() : Ressource("journal (singleton)");
sealed class Connexion() : Ressource("connexion (scoped)");
sealed class Lecteur(Connexion connexion) : Ressource("lecteur (transient)")
{
    public Connexion Connexion => connexion;
}
sealed class Configuration() : Ressource("configuration (instance fournie)");
sealed class Tampon() : Ressource("tampon (transient)");

// Seulement IAsyncDisposable, comme un client qui vide un tampon sur le reseau.
sealed class Export : IAsyncDisposable
{
    public ValueTask DisposeAsync()
    {
        Console.WriteLine("  export liberee");
        return ValueTask.CompletedTask;
    }
}

static class Liberation
{
    public static async Task ExecuterAsync()
    {
        var services = new ServiceCollection();
        services.AddSingleton<Journal>();
        services.AddScoped<Connexion>();
        services.AddTransient<Lecteur>();
        services.AddScoped<Export>();
        services.AddTransient<Tampon>();

        // Une instance deja construite : le conteneur la sert, mais ne la
        // possede pas.
        var configuration = new Configuration();
        services.AddSingleton(configuration);

        var racine = services.BuildServiceProvider();
        racine.GetRequiredService<Journal>();
        racine.GetRequiredService<Configuration>();

        Console.WriteLine("fin de la portee :");
        using (var portee = racine.CreateScope())
        {
            portee.ServiceProvider.GetRequiredService<Lecteur>();
        }
        // fin de la portee :
        //   lecteur (transient) liberee
        //   connexion (scoped) liberee

        // Un Dispose synchrone ne sait pas liberer ce qui n'est
        // qu'IAsyncDisposable : il leve.
        try
        {
            using var portee = racine.CreateScope();
            portee.ServiceProvider.GetRequiredService<Export>();
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
            // 'Export' type only implements IAsyncDisposable. Use DisposeAsync to
            // dispose the container.
        }

        await using (var portee = racine.CreateAsyncScope())
        {
            portee.ServiceProvider.GetRequiredService<Export>();
        }
        //   export liberee

        // Un transient jetable resolu depuis la racine : la racine le garde
        // pour le liberer a sa propre fin, c'est-a-dire a l'arret.
        for (var i = 0; i < 3; i++) racine.GetRequiredService<Tampon>();

        Console.WriteLine("fin de l'application :");
        racine.Dispose();
        // fin de l'application :
        //   tampon (transient) liberee
        //   tampon (transient) liberee
        //   tampon (transient) liberee
        //   journal (singleton) liberee
        // Et la configuration ? Rien : c'est a qui l'a creee de la liberer.
    }
}

Deux conséquences surprennent. Un transient jetable résolu depuis la racine y reste référencé jusqu'à l'arrêt, pour être libéré alors : résolu depuis la racine à chaque appel, par un singleton qui détient IServiceProvider, il fait grossir la mémoire sans limite. Et un service qui n'implémente que IAsyncDisposable exige une fin asynchrone, CreateAsyncScope et await using ; un Dispose synchrone lève, et son message renvoie à DisposeAsync. Le code qui reçoit un service par injection n'appelle jamais son Dispose : il ne le possède pas, un autre consommateur de la même portée peut encore s'en servir, et le conteneur le libérera de toute façon.

Ce cours vous a servi ? Offrir un café Signaler une erreur