Chapitre 1421 minIntermédiaire
Injection de dépendances et attributs en C#
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- repérer les dépendances cachées d'une classe ;
- injecter des dépendances par le constructeur ;
- assembler l'application dans une racine de composition ;
- utiliser le conteneur de .NET et ses durées de vie ;
- valider et configurer avec des attributs.
3 exercices corrigés
L'injection de dépendances consiste à fournir à une classe, de l'extérieur et le plus souvent par son constructeur, les objets dont elle a besoin pour travailler (ses dépendances), au lieu de la laisser les créer elle-même. C'est la mise en pratique du principe d'inversion des dépendances : la classe ne connaît que des interfaces, et un seul endroit du programme choisit les classes concrètes. Ce chapitre montre l'injection à la main, puis avec le conteneur de .NET, et présente les attributs, les annotations que C# place entre crochets et que les frameworks lisent pour configurer ton code.
Le problème : des dépendances cachées
class TicketServiceV1
{
public Ticket Ouvrir(string titre, Priorite priorite, Employe demandeur)
{
var depot = new TicketRepositoryMySql("server=srv-bdd;database=parc;…");
var ticket = new Ticket(depot.ProchainNumero(), titre, priorite, demandeur, DateTime.Now);
depot.Enregistrer(ticket);
if (priorite == Priorite.Critique)
{
new EnvoiSmtp("smtp.sorbier.example").Envoyer("astreinte@sorbier.example", $"Ticket critique : {titre}");
}
return ticket;
}
}
Cette classe a trois dépendances cachées : la base MySQL, le serveur de messagerie et l'horloge (DateTime.Now). Conséquences :
- impossible de la tester sans base, sans serveur de courriel, et sans attendre la bonne heure ;
- la chaîne de connexion est écrite en dur dans le code métier ;
- passer à une autre base ou à des notifications Teams oblige à modifier le service.
L'injection par le constructeur
La classe déclare ses besoins sous forme d'interfaces dans son constructeur, et les range dans des champs readonly. Elle ne crée plus rien elle-même.
public class TicketService
{
private readonly ITicketRepository depot;
private readonly ICalculEcheance calculEcheance;
private readonly INotificateur notificateur;
private readonly TimeProvider horloge;
public TicketService(ITicketRepository depot, ICalculEcheance calculEcheance,
INotificateur notificateur, TimeProvider horloge)
{
this.depot = depot;
this.calculEcheance = calculEcheance;
this.notificateur = notificateur;
this.horloge = horloge;
}
public Ticket Ouvrir(string titre, Priorite priorite, Employe demandeur)
{
DateTime maintenant = horloge.GetLocalNow().DateTime;
Ticket ticket = new Ticket(depot.ProchainNumero(), titre, priorite, demandeur, maintenant);
depot.Enregistrer(ticket);
if (priorite == Priorite.Critique)
{
notificateur.Notifier("astreinte@sorbier.example", $"Ticket critique {ticket.Numero} : {titre}");
}
return ticket;
}
public IReadOnlyList<Ticket> EnRetard()
{
DateTime maintenant = horloge.GetLocalNow().DateTime;
return depot.NonResolus().Where(t => calculEcheance.Echeance(t) < maintenant).ToList();
}
}
La signature du constructeur dit honnêtement tout ce dont le service a besoin. ITicketRepository et ICalculEcheance sont les interfaces du chapitre 13 ; INotificateur est une interface d'une méthode, Notifier(destinataire, message). Le service ne crée qu'un seul objet lui-même, le Ticket : c'est une entité métier, pas une dépendance technique.
| Façon d'obtenir une dépendance | Ce qu'on en pense |
|---|---|
new dans la méthode ou le constructeur | couplage fort, impossible à remplacer |
un Singleton (Journal.Instance) ou un membre static | dépendance cachée, état global |
| paramètre du constructeur (injection par constructeur) | la forme recommandée : explicite, obligatoire, immuable |
| propriété publique à remplir (injection par propriété) | pour une dépendance facultative seulement |
| paramètre d'une méthode | quand la dépendance ne sert qu'à cette méthode |
La racine de composition
Il faut bien que quelqu'un crée les objets concrets. Ce rôle revient à un seul endroit, au démarrage de l'application : la racine de composition (composition root). Dans une application console, c'est Program.cs ; dans une application WinForms, le Main ou le constructeur du formulaire principal ; dans une API, le fichier de démarrage.
// Program.cs : le seul endroit qui choisit les classes concrètes
TicketService service = new TicketService(
new TicketRepositoryMemoire(),
new EcheanceStandard(),
new NotificateurConsole(),
TimeProvider.System);
Employe julie = new Employe(3, "Lefèvre", "Julie", "julie.lefevre@sorbier.example");
Ticket t = service.Ouvrir("Serveur de fichiers inaccessible", Priorite.Critique, julie);
Console.WriteLine(t);
[notification à astreinte@sorbier.example] Ticket critique 1 : Serveur de fichiers inaccessible
#1 Serveur de fichiers inaccessible [Critique, Ouvert]
Passer en production avec MySQL, c'est remplacer new TicketRepositoryMemoire() par new TicketRepositoryMySql(chaine) sur une ligne. Passer au contrat premium, c'est remplacer new EcheanceStandard(). Le service n'a pas changé.
Une horloge injectable : TimeProvider
Tout code qui lit DateTime.Now dépend de l'heure réelle : on ne peut pas tester « ce ticket est-il en retard ? » sans attendre quatre heures. Depuis .NET 8, la classe abstraite TimeProvider résout ce problème : TimeProvider.System donne l'heure du système en production, et un test fournit une horloge figée (chapitre 15). GetLocalNow() renvoie l'heure locale, GetUtcNow() l'heure universelle.
Le conteneur d'injection de .NET
Quand l'application grossit, assembler les objets à la main devient fastidieux : un contrôleur dépend d'un service, qui dépend de deux repositories, qui dépendent d'une connexion. Un conteneur d'injection de dépendances fait ce travail : on lui enregistre, pour chaque interface, la classe à utiliser et sa durée de vie ; il construit ensuite les objets en analysant leurs constructeurs. ASP.NET Core et les applications .NET récentes l'intègrent ; dans une application console, on ajoute le paquet Microsoft.Extensions.DependencyInjection.
dotnet add package Microsoft.Extensions.DependencyInjection
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new ServiceCollection();
services.AddSingleton<ITicketRepository, TicketRepositoryMemoire>();
services.AddSingleton<ICalculEcheance, EcheancePremium>();
services.AddTransient<INotificateur, NotificateurConsole>();
services.AddSingleton(TimeProvider.System);
services.AddTransient<TicketService>();
using ServiceProvider fournisseur = services.BuildServiceProvider();
TicketService service2 = fournisseur.GetRequiredService<TicketService>();
Ticket t2 = service2.Ouvrir("Imprimante bloquée", Priorite.Normale, julie);
Console.WriteLine(t2);
Console.WriteLine(ReferenceEquals(fournisseur.GetRequiredService<ITicketRepository>(),
fournisseur.GetRequiredService<ITicketRepository>()));
Console.WriteLine(ReferenceEquals(fournisseur.GetRequiredService<TicketService>(),
fournisseur.GetRequiredService<TicketService>()));
#1 Imprimante bloquée [Normale, Ouvert]
True
False
Le conteneur a vu que TicketService demande un ITicketRepository, un ICalculEcheance, un INotificateur et un TimeProvider, et les a fournis. Le repository, enregistré en singleton, est le même objet à chaque demande ; le service, transitoire, est un nouvel objet à chaque fois.
| Durée de vie | Méthode | Un objet… | Exemples |
|---|---|---|---|
| Singleton | AddSingleton | pour toute l'application | configuration, cache, horloge, repository en mémoire |
| Scoped | AddScoped | par « portée » : dans une API web, par requête HTTP | connexion à la base, contexte d'un ORM |
| Transitoire | AddTransient | à chaque demande | services légers, sans état |
Avec le constructeur principal de C# 12, une classe à injecter s'écrit plus court, et c'est la forme que tu verras dans beaucoup de projets récents :
public class TicketService(ITicketRepository depot, ICalculEcheance calculEcheance,
INotificateur notificateur, TimeProvider horloge)
{
public Ticket Ouvrir(string titre, Priorite priorite, Employe demandeur)
{
DateTime maintenant = horloge.GetLocalNow().DateTime;
// … même code, les paramètres sont visibles dans toute la classe
}
}
Les attributs : les annotations de C#
Un attribut est une information ajoutée à une classe, une propriété ou une méthode, entre crochets, juste au-dessus de la déclaration. Il ne change pas le code de la méthode : c'est une métadonnée, que le compilateur, un outil ou un framework lit pour adapter son comportement. Java appelle ce mécanisme des annotations (@Override), le terme qu'emploie le référentiel du BTS SIO ; PHP 8, comme C#, parle d'attributs (#[\Override]).
| Attribut | Lu par | Effet |
|---|---|---|
[Obsolete("message")] | le compilateur | avertissement CS0618 à chaque utilisation du membre |
[Required], [StringLength], [Range], [EmailAddress] | la classe Validator de .NET, ASP.NET Core | règles de validation déclaratives |
[JsonPropertyName("id")], [JsonIgnore] | le sérialiseur JSON System.Text.Json | nom de la propriété dans le JSON, propriété exclue |
[Fact], [Theory], [InlineData] | le moteur de tests xUnit | « cette méthode est un test » (chapitre 15) |
[HttpGet("{id}")], [Authorize] | ASP.NET Core | route d'une API, accès réservé aux utilisateurs connectés |
Valider avec des attributs. L'espace de noms System.ComponentModel.DataAnnotations fournit des attributs de validation, que la classe Validator sait vérifier :
using System.ComponentModel.DataAnnotations;
class DemandeTicket
{
[Required(ErrorMessage = "Le titre est obligatoire.")]
[StringLength(100, MinimumLength = 5, ErrorMessage = "Le titre doit compter de 5 à 100 caractères.")]
public string Titre { get; set; } = "";
[Required]
[EmailAddress(ErrorMessage = "L'adresse électronique est invalide.")]
public string EmailDemandeur { get; set; } = "";
[Range(0, 3, ErrorMessage = "La priorité va de 0 (basse) à 3 (critique).")]
public int Priorite { get; set; }
}
DemandeTicket demande = new DemandeTicket { Titre = "PC", EmailDemandeur = "julie.lefevre", Priorite = 7 };
List<ValidationResult> erreurs = [];
bool valide = Validator.TryValidateObject(demande, new ValidationContext(demande), erreurs, validateAllProperties: true);
Console.WriteLine($"Valide : {valide}");
foreach (ValidationResult r in erreurs)
{
Console.WriteLine($"- {r.ErrorMessage}");
}
Valide : False
- Le titre doit compter de 5 à 100 caractères.
- L'adresse électronique est invalide.
- La priorité va de 0 (basse) à 3 (critique).
Cette validation déclarative convient aux objets qui transportent une saisie (un formulaire, le corps d'une requête d'API). Elle ne remplace pas l'encapsulation des classes métier : un Ticket doit rester valide même si quelqu'un oublie d'appeler Validator. Les deux se complètent : la saisie est validée pour afficher des messages clairs, l'objet métier se protège lui-même.
Configurer la conversion en JSON. Les attributs de System.Text.Json.Serialization règlent la forme du JSON d'une API (cours API REST) :
using System.Text.Json;
using System.Text.Json.Serialization;
class TicketDto
{
[JsonPropertyName("id")]
public int Numero { get; set; }
[JsonPropertyName("titre")]
public string Titre { get; set; } = "";
[JsonIgnore]
public string NoteInterne { get; set; } = "";
}
string json = JsonSerializer.Serialize(new TicketDto { Numero = 4, Titre = "Serveur inaccessible", NoteInterne = "à ne pas exposer" });
Console.WriteLine(json); // {"id":4,"titre":"Serveur inaccessible"}
[JsonIgnore] évite d'exposer une donnée interne dans la réponse d'une API : c'est aussi une mesure de sécurité.
Écrire son propre attribut. Un attribut est une classe qui hérite de System.Attribute ; son nom se termine par Attribute, qu'on omet à l'usage. Le code le lit par réflexion, la capacité d'un programme à examiner ses propres types à l'exécution.
[AttributeUsage(AttributeTargets.Class)]
class DonneesPersonnellesAttribute : Attribute
{
public int DureeConservationMois { get; set; }
}
[DonneesPersonnelles(DureeConservationMois = 36)]
class DemandeTicket { /* … */ }
using System.Reflection;
foreach (Type type in new[] { typeof(TicketDto), typeof(DemandeTicket) })
{
var regle = type.GetCustomAttribute<DonneesPersonnellesAttribute>();
Console.WriteLine($"{type.Name} : {(regle is null ? "aucune donnée personnelle" : $"conservation {regle.DureeConservationMois} mois")}");
}
TicketDto : aucune donnée personnelle
DemandeTicket : conservation 36 mois
Un tel attribut pourrait alimenter un script de purge des données arrivées en fin de durée de conservation, une obligation du RGPD (cours Droit et RGPD). Tu écriras rarement tes propres attributs ; tu en utiliseras constamment dans les frameworks.
Exercices
Exercice 1 sur 3 Trouver les dépendances cachées Réussi À revoir
Relève les dépendances cachées de cette classe, puis réécris son en-tête (champs et constructeur) avec l'injection par constructeur.
class RelanceLicences
{
public void Executer()
{
var depot = new LogicielRepositoryMySql(Configuration.Instance.ChaineConnexion);
foreach (Logiciel l in depot.Tous())
{
if (l.DateFinLicence < DateOnly.FromDateTime(DateTime.Today).AddDays(30))
{
new EnvoiSmtp().Envoyer("achats@sorbier.example", $"Licence {l.Nom} à renouveler");
}
}
}
}
Voir la réponseMasquer la réponse de l'exercice 1
Quatre dépendances cachées : le repository MySQL (créé avec new), la configuration (un Singleton), l'horloge (DateTime.Today) et l'envoi de courriels (new EnvoiSmtp()).
class RelanceLicences
{
private readonly ILogicielRepository depot;
private readonly INotificateur notificateur;
private readonly TimeProvider horloge;
public RelanceLicences(ILogicielRepository depot, INotificateur notificateur, TimeProvider horloge)
{
this.depot = depot;
this.notificateur = notificateur;
this.horloge = horloge;
}
public void Executer()
{
DateOnly aujourdhui = DateOnly.FromDateTime(horloge.GetLocalNow().DateTime);
foreach (Logiciel l in depot.Tous())
{
if (l.DateFinLicence < aujourdhui.AddDays(30))
{
notificateur.Notifier("achats@sorbier.example", $"Licence {l.Nom} à renouveler");
}
}
}
}
La chaîne de connexion disparaît de la classe : c'est la racine de composition qui la lit dans la configuration et la passe au constructeur de LogicielRepositoryMySql.
Tu avais trouvé ?
Exercice 2 sur 3 Choisir une durée de vie Réussi À revoir
Dans une API ASP.NET Core qui utilise le conteneur de .NET, choisis la durée de vie (singleton, scoped ou transitoire) de chaque service.
- Le
TimeProviderdu système. - La connexion à la base de données, à ouvrir et fermer pour chaque requête HTTP.
- Un calculateur d'échéance sans état.
- Un cache en mémoire de la liste des logiciels, partagé par tous les utilisateurs.
Voir la réponseMasquer la réponse de l'exercice 2
- Singleton : l'horloge du système est unique et sans état propre.
- Scoped : une connexion par requête, libérée à la fin de la requête.
- Transitoire (ou singleton, puisqu'il est sans état) : créer un nouvel objet à chaque fois ne coûte presque rien.
- Singleton : c'est le but d'un cache partagé. Attention, un singleton utilisé par plusieurs requêtes en parallèle doit être protégé contre les accès simultanés.
Tu avais trouvé ?
Exercice 3 sur 3 Valider par attributs Réussi À revoir
Écris la classe NouvelEmploye (saisie d'un formulaire) avec des attributs de validation : nom et prénom obligatoires, 50 caractères au plus ; email obligatoire et au format d'une adresse ; téléphone facultatif, mais s'il est saisi, au format 01 23 45 67 89 (expression régulière ^0\d( \d{2}){4}$). Quelles erreurs donne Validator.TryValidateObject pour un employé dont le nom et l'email sont correctement saisis, mais le prénom vide et le téléphone 0199005003 ?
Voir la réponseMasquer la réponse de l'exercice 3
class NouvelEmploye
{
[Required(ErrorMessage = "Le nom est obligatoire.")]
[StringLength(50)]
public string Nom { get; set; } = "";
[Required(ErrorMessage = "Le prénom est obligatoire.")]
[StringLength(50)]
public string Prenom { get; set; } = "";
[Required]
[EmailAddress(ErrorMessage = "Adresse électronique invalide.")]
public string Email { get; set; } = "";
[RegularExpression(@"^0\d( \d{2}){4}$", ErrorMessage = "Téléphone au format 01 23 45 67 89.")]
public string? Telephone { get; set; }
}
Deux erreurs : « Le prénom est obligatoire. » ([Required] refuse une chaîne vide) et « Téléphone au format 01 23 45 67 89. » (les espaces manquent). Un téléphone null passerait : [RegularExpression] ignore les valeurs absentes, c'est [Required] qui rend une donnée obligatoire.
Tu avais trouvé ?