Fondamentaux • Injection de dépendances
Concepts
L’injection de dépendances (Dependency Injection en anglais) est un modèle de conception (Design Pattern en anglais). Il permet, comme son nom l’indique, d’injecter une dépendance (au lieu de l’instancier directement).
Arrêtons-nous un moment sur plusieurs concepts clefs.
Dépendance
Qu’est-ce qu’une dépendance ?
Imaginons que nous développons un jeu de course automobile.

Le moteur de jeu de course a besoin pour bien fonctionner :
- d’un gestionnaire d’entrées (manette, clavier, souris, écran tactile, volant, pédale, etc.) ;
- d’une persistence (sauvegarder la progression, les classements, les meilleurs temps de course, etc.) ;
- d’un moteur de physique ;
- d’un média pour l’affichage (écran de PC, écran de mobile, navigateur web) ;
- d’un média pour le son ;
- etc.
Cette liste représente les dépendances du moteur de jeu. Le moteur en a besoin pour bien fonctionner et couvrir tous les besoins et fonctionnalités.
Instantiation
Nous pourrions directement instancier les dépendances (via new()) dans la classe du moteur de jeu.

Cette approche fonctionne, bien entendu. Mais elle impose un couplage fort entre le moteur de jeu de course et toutes ses dépendances.
Qu’est-ce un couplage fort ? C’est un lien étroit entre deux classes. Les deux classes sont très dépendantes l’une de l’autre. Elles sont imbriquées. Tout changement apporté à une classe aura potentiellement des impacts sur l’autre.
C’est vraiment une situation que nous souhaitons éviter à tout prix. Nous préférons des classes autonomes, isolées, indépendantes.
Pourtant, le moteur de jeu a besoin de toutes les dépendances que nous avons citées. Alors comment faire ?
Contrat d’interface
Concentrons-nous sur le gestionnaire d’entrées. Il a pour rôle de capturer les actions du joueur. Ces actions vont lui permettre de piloter la voiture sur le circuit de course :
- accélérer ;
- freiner ;
- tourner le volant.
Si nous devions définir un contrat pour le gestionnaire d’entrées, il pourrait être :
- niveau d’accélération : un nombre décimal entre 0 (aucune accéleration) et 1 (accélération maximale) ;
- niveau de freinage : un nombre décimal entre 0 (aucun freinage) et 1 (freinage maximal) ;
- direction : un angle entre -45º (volant tourné complètement à gauche) et +45º (volant tourné complètement à droite).

Le moteur de jeu va utiliser ces 3 valeurs renvoyées par le gestionnaire d’entrées afin de modifier le comportement de la voiture.
Le moteur de jeu a-t-il besoin de savoir comment sont obtenues ces 3 valeurs ? Le joueur peut utiliser un système de volant/pédalier, une manette, un clavier, un écran tactile, etc. Mais le moteur de jeu a uniquement besoin des 3 valeurs, quel que soit le dispositif employé par le joueur.
Nous avons défini un contrat d’interface : les propriétés et méthodes que s’engage à fournir la dépendance.
Le moteur de jeu aura besoin d’une implémentation concrète qui respecte ce contrat, rien de plus, rien de moins. Il n’aura pas besoin de connaitre les détails de l’implémentation, comment est effectivement codé le gestionnaire d’entrées.
Injection de dépendances
Le moteur de jeu a défini ce dont il avait besoin. Nous devons lui fournir maintenant une classe qui fait le boulot.
C’est là que l’injection de dépendances intervient. Elle va fournir au moteur de jeu une classe concrète qui respecte le contrat d’interface. Il pourra utiliser alors les méthodes et propriétés définies par ce contrat.

Il y a plusieurs manières d’injecter la dépendance :
- via un paramètre du constructeur
- via une méthode
- via une propriété décorée
Quelle que soit la façon de faire, le mécanisme d’injection de dépendance va fournir une classe correspondante à l’interface souhaitée.
Pour y arriver, nous allons devoir paramétrer l’injection de dépendance afin de lui définir les liens entre interface et implémentation.

Avantages
Le mécanisme d’injection de dépendances permet de fournir une implémentation concrète correspondant à un contrat d’interface.
Cela nous apporte un certain nombre d’avantages :
- découplage entre un besoin (l’interface) et les moyens de répondre à ce besoin (la classe concrète) ;
- mise à disposition d’un ensemble de dépendances que nous pourrons utiliser selon nos besoins ;
- résolution de dépendances en cascade : une dépendance qui a besoin d’une dépendance, qui en a besoin d’une autre, etc.
L’injection de dépendances avec .NET
Afin de gérer l’injection de dépendances, .NET fournit nativement un mécanisme de gestion de dépendances via le namespace Microsoft.Extensions.DependencyInjection.
Comment cela fonctionne-t-il ?
- les dépendances utilisées dans notre code sont représentées par des interfaces ;
- nous codons des implémentations concrètes pour ces interfaces ou nous utilisons des implémentations proposées par des paquets NuGet ;
- .NET propose le conteneur de dépendances (des
servicespour .NET)IServiceProvider; - nous ajoutons les dépendances dans
IServiceCollection; - le conteneur de dépendances est construit via
BuildServiceProvider(); - .NET résout les dépendances en injectant les implémentations concrètes dans les constructeurs des classes où elles sont utilisées ;
- nous pouvons également récupérer nous même des dépendances via
GetService()ouGetRequiredService().
Pour nous accompagner et mieux comprendre le fonctionnement, commençons par créer un nouveau projet console.
dotnet new console --name=DependencyInjectionAjoutons le paquet Nuget : Microsoft.Extensions.Hosting.
cd DependencyInjectiondotnet add package Microsoft.Extensions.HostingNous pouvons maintenant ouvrir le projet DependencyInjection dans notre IDE favori.
Tout d’abord, créons un conteneur de services IServiceCollection.
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de servicesIServiceCollection services = new ServiceCollection();Nous avons maintenant une base de travail.
Ajout de dépendances dans le conteneur
.NET propose d’ajouter des dépendances dans le conteneur avec 3 durées de vie :
Singleton: une instance de la dépendance est créée au démarrage de l’application et .NET fournit toujours cette instance ;Scoped: une instance de la dépendance est créée en même temps que le scope, .NET fournit cette instance depuis ce scope et pendant sa durée de vie, puis l’instance est détruite en même temps que le scope ;Transient: une nouvelle instance de la dépendance est créée systématiquement dès qu’elle est requise.
Rentrons un peu plus dans le détail.
Durée de vie Singleton
Un Singleton est un design pattern qui nous garantie qu’une seule instance d’une classe est créée.
En faisant un parallèle avec le monde réél, le tableau de La Joconde est un Singleton.
Il est unique au monde et le musée du Louvre en est garant.
Le visage de Mona Lisa peut être admiré par tous et chacun peut profiter de son sourire.
Un Singleton a donc les propriétés suivantes :
- l’instance est unique, pas de doublon possible ;
- l’instance peut héberger des données qui pourront être partagées et réutilisées ;
- comme l’instance n’est créée qu’une seule fois, elle peut (mais ne doit pas obligatoirement) être complexe.
Nous pouvons ajouter une dépendance de type Singleton en utilisant la méthode AddSingleton(). Il s’agit d’une méthode d’extension de IServiceCollection.
Un cache mémoire est un exemple d’utilisation d’un Singleton.
Les données du cache doivent être partagées et identiques quel que soit le consommateur du cache.
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de servicesIServiceCollection services = new ServiceCollection();
// Ajout de la dépendance IMemoryCache au conteneur en tant que Singletonservices.AddSingleton<IMemoryCache, MemoryCache>();
// Contrat d'interface du cache mémoireinternal interface IMemoryCache{ object Get(string key); void Set(string key, object value);}
// Implémentation concrète du cache mémoireinternal sealed class MemoryCache : IMemoryCache{ private readonly Dictionary<string, object> _cache = new(); public object Get(string key) => _cache[key]; public void Set(string key, object value) => _cache[key] = value;}Nous avons :
- déclaré une interface
IMemoryCache: il s’agit du contrat à respecter ; - déclaré une classe
MemoryCacheimplémentant cette interface et utilisant un dictionnaire comme cache ; - ajouté la dépendance
IMemoryCache↔MemoryCacheau conteneur en tant queSingleton.
Durée de vie Transient
À l’opposé de Singleton, une dépendance avec la durée de vie Transient est éphémère.
Elle est instanciée à la demande, n’est utilisée qu’une fois, puis détruite.
Pour faire de nouveau un parallèle avec le mode réel, un mouchoir jetable est Transient.
Nous prenons un mouchoir depuis un paquet, nous nous mouchons avec, puis nous le jetons à la poubelle.
Un mouchoir a les mêmes propriétés que n’importe quel autre mouchoir du paquet : la taille, la couleur, la texture, les capacités d’absorption, etc.
Nous voyons avec cet exemple que la dépendance de type Transient a les propriétés suivantes :
- elle répond à un besoin ciblé, c’est donc une dépendance plutôt légère avec des fonctionnalités réduites ;
- elle est facile à instancier, c’est-à-dire que c’est peu coûteux de la créer ;
- elle ne possède pas de données persistantes, elle est stateless.
Nous pouvons ajouter une dépendance de type Transient en utilisant la méthode AddTransient(). Il s’agit d’une méthode d’extension de IServiceCollection.
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de servicesIServiceCollection services = new ServiceCollection();
// Ajout de la dépendance IMemoryCache au conteneur en tant que Singletonservices.AddSingleton<IMemoryCache, MemoryCache>();
// Ajout de la dépendance ILogger au conteneur en tant que Transientservices.AddTransient<ILogger, ConsoleLogger>();
// ...
// Contrat d'interface du service de journalisationinternal interface ILogger{ void LogInfo(string message); void LogError(string message);}
// Implémentation concrète du service de journalisation via la consoleinternal sealed class ConsoleLogger : ILogger{ public void LogInfo(string message) => Console.WriteLine($"{DateTime.Now} - {message}"); public void LogError(string message) => Console.WriteLine($"{DateTime.Now} - ERROR: {message}");}Nous avons :
- déclaré une interface
ILogger; - déclaré une classe
ConsoleLoggerimplémentant cette interface et utilisant la classe statiqueConsole; - ajouté la dépendance
ILogger↔ConsoleLoggerau conteneur en tant queTransient.
La classe ConsoleLogger a les caractéristiques suivantes :
- elle est légère, facile à instancier ;
- aucune donnée persistante n’est présente ;
- elle répond au besoin de journaliser des messages ;
- puisque nous avons une application
console, elle utilise la classe statiqueConsolepour afficher les messages ; - dans une autre situation, les messages auraient pu être consignés dans un fichier texte, une table de base de données ou bien un gestionnaire d’événements.
Durée de vie Scoped
Définissons d’abord ce qu’est un scope. Il s’agit d’un sous-conteneur. Dans une application web, un nouveau scope est utilisé à chaque requête client. Il est créé à la réception de la requête et détruit une fois que le traitement de la requête est terminé. Toutes les dépendances sont récupérées à partir de ce scope.
S’il est créé automatiquement par ASP.NET, nous pouvons également en créer un nous même via les méthodes d’extension de IServiceProvider :
CreateScope()en mode synchrone ;CreateAsyncScope()en mode asynchrone.
À partir d’un scope, il est possible d’obtenir une dépendance via la méthode GetService() ou GetRequiredService().
Mais à quoi ça sert exactement ?
Pour les dépendances enregistrées avec une durée de vie Singleton ou Transient, le scope n’apporte rien de particulier.
Par contre, les dépendances avec une durée de vie Scoped seront instanciées une seule fois par scope.
Si une dépendance est injectée plusieurs fois pour le même scope alors l’instance sera toujours la même.
Si un autre scope est créé, une nouvelle instance de la dépendance sera ainsi créée pour ce nouveau scope.
Nous sommes donc à mi-chemin entre Singleton et Transient.
Faisons de nouveau une analogie avec un événement de la vie réelle. Nous nous rendons dans un parc d’attraction. Nous achetons un billet d’entrée pour ce parc. Ce billet n’est valable que pour la journée et uniquement pour nous. Par contre, ce billet nous permet d’accéder à toutes les attractions du parc, autant de fois que nous le souhaitons.
Qu’apporte donc la durée de vie Scoped ?
- pendant la durée de vie du scope, les dépendances ne sont pas recréées ce qui évite une charge de travail ;
- les dépendances restent stateless puisque recréés à chaque nouveau scope ;
- mais des données peuvent être partagées pendant la durée de vie du scope.
Limitations
Certaines dépendances peuvent dépendre les unes des autres.
Il faut dans ce cas être attentif aux durées de vie, en particulier Scoped.
Récupération des dépendances
Le moyen le plus simple d’obtenir une dépendance est d’utiliser le constructeur de la classe.
Nous utiliserons cette façon de faire quasiment tout le temps.
Chaque dépendance est un paramètre du constructeur.
Le fournisseur de services IserviceProvider va alors pour chaque paramètre du constructeur :
- lire le type du paramètre ;
- chercher le type correspondant dans le conteneur de services
IservicesCollection; - instancier la classe correspondant au type ;
- si cette classe a également des dépendances alors il essaie de les résoudre ;
- une fois la classe instanciée, elle est utilisée comme valeur du paramètre.
Nous pouvons également obtenir manuellement une dépendance en appelant la méthode d’extension GetService() ou GetRequiredService() de l’interface IServiceProvider.
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de servicesIServiceCollection services = new ServiceCollection();
// Ajout de la dépendance IMemoryCache au conteneur en tant que Singletonservices.AddSingleton<IMemoryCache, MemoryCache>();
// Ajout de la dépendance ILogger au conteneur en tant que Transientservices.AddTransient<ILogger, ConsoleLogger>();
// Construction du fournisseur de servicesIServiceProvider serviceProvider = services.BuildServiceProvider();
// Récupération du service de type IMemoryCacheIMemoryCache memoryCache = serviceProvider.GetRequiredService<IMemoryCache>();
// Utilisation du cachememoryCache.Set("cache-key", "cache-value-123");object cacheValue = memoryCache.Get("cache-key");
// Contrat d'interface du cache mémoireinternal interface IMemoryCache{ object Get(string key); void Set(string key, object value);}
// Implémentation concrète du cache mémoireinternal sealed class MemoryCache : IMemoryCacheinternal sealed class MemoryCache(ILogger logger) : IMemoryCache{ private readonly Dictionary<string, object> _cache = new();
public object Get(string key) => _cache[key]; public object Get(string key) { if (!_cache.TryGetValue(key, out object? value)) { logger.LogError($"Key '{key}' not found"); throw new InvalidOperationException(); }
logger.LogInfo($"Read from cache '{key}' → '{value}'"); return value; }
public void Set(string key, object value) => _cache[key] = value; public void Set(string key, object value) { _cache[key] = value; logger.LogInfo($"Write '{value}' to cache '{key}'"); }}
// Contrat d'interface du logger de consoleinternal interface ILogger{ void LogInfo(string message); void LogError(string message);}
// Implémentation concrète du logger consoleinternal sealed class ConsoleLogger : ILogger{ public void LogInfo(string message) => Console.WriteLine($"{DateTime.Now} - {message}"); public void LogError(string message) => Console.WriteLine($"{DateTime.Now} - ERROR: {message}");}Si nous exécutons le code, nous obtenons ceci dans la console :
17/03/2024 12:46:42 - Write 'cache-value-123' to cache 'cache-key'17/03/2024 12:46:42 - Read from cache 'cache-key' → 'cache-value-123'Nous avons apporté beaucoup de modifications dans le code ci-dessus. Tout d’abord, nous avons :
- construit le fournisseur de service via la méthode d’extension
BuildServiceProvider(); - récupéré une implémentation concrète de
IMemoryCachevia la méthode d’extensionGetRequiredService(); - appelé le cache mémoire afin de tester son comportement.
Nous avons ensuite modifié la classe MemoryCache en :
- ajoutant un constructeur avec le paramètre
ILoggerpermettant d’injecter cette dépendance ; - utilisant
ILoggerpour journaliser les actions de chaque méthode.
Ainsi, nous avons pu voir comment récupérer une dépendance :
- le cache mémoire via la méthode
GetRequiredService(); - le service de journalisation via le constructeur du cache mémoire.
Pour aller plus loin
Nous avons vu ensemble comment fonctionne le design pattern d’injection de dépendances et la façon dont il est implémenté nativement dans .NET.
Ce design pattern est primordial pour coder une application de qualité. Il est donc important de bien comprendre comment il fonctionne et comment l’employer.
Microsoft propose une documentation complète sur ce sujet (et bien d’autres). N’hésitez pas à la consulter pour approfondir ou éclaircir certains points.
- Microsoft - Injection de dépendances .NET
- Microsoft - Tutoriel : Utiliser l’injection de dépendances dans .NET
- Microsoft - Recommandations relatives à l’injection de dépendances
- Microsoft - Injection de dépendances dans ASP.NET Core
Avant que l’injection de dépendances ne soit proposé nativement dans .NET Core, il existait des paquets NuGet qui permettaient (et permettent encore) de le faire :
Ces alternatives peuvent être utiles au cas où le mécanisme natif de .NET ne serait pas suffisant ou bien pour le code legacy écrit en .NET Framework.