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
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
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
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
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();
new sur un service : tu déclares ce dont tu as besoin, le conteneur
assemble.Les trois durées de vie
| Méthode | Une instance par… | Image | Exemples typiques |
|---|---|---|---|
AddSingleton | toute l'application | ⏰ L'horloge de la gare : une pour tous | Cache mémoire, configuration, client HTTP réutilisable, table de correspondance |
AddScoped | requête HTTP | 🛒 Le chariot de supermarché : un par client | DbContext, unité de travail, contexte utilisateur |
AddTransient | chaque demande | 🥤 Le gobelet jetable | Petit service sans état, validateur, générateur |
- Le service contient-il des données propres à un utilisateur ou une requête ? → Scoped.
- Est-il coûteux à créer, sans état modifiable, et thread-safe ? → Singleton.
- 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
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'.
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èle | Pourquoi c'est mauvais | À la place |
|---|---|---|
Injecter IServiceProvider puis appeler GetService partout | « Localisateur de service » : les dépendances redeviennent invisibles, les erreurs passent à l'exécution | Déclare-les dans le constructeur |
| Un constructeur avec 12 dépendances | La classe fait trop de choses (SRP) | Découpe en services cohérents |
AddSingleton sur un service qui garde l'utilisateur courant | Fuite de données entre utilisateurs — faille de sécurité | AddScoped |
Appeler Dispose() sur un service injecté | Le conteneur le fera aussi : double libération | Ne rien faire, c'est son travail |
| Une interface par classe systématiquement | Bruit sans bénéfice (ch. 5) | Enregistre la classe concrète |
new ServiceCommande(...) dans du code applicatif | Tu contournes la configuration et les durées de vie | Demande-le au conteneur |
Décoder les messages d'erreur
| Message | Traduction |
|---|---|
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 detected | A 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 ambiguous | Plusieurs constructeurs publics : n'en garde qu'un |
- Le conteneur intégré choisit le constructeur public qu'il peut satisfaire entièrement avec les services enregistrés (le plus riche possible). Un paramètre non résoluble non optionnel = échec.
- Les instances
IDisposablecréées par le conteneur sont libérées à la fin de leur portée (fin de requête pour scoped, arrêt de l'application pour singleton). D'où l'inutilité — et le danger — d'unDisposemanuel. - Valider au démarrage plutôt qu'à la première requête :
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 ?
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 ?
3. Deux implémentations d'IRegleTarif enregistrées. Que reçoit
ServiceTarif(IRegleTarif regle) ?
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 ?
5. Dans un BackgroundService, comment accéder à un DbContext ?
Fiches de révision
IServiceScopeFactory.IEnumerable<IContrat> pour toutes, services nommés (keyed) pour choisir.Dispose ?AddHttpClient et IHttpClientFactory — jamais new HttpClient() par appel.- Déclare tes dépendances dans le constructeur ; ne fabrique rien toi-même.
- Scoped par défaut en web ; singleton seulement si sans état et thread-safe.
- Jamais un scoped dans un singleton : passe par
IServiceScopeFactory. - Le conteneur libère ce qu'il crée : ne fais pas
Disposeà sa place. - Active
ValidateOnBuild: les erreurs de câblage au démarrage, pas en production.