Voici une classe que vous ne pouvez pas tester unitairement sans une base de données active et un serveur SMTP opérationnel :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository("Server=prod-db;Database=Orders;...");
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.company.com");

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService construit lui-même ses SqlOrderRepository et SmtpEmailSender avec new, chaîne de connexion comprise. Chaque test qui touche à PlaceOrder a besoin d’une vraie base de données Orders et d’un vrai serveur de mail, sinon il échoue avant même que votre assertion s’exécute. La logique que vous voulez tester tient en deux lignes : enregistrer la commande, envoyer la confirmation. Elle est soudée à deux classes concrètes que vous ne pouvez pas remplacer.

Ce que signifie la Dependency Injection en pratique

La Dependency Injection consiste à transmettre à une classe les objets dont elle a besoin au lieu de la laisser les construire elle-même. La classe déclare ce dont elle dépend. Du code extérieur à la classe décide quelle implémentation concrète elle reçoit. Les conteneurs et les lifetimes ne sont que de l’outillage construit autour de ce seul principe.

Réécrivez OrderService pour qu’elle reçoive ses dépendances via le constructeur, sous forme d’interfaces plutôt que de classes concrètes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService ne sait plus si le repository écrit dans SQL Server ou dans une simple liste en mémoire, ni si l’e-mail part réellement par SMTP ou nulle part. Elle connaît deux interfaces et appelle leurs méthodes. Celui qui crée OrderService décide de ce qui est branché : un test lui passe des fakes, l’application web lui passe les vraies implémentations.

Voilà le constructor injection, la forme que vous utiliserez presque tout le temps. Le property injection (affecter une propriété publique après la construction) et le method injection (passer une dépendance uniquement à la méthode qui en a besoin) existent pour les dépendances optionnelles et les valeurs ponctuelles. Le conteneur intégré d’ASP.NET Core ne supporte d’ailleurs pas du tout le property injection. La règle tient en une phrase : si une classe ne peut pas faire son travail sans quelque chose, ce quelque chose va dans le constructeur.

Comment la Dependency Injection facilite les tests

Les dépendances arrivant sous forme de paramètres d’interface, un test peut passer à OrderService ce qu’il veut :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FakeOrderRepository : IOrderRepository
{
    public List<Order> SavedOrders { get; } = new();
    public void Save(Order order) => SavedOrders.Add(order);
}

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public void Send(string to, string subject) => SentTo.Add(to);
}

[Fact]
public void PlaceOrder_SavesOrderAndSendsConfirmation()
{
    var repository = new FakeOrderRepository();
    var emailSender = new FakeEmailSender();
    var service = new OrderService(repository, emailSender);

    service.PlaceOrder(new Order { CustomerEmail = "[email protected]" });

    Assert.Single(repository.SavedOrders);
    Assert.Equal("[email protected]", emailSender.SentTo.Single());
}

Ce test s’exécute en quelques millisecondes et ne touche ni base de données ni réseau. Il se situe à l’extrémité « unitaire » du spectre décrit dans Tests Unitaires vs Tests d’Intégration vs Tests End-to-End : une seule classe, les collaborateurs remplacés, un seul comportement vérifié. Ces classes écrites à la main sont des fakes, de petites implémentations fonctionnelles avec leur propre état. Une bibliothèque de mocking comme Moq ou NSubstitute génère le même genre de doublure à votre place, pour éviter d’écrire une classe par interface.

Ce que fait un conteneur DI, et pourquoi vous n’en avez pas forcément besoin

Rien de tout cela n’imposait un framework. Vous pouvez câbler les vraies dépendances à la main, dans le point d’entrée de votre application :

1
2
3
var repository = new SqlOrderRepository(connectionString);
var emailSender = new SmtpEmailSender(smtpHost);
var orderService = new OrderService(repository, emailSender);

On appelle ça la « pure DI » (ou « poor man’s DI »), et pour une petite application, ça suffit largement. Le principe reste le même : faire entrer les dépendances de l’extérieur. Un conteneur automatise ce câblage, et il devient rentable dès que vous avez des dizaines de classes aux dépendances imbriquées, où changer un seul constructeur oblige à modifier le code d’assemblage à cinq endroits différents.

ASP.NET Core embarque son propre conteneur. Vous enregistrez chaque interface face à une implémentation une seule fois, au démarrage, et le framework résout le graphe de dépendances à chaque fois qu’il construit un controller, un handler d’API minimale, ou tout autre service enregistré :

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository>(sp =>
    new SqlOrderRepository(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddTransient<IEmailSender>(sp =>
    new SmtpEmailSender(builder.Configuration["Smtp:Host"]));

var app = builder.Build();

SqlOrderRepository et SmtpEmailSender prennent une chaîne de connexion et un nom d’hôte en paramètre, pas un constructeur sans argument : chaque enregistrement est donc une factory, une lambda qui reçoit le conteneur (sp) et renvoie l’instance. Quand aucune des deux implémentations n’a besoin d’une valeur de ce genre, la forme à deux paramètres de type est plus courte : AddScoped<IOrderRepository, SqlOrderRepository>().

Enregistrez OrderService elle-même de la même façon (builder.Services.AddScoped<OrderService>()) et vous pouvez la demander dans le constructeur d’un controller. Le conteneur la construit, résout IOrderRepository et IEmailSender à partir de ces enregistrements, et vous n’appelez jamais new.

Les lifetimes ASP.NET Core : singleton, scoped, transient

AddScoped, AddTransient et AddSingleton s’enregistrent de la même façon ; le nom de la méthode est le lifetime, et choisir le mauvais est à l’origine de la plupart des bugs de DI sous ASP.NET Core.

LifetimeNouvelle instance crééeUsage typique
TransientÀ chaque demandeServices sans état, peu coûteux (un formatter, un validator)
ScopedUne fois par requête HTTPTout ce qui est lié à la requête en cours : un DbContext, une unit of work
SingletonUne seule fois, pour toute la durée de vie de l’applicationÉtat partagé et thread-safe : configuration, pool de connexions, cache

L’enregistrement AddScoped ci-dessus donne à chaque requête son propre SqlOrderRepository. Si le repository encapsule un DbContext, deux requêtes concurrentes ne peuvent jamais partager une transaction à moitié terminée. L’enregistrement AddTransient, lui, crée un nouveau SmtpEmailSender à chaque point d’injection. Ça ne pose aucun problème, puisque le sender ne conserve aucun état propre à une requête.

Les captive dependencies : un service scoped piégé dans un singleton

La règle sur les lifetimes est celle qui fait le plus mal : un service ne peut dépendre sans risque que de choses qui vivent au moins aussi longtemps que lui. Un singleton peut dépendre d’autres singletons. Un service scoped peut dépendre d’un scoped ou d’un singleton. Un transient peut dépendre de n’importe quoi. L’erreur classique, c’est un singleton qui prend une dépendance scoped dans son constructeur :

1
2
3
4
5
6
7
8
9
public class ReportCache // enregistré en singleton
{
    private readonly AppDbContext _db; // enregistré en scoped

    public ReportCache(AppDbContext db)
    {
        _db = db;
    }
}

Le conteneur construit ReportCache une seule fois, et l’AppDbContext qu’il a reçu reste dans ce champ pour toute la durée de vie de l’application. C’est une captive dependency. Chaque requête suivante partage le même DbContext, une classe qui n’est ni thread-safe ni conçue pour vivre longtemps. Ça se traduit par des lectures obsolètes, ou par des InvalidOperationException qui se plaignent qu’une seconde opération a démarré sur ce contexte avant que la précédente ne soit terminée.

ASP.NET Core détecte ça en développement. Avec la scope validation activée, ce qui est le comportement par défaut dans l’environnement Development, résoudre un singleton qui a besoin d’un service scoped déclenche une InvalidOperationException au démarrage, plutôt que de corrompre des données en production. Si un singleton a vraiment besoin, de temps en temps, d’un service scoped, injectez IServiceScopeFactory, créez un scope là où vous en avez besoin, et résolvez le service depuis ce scope au lieu de le garder dans un champ.

Les anti-patterns de la Dependency Injection : service locator et constructeurs surchargés

Le service locator déguisé. Injecter le conteneur lui-même et en extraire des dépendances à l’intérieur d’une méthode ressemble à de la DI, mais ça jette tout ce que la DI est censée apporter :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class OrderService
{
    private readonly IServiceProvider _provider;

    public OrderService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void PlaceOrder(Order order)
    {
        var repository = _provider.GetService<IOrderRepository>();
        repository.Save(order);
    }
}

Le constructeur ne dit plus ce dont OrderService a besoin. Il faut lire chaque méthode pour le savoir, et un enregistrement manquant se manifeste par un null en plein milieu de PlaceOrder, au lieu d’un échec au moment de la construction de la classe. Injectez les interfaces précises dont vous vous servez, pas l’objet qui les distribue.

Le constructeur à douze paramètres. Une classe qui a besoin de IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger et de sept autres fait trop de choses à la fois. La DI n’en est pas la cause ; elle l’a seulement rendue visible. C’est le même symptôme que celui décrit dans Qu’est-ce que le Spaghetti Code et Comment le Corriger. Découpez la classe par responsabilité et le constructeur rétrécit tout seul. Regrouper les douze dépendances dans un seul objet « services » masque le problème sans le résoudre.

Ces deux anti-patterns passent facilement entre les mailles de la code review. Ils ne passent plus dès que quelqu’un essaie d’écrire un test unitaire pour la classe.

Par où commencer avec la Dependency Injection

Choisissez une classe de votre codebase qui appelle new sur un client de base de données, un client HTTP ou un sender de mail. Modifiez son constructeur pour qu’il prenne une interface, câblez l’implémentation réelle à la main dans Program.cs, et écrivez un test qui lui passe un fake. Dans une application ASP.NET Core, inutile de câbler à la main : enregistrez-la directement dans builder.Services, puisque le conteneur est déjà là. Choisissez le lifetime délibérément pour tout ce qui conserve un état, et laissez la scope validation activée pour que le framework détecte une captive dependency avant vos utilisateurs.

Articles associés