Accueil › L'ossature › Chapitre 11

L'injection de dépendances

Trois mots intimidants pour une idée d'une simplicité désarmante : une classe ne fabrique pas ses outils, on les lui tend. Tout ASP.NET Core est construit là-dessus ; le comprendre, c'est comprendre comment une application .NET moderne est assemblée.

Le problème

Sans injection
public class ServiceCommande
{
    public async Task ValiderAsync(int id)
    {
        // La classe fabrique tout elle-même :
        var db = new AppDbContext(
            "Server=prod;Password=secret");   // 😱 en dur
        var mail = new SmtpEnvoyeur("smtp.fr");
        var log = new FichierLogger("C:\\logs");
        // ...
    }
}
  • Impossible à tester sans vraie base ni vrai SMTP
  • Chaîne de connexion figée dans le code
  • Changer de fournisseur = rouvrir cette classe
  • Une connexion nouvelle à chaque appel
Avec injection
public class ServiceCommande(
    AppDbContext db,
    IEnvoyeurEmail mail,
    ILogger<ServiceCommande> log)
{
    public async Task ValiderAsync(int id)
    {
        var cmd = await db.Commandes.FindAsync(id);
        // ... la classe se concentre sur SON métier
        await mail.EnvoyerAsync(cmd.Email, "Validée", "");
        log.LogInformation("Commande {Id} validée", id);
    }
}
  • Testable : on passe des doublures
  • Configuration externalisée
  • Les dépendances sont visibles dans la signature
  • Le conteneur gère la durée de vie
L'image

Un chirurgien ne fabrique pas ses instruments. Il tend la main, on lui pose le bon outil, stérilisé, au bon moment. Il ne sait ni qui l'a stérilisé, ni où il était rangé, ni ce qu'il devient après. Son travail, c'est d'opérer. Le conteneur d'injection est l'infirmier instrumentiste de ton application.

Comment ça marche concrètement

Program.cs — l'enregistrement (« qui fournit quoi »)
var builder = WebApplication.CreateBuilder(args);

// 1. On déclare les services disponibles
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Defaut")));

builder.Services.AddScoped<IEnvoyeurEmail, EnvoyeurSmtp>();   // contrat → réalisation
builder.Services.AddScoped<ServiceCommande>();                // classe concrète
builder.Services.AddSingleton<ITauxChange, TauxChangeCache>();
builder.Services.AddHttpClient<ApiPartenaire>();

var app = builder.Build();

// 2. On demande un service : le conteneur construit tout l'arbre
app.MapPost("/commandes/{id}/valider",
    async (int id, ServiceCommande service) =>      // ← injecté automatiquement
    {
        await service.ValiderAsync(id);
        return Results.NoContent();
    });

app.Run();
Enregistrements (Program.cs) ServiceCommande → scoped IEnvoyeurEmail → Smtp AppDbContext → scoped ITauxChange → singleton ILogger<T> → intégré Conteneur IServiceProvider « donne-moi X » Il construit tout l'arbre : new ServiceCommande( new AppDbContext(...), new EnvoyeurSmtp(...), logger) récursivement, jusqu'aux feuilles
Tu ne fais jamais new sur un service : tu déclares ce dont tu as besoin, le conteneur assemble.

Les trois durées de vie

MéthodeUne instance par…ImageExemples typiques
AddSingletontoute l'application⏰ L'horloge de la gare : une pour tousCache mémoire, configuration, client HTTP réutilisable, table de correspondance
AddScopedrequête HTTP🛒 Le chariot de supermarché : un par clientDbContext, unité de travail, contexte utilisateur
AddTransientchaque demande🥤 Le gobelet jetablePetit service sans état, validateur, générateur
Requête HTTP nº 1 AppDbContext #1 (scoped) ServiceCommande #1 (scoped) Validateur #1 Validateur #2 2 demandes de Validateur (transient) → 2 instances distinctes Requête HTTP nº 2 (simultanée) AppDbContext #2 ← un AUTRE ServiceCommande #2 Validateur #3 Les deux requêtes sont isolées : aucune donnée ne fuit de l'une à l'autre
Le singleton, lui, est unique et partagé par les deux requêtes : il doit donc être thread-safe et sans état lié à un utilisateur.
La règle de décision, en 10 secondes
  1. Le service contient-il des données propres à un utilisateur ou une requête ? → Scoped.
  2. Est-il coûteux à créer, sans état modifiable, et thread-safe ? → Singleton.
  3. Dans le doute → Scoped. C'est le défaut le plus sûr en application web.

Le piège nº 1 : la dépendance captive

Un singleton qui prend un scoped
builder.Services.AddSingleton<CacheProduits>();      // singleton
builder.Services.AddDbContext<AppDbContext>();        // scoped

public class CacheProduits(AppDbContext db)            // 💥 le DbContext devient prisonnier
{
    // Le contexte est créé une seule fois, avec la première requête,
    // puis réutilisé par toutes les suivantes, sur plusieurs threads.
}

Symptômes : ObjectDisposedException, données périmées, « A second operation was started on this context », corruption silencieuse du suivi de modifications. Heureusement, ASP.NET Core détecte le cas au démarrage en développement :

Cannot consume scoped service 'AppDbContext' from singleton 'CacheProduits'.

La solution : créer une portée à la demande
public class CacheProduits(IServiceScopeFactory fabrique)
{
    private readonly SemaphoreSlim _verrou = new(1);
    private List<Produit>? _cache;

    public async Task<List<Produit>> ObtenirAsync(CancellationToken ct)
    {
        if (_cache is not null) return _cache;

        await _verrou.WaitAsync(ct);
        try
        {
            using var portee = fabrique.CreateScope();                          // ← portée dédiée
            var db = portee.ServiceProvider.GetRequiredService<AppDbContext>();
            _cache ??= await db.Produits.AsNoTracking().ToListAsync(ct);
            return _cache;
        }
        finally { _verrou.Release(); }
    }
}

Même schéma obligatoire dans un BackgroundService : il est singleton, il doit donc créer une portée à chaque cycle de travail.

Les enregistrements qu'on finit toujours par utiliser

// Fabrique personnalisée : quand la construction demande de la logique
builder.Services.AddSingleton<IStockage>(sp =>
{
    var cfg = sp.GetRequiredService<IConfiguration>();
    return cfg["Stockage:Type"] == "azure"
        ? new StockageAzure(cfg["Stockage:Chaine"]!)
        : new StockageDisque(cfg["Stockage:Dossier"]!);
});

// Plusieurs implémentations du même contrat : injectées en IEnumerable
builder.Services.AddScoped<IRegleTarif, RegleWeekend>();
builder.Services.AddScoped<IRegleTarif, RegleFidelite>();
public class Tarificateur(IEnumerable<IRegleTarif> regles) { /* applique toutes les règles */ }

// Services nommés (« keyed », .NET 8+) : choisir explicitement lequel
builder.Services.AddKeyedScoped<IEnvoyeur, EnvoyeurSms>("sms");
builder.Services.AddKeyedScoped<IEnvoyeur, EnvoyeurEmail>("email");
app.MapPost("/notifier", ([FromKeyedServices("sms")] IEnvoyeur e) => e.EnvoyerAsync(...));

// N'enregistrer que s'il n'y a rien déjà (utile en bibliothèque)
builder.Services.TryAddScoped<IHorloge, HorlogeSysteme>();

// Client HTTP correctement géré (jamais « new HttpClient() » à la main)
builder.Services.AddHttpClient<ApiPartenaire>(c =>
{
    c.BaseAddress = new Uri("https://api.partenaire.fr/");
    c.Timeout = TimeSpan.FromSeconds(10);
});

// Tâche de fond
builder.Services.AddHostedService<NettoyageNocturne>();

Ce qu'il ne faut pas faire

Anti-modèlePourquoi c'est mauvaisÀ la place
Injecter IServiceProvider puis appeler GetService partout« Localisateur de service » : les dépendances redeviennent invisibles, les erreurs passent à l'exécutionDéclare-les dans le constructeur
Un constructeur avec 12 dépendancesLa classe fait trop de choses (SRP)Découpe en services cohérents
AddSingleton sur un service qui garde l'utilisateur courantFuite de données entre utilisateurs — faille de sécuritéAddScoped
Appeler Dispose() sur un service injectéLe conteneur le fera aussi : double libérationNe rien faire, c'est son travail
Une interface par classe systématiquementBruit sans bénéfice (ch. 5)Enregistre la classe concrète
new ServiceCommande(...) dans du code applicatifTu contournes la configuration et les durées de vieDemande-le au conteneur

Décoder les messages d'erreur

MessageTraduction
Unable to resolve service for type 'X' while attempting to activate 'Y'Tu as oublié builder.Services.Add...<X>() — ou tu l'as enregistré après builder.Build()
Cannot consume scoped service 'X' from singleton 'Y'Dépendance captive : passe par IServiceScopeFactory
Cannot access a disposed object 'AppDbContext'Tu utilises un service scoped après la fin de la requête (souvent un fire-and-forget non attendu)
A circular dependency was detectedA a besoin de B qui a besoin de A. Extrais la partie commune dans un troisième service
Unable to activate type 'X'. The following constructors are ambiguousPlusieurs constructeurs publics : n'en garde qu'un
Sous le capot, et deux options à activer
builder.Host.UseDefaultServiceProvider((ctx, o) =>
{
    o.ValidateScopes = true;      // détecte les dépendances captives (déjà actif en Development)
    o.ValidateOnBuild = true;     // vérifie tout le graphe au démarrage : échec immédiat et explicite
});

Pour les besoins avancés (décorateurs, interception, enregistrement par convention), les conteneurs tiers comme Autofac ou Scrutor se branchent sur le même mécanisme. La règle : ne les ajoute que lorsqu'un besoin précis apparaît, pas par principe.

Le bénéfice concret : les tests

// Test d'intégration : on démarre l'application réelle en remplaçant DEUX services
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ApiTests(WebApplicationFactory<Program> usine)
    {
        _client = usine.WithWebHostBuilder(b => b.ConfigureServices(services =>
        {
            services.RemoveAll<IEnvoyeurEmail>();
            services.AddScoped<IEnvoyeurEmail, EnvoyeurEspion>();   // pas de vrai email
            services.RemoveAll<IHorloge>();
            services.AddSingleton<IHorloge>(new HorlogeFigee(new DateTime(2026, 1, 1)));
        })).CreateClient();
    }

    [Fact]
    public async Task Inscription_renvoie_201()
        => Assert.Equal(HttpStatusCode.Created,
            (await _client.PostAsJsonAsync("/clients", new { Nom = "Ada" })).StatusCode);
}

Sans injection de dépendances, ce test exigerait un vrai serveur SMTP et échouerait le 1ᵉʳ janvier. Détails au chapitre 18.

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Quelle durée de vie pour un DbContext ?

Il n'est pas thread-safe et garde en mémoire le suivi des entités chargées. AddDbContext l'enregistre en scoped par défaut. Transient créerait plusieurs contextes par requête, cassant l'unité de travail.

2. Message au démarrage : Cannot consume scoped service 'AppDbContext' from singleton 'CacheProduits'. Que fais-tu ?

Les deux autres options « font taire » le message et créent des bugs de données bien plus difficiles à diagnostiquer.

3. Deux implémentations d'IRegleTarif enregistrées. Que reçoit ServiceTarif(IRegleTarif regle) ?

Le dernier enregistrement gagne pour une injection simple. Pour obtenir les deux : IEnumerable<IRegleTarif>. Pour choisir explicitement : services nommés (keyed).

4. Pourquoi éviter d'injecter IServiceProvider et d'appeler GetService dans le corps des méthodes ?

C'est le « localisateur de service ». Un constructeur explicite documente les besoins de la classe et fait échouer le démarrage — pas la requête d'un client — si quelque chose manque.

5. Dans un BackgroundService, comment accéder à un DbContext ?

Un service hébergé est singleton et vit aussi longtemps que l'application : injecter un contexte dans son constructeur le rendrait captif pendant des semaines.

Fiches de révision

Injection de dépendances en une phrase ?
La classe déclare ses besoins dans son constructeur ; le conteneur les fournit.
Les trois durées de vie ?
Singleton (application), Scoped (requête), Transient (chaque demande).
Dépendance captive ?
Un singleton qui capture un scoped, le gardant vivant trop longtemps. Solution : IServiceScopeFactory.
Plusieurs implémentations ?
IEnumerable<IContrat> pour toutes, services nommés (keyed) pour choisir.
Faut-il appeler Dispose ?
Non, jamais sur un service injecté : le conteneur le fait à la fin de la portée.
HttpClient ?
AddHttpClient et IHttpClientFactory — jamais new HttpClient() par appel.
À retenir