Architecture & conception

SOLID

Les cinq principes, avec le code avant et après.

Vérifié en septembre 2026 · .NET 10 · environ 16 min

SOLID réunit cinq principes de conception objet que Robert C. Martin a formulés ou repris à la fin des années 1990 : son article de 2000, « Design Principles and Design Patterns », en présente quatre, sans la responsabilité unique, qu'il date lui-même de la même période. L'acronyme est attribué à Michael Feathers, vers 2004, par des sources de seconde main. Tous parlent de la même chose : les dépendances entre morceaux de code, et ce qu'elles coûtent quand le code change. Ils servent donc là où il change — un produit maintenu des années, par plusieurs équipes — et presque pas dans un script jetable ou un écran de saisie figé. Chaque section montre un code qui viole le principe, le même code qui le respecte, ce que le principe interdit vraiment et l'abus qu'on en fait. Le fil rouge est une facturation.

Responsabilité unique : un seul acteur

La formule consacrée dit qu'une classe n'a qu'une raison de changer. Elle se lit mal, et Martin l'a reprise lui-même dans « Clean Architecture », en 2017 : un module ne répond qu'à un seul acteur, c'est-à-dire à un seul groupe de personnes susceptibles d'en demander la modification. Le principe ne dit pas qu'une classe fait une seule chose ; c'est une règle pour les fonctions, et une autre affaire. Les exemples du cours s'appuient sur ce fichier commun ; l'avant et l'après d'une section sont deux versions du même code, compilées chacune avec lui, et un commentaire signale l'exemple qui réunit plusieurs projets.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace Facturation;

public sealed record Ligne(string Libelle, int Quantite, decimal PrixUnitaireHT)
{
    public decimal TotalHT => Quantite * PrixUnitaireHT;
}

public sealed record Facture(
    string Numero,
    string Courriel,
    IReadOnlyList<Ligne> Lignes,
    decimal FraisDePort,
    DateOnly Echeance,
    bool Payee = false)
{
    public decimal TotalLignesHT => Lignes.Sum(l => l.TotalHT);
}

public static class Euros
{
    // Culture invariante : l'affichage est le meme sur toutes les machines.
    public static string Afficher(decimal montant) =>
        montant.ToString("0.00", CultureInfo.InvariantCulture) + " EUR";
}

public static class Exemple
{
    // 350 EUR de lignes, 20 EUR de port, echue le 15 septembre 2026.
    public static Facture Facture() =>
        new(
            "F-2026-041",
            "[email protected]",
            [new Ligne("Ecran 27 pouces", 2, 150m), new Ligne("Cable HDMI", 4, 12.50m)],
            FraisDePort: 20m,
            Echeance: new DateOnly(2026, 9, 15));
}

La classe suivante sert deux acteurs, la comptabilité et les ventes, par une méthode privée factorisée parce que les deux calculs partaient du même montant. Les ventes viennent d'en demander la modification :

using System;
using Facturation;

namespace Facturation.Calculs;

// Une classe, deux clients : la comptabilite lui demande la TVA, les ventes
// la commission des commerciaux.
public sealed class CalculFacture
{
    public decimal Tva(Facture facture) => Assiette(facture) * 0.20m;

    public decimal Commission(Facture facture) => Assiette(facture) * 0.05m;

    // Demande des ventes : « la commission ne porte plus sur les frais de port ».
    // La methode commune ressemblait a une seule notion ; c'en etait deux.
    private static decimal Assiette(Facture facture) =>
        facture.TotalLignesHT; // + facture.FraisDePort, avant la demande
}

static class Demonstration
{
    public static void Executer()
    {
        var calcul = new CalculFacture();
        var facture = Exemple.Facture();

        Console.WriteLine(Euros.Afficher(calcul.Commission(facture))); // 17.50 EUR, comme voulu
        Console.WriteLine(Euros.Afficher(calcul.Tva(facture)));        // 70.00 EUR au lieu de 74.00
    }
}

La modification est juste pour qui l'a demandée, et le test des commissions passe. La TVA, elle, perd quatre euros sur cette facture sans qu'aucune ligne fiscale ait été touchée, et la comptabilité découvrira l'écart à la clôture. Le défaut n'est pas la taille de la classe : c'est qu'elle mêle deux raisons de changer, et que le code partagé transmet la demande de l'un au calcul de l'autre.

using System;
using Facturation;

namespace Facturation.Comptabilite
{
    // Ne change que si la regle fiscale change : la TVA porte sur tout ce
    // qui est facture, port compris.
    public static class Tva
    {
        public static decimal Calculer(Facture facture) =>
            (facture.TotalLignesHT + facture.FraisDePort) * 0.20m;
    }
}

namespace Facturation.Ventes
{
    // Ne change que si la politique commerciale change.
    public static class Commission
    {
        public static decimal Calculer(Facture facture) => facture.TotalLignesHT * 0.05m;
    }

    static class Demonstration
    {
        public static void Executer()
        {
            var facture = Exemple.Facture();

            Console.WriteLine(Euros.Afficher(Commission.Calculer(facture)));       // 17.50 EUR
            Console.WriteLine(Euros.Afficher(Comptabilite.Tva.Calculer(facture))); // 74.00 EUR
        }
    }
}

Avant la demande, les deux assiettes s'écrivaient pareil, et la factorisation semblait appliquer DRY. Mais deux expressions qui coïncident et qui changeront pour des raisons différentes sont deux connaissances, pas une : les fusionner a couplé deux acteurs par un détail. Séparées, elles ont pu diverger, et chacune ne suit plus que la règle de son acteur.

L'abus classique prend la formule au mot : une classe par méthode, un calculateur d'assiette, un applicateur de taux, et une règle fiscale de trois lignes éparpillée dans quatre fichiers. Une classe de dix méthodes qui ne servent que la comptabilité respecte le principe. Le critère tient dans une question : qui demandera la prochaine modification ? S'il y a deux réponses, il y a deux modules.

Ouvert-fermé : ajouter sans rouvrir

Un module est ouvert à l'extension et fermé à la modification : un comportement nouveau s'ajoute par du code nouveau, sans rouvrir le code qui marche. Bertrand Meyer l'a formulé en 1988 avec l'héritage d'implémentation : une classe publiée ne change plus, une sous-classe l'étend. Martin l'a reformulé en 1996 avec l'abstraction polymorphe : le code dépend d'une abstraction, et l'extension en est une implémentation de plus. C'est cette version qui a cours. Le principe n'interdit pas de modifier du code, qu'on corrige et remanie tous les jours. Il interdit qu'une variation attendue, qui revient, oblige à retrouver et retoucher chaque endroit qui la connaît. Ici, la variation est le canal d'envoi, et la facturation électronique vient d'en ajouter un. Le switch des frais a été complété, mais une condition sur le canal, écrite du temps de deux canaux, ne voit pas le troisième :

using System;
using Facturation;

namespace Facturation.Envoi;

public enum Canal
{
    Courriel,
    Courrier,
    Plateforme, // ajoute cette annee, pour la facturation electronique
}

public static class Expedition
{
    // Chaque canal nouveau rouvre cette classe, et chaque endroit qui teste
    // le canal doit etre retrouve a la main.
    public static decimal Frais(Canal canal) =>
        canal switch
        {
            Canal.Courriel => 0m,
            Canal.Courrier => 1.80m,
            Canal.Plateforme => 0.10m, // pense ici...
            _ => throw new ArgumentOutOfRangeException(nameof(canal)),
        };

    // ... oublie la : ecrite du temps de deux canaux, la condition range
    // d'office le troisieme parmi les envois papier.
    public static bool DoitImprimer(Canal canal) => canal != Canal.Courriel;

    public static string Expedier(Facture facture, Canal canal) =>
        $"{facture.Numero} par {canal} : frais {Euros.Afficher(Frais(canal))}, impression {DoitImprimer(canal)}";
}

static class Demonstration
{
    public static void Executer()
    {
        Console.WriteLine(Expedition.Expedier(Exemple.Facture(), Canal.Plateforme));
        // F-2026-041 par Plateforme : frais 0.10 EUR, impression True
    }
}

La condition de DoitImprimer n'avait aucun moyen de signaler le nouveau canal : la facture part sur la plateforme et, en plus, à l'imprimerie. Rien ne compile de travers, aucun test existant n'échoue ; il fallait savoir que cette ligne existait.

using System;
using Facturation;

namespace Facturation.Envoi;

// Le point d'extension : tout ce qu'un canal doit savoir dire de lui-meme.
// Un canal qui oublie un membre ne compile pas.
public interface ICanal
{
    string Nom { get; }

    decimal Frais { get; }

    bool Imprime { get; }
}

public sealed class ParCourriel : ICanal
{
    public string Nom => "courriel";

    public decimal Frais => 0m;

    public bool Imprime => false;
}

public sealed class ParCourrier : ICanal
{
    public string Nom => "courrier";

    public decimal Frais => 1.80m;

    public bool Imprime => true;
}

// Fermee : elle ne connait aucun canal, et ne changera pas au prochain.
public static class Expedition
{
    public static string Expedier(Facture facture, ICanal canal) =>
        $"{facture.Numero} par {canal.Nom} : frais {Euros.Afficher(canal.Frais)}, impression {canal.Imprime}";
}

// Ajoute cette annee, dans un fichier neuf : rien au-dessus n'a ete rouvert.
public sealed class ParPlateforme : ICanal
{
    public string Nom => "plateforme";

    public decimal Frais => 0.10m;

    public bool Imprime => false;
}

static class Demonstration
{
    public static void Executer()
    {
        Console.WriteLine(Expedition.Expedier(Exemple.Facture(), new ParPlateforme()));
        // F-2026-041 par plateforme : frais 0.10 EUR, impression False
    }
}

Un canal est maintenant un type qui répond à tout ce qu'on doit savoir de lui, et le compilateur refuse un ParPlateforme qui oublierait Imprime. Expedition ne cite plus aucun canal : elle est fermée, et le troisième canal est un fichier neuf.

Le prix est symétrique. Cette forme rend facile l'ajout d'un canal et coûteux l'ajout d'une question : demander à chaque canal son délai d'acheminement impose de modifier toutes les classes, là où l'enum n'aurait demandé qu'un switch de plus. Le bon choix dépend de l'axe sur lequel les changements arrivent vraiment. D'où l'abus classique, l'abstraction spéculative : une interface et une fabrique pour une variation qui ne viendra jamais, et qui gêne celle qui vient. Tant qu'un seul switch connaît les cas, il suffit ; on abstrait quand la même variation a déjà forcé plusieurs modifications, pas avant.

Substitution de Liskov : tenir le contrat du type

Barbara Liskov l'a énoncé dans une conférence de 1987, publiée en 1988 : un sous-type doit pouvoir remplacer son type partout sans que le programme cesse d'être correct. Correct au sens du contrat, pas de la signature : le compilateur vérifie que la méthode existe, pas qu'elle tient la promesse. Liskov et Wing ont précisé en 1994 ce que cela interdit : exiger davantage à l'entrée, garantir moins à la sortie, casser un invariant du type, lever une exception que le contrat ne prévoit pas. Leur article ajoute une règle qui lui est propre, la règle d'historique : un sous-type ne peut pas permettre une évolution d'état que le type interdit. Un ensemble qui ne fait que grossir n'a pas pour sous-type un ensemble dont on retire des éléments, même si chaque méthode héritée se comporte à l'identique, et un sous-type modifiable d'un type immuable échoue pour la même raison. Le remboursement d'une commande annulée tombe, lui, dans le cas de l'exception :

using System;
using System.Collections.Generic;
using Facturation;

namespace Facturation.Paiements;

public abstract class Paiement(decimal montant)
{
    public decimal Montant { get; } = montant;

    // Contrat : rend au client la valeur de ce paiement, et dit sous quelle
    // forme. Aucune exception pour un paiement encaisse.
    public abstract string Rembourser();
}

public sealed class Carte(decimal montant, string finDeNumero) : Paiement(montant)
{
    public override string Rembourser() =>
        $"{Euros.Afficher(Montant)} recredites sur la carte {finDeNumero}";
}

public sealed class BonAchat(decimal montant) : Paiement(montant)
{
    // La regle metier est juste : un bon ne se rembourse pas en especes.
    // Sa traduction ne l'est pas : le sous-type refuse ce que le type promet.
    public override string Rembourser() =>
        throw new NotSupportedException("Un bon d'achat ne se rembourse pas.");
}

public static class Annulation
{
    // Ecrite contre Paiement : juste pour tout paiement qui tient le contrat.
    public static void Annuler(IEnumerable<Paiement> paiements)
    {
        foreach (var paiement in paiements)
        {
            Console.WriteLine(paiement.Rembourser());
        }
    }
}

static class Demonstration
{
    public static void Executer()
    {
        // Les 444 EUR TTC de la facture, payes en trois fois.
        try
        {
            Annulation.Annuler([new Carte(300m, "4417"), new BonAchat(100m), new Carte(44m, "4417")]);
        }
        catch (NotSupportedException erreur)
        {
            Console.WriteLine(erreur.Message);
        }

        // 300.00 EUR recredites sur la carte 4417
        // Un bon d'achat ne se rembourse pas.
        // Les 44 EUR du dernier paiement ne sont jamais rendus.
    }
}

Annuler est juste pour tout paiement qui tient le contrat, et BonAchat ne le tient pas. L'annulation s'arrête au milieu, 300 euros rendus et 144 dus, et l'erreur n'apparaît qu'à l'exécution, avec le premier client qui a payé en partie par bon. La réparation tentante, if (paiement is BonAchat) dans Annuler, casse ouvert-fermé : chaque sous-type rétif ajoutera son test chez tous les appelants. La règle métier n'était pas en cause, seulement sa traduction.

using System;
using System.Collections.Generic;
using Facturation;

namespace Facturation.Paiements;

public abstract class Paiement(decimal montant)
{
    public decimal Montant { get; } = montant;

    // Contrat : rend au client la valeur de ce paiement, et dit sous quelle
    // forme. Aucune exception pour un paiement encaisse.
    public abstract string Rembourser();
}

public sealed class Carte(decimal montant, string finDeNumero) : Paiement(montant)
{
    public override string Rembourser() =>
        $"{Euros.Afficher(Montant)} recredites sur la carte {finDeNumero}";
}

public sealed class BonAchat(decimal montant) : Paiement(montant)
{
    // Meme regle metier, traduite dans le contrat : le client recupere sa
    // valeur, sous la seule forme qu'un bon autorise.
    public override string Rembourser() => $"{Euros.Afficher(Montant)} rendus en bon d'achat neuf";
}

public static class Annulation
{
    // Inchangee.
    public static void Annuler(IEnumerable<Paiement> paiements)
    {
        foreach (var paiement in paiements)
        {
            Console.WriteLine(paiement.Rembourser());
        }
    }
}

static class Demonstration
{
    public static void Executer()
    {
        Annulation.Annuler([new Carte(300m, "4417"), new BonAchat(100m), new Carte(44m, "4417")]);
        // 300.00 EUR recredites sur la carte 4417
        // 100.00 EUR rendus en bon d'achat neuf
        // 44.00 EUR recredites sur la carte 4417
    }
}

Le bon rend la valeur sous la forme qui lui est propre, et Annuler n'a pas changé. Quand aucune traduction ne tient le contrat, c'est la hiérarchie qui est fausse : un bon non remboursable n'est pas un Paiement remboursable, et les types doivent le dire.

Le contrat fait foi, et c'est lui qui tranche les cas limites. ICollection<T>.Add documente une NotSupportedException pour une collection en lecture seule, que signale IsReadOnly. Un tableau vu comme IList<int> répond true à IsReadOnly et lève « Collection was of a fixed size. » à Add : il ne viole rien, puisque le contrat l'annonçait. Ce contrat est seulement faible, et chaque appelant doit tester avant d'écrire ; IReadOnlyList<T>, arrivée avec .NET Framework 4.5, y répond par une interface qui ne promet pas l'écriture. Le cours Collections et LINQ montre le même tableau passé à une méthode qui écrit.

L'exemple canonique du carré qui hérite du rectangle montre qu'un « est un » du monde réel n'est pas un sous-type : un carré dont on règle la largeur ne peut pas garder sa hauteur, et le code qui double la largeur d'un rectangle en attend l'aire doublée. On en conclut qu'il suffit de rendre les deux immuables. En C#, cela ne suffit pas :

using System;

namespace Geometrie;

public record Rectangle(double Largeur, double Hauteur);

// Immuable : plus de setter pour deformer le carre... en principe.
public record Carre(double Cote) : Rectangle(Cote, Cote);

static class Demonstration
{
    public static void Executer()
    {
        Rectangle forme = new Carre(2);

        // with clone le type reel, Carre, puis affecte Largeur, propriete init
        // heritee de Rectangle. Le constructeur principal du carre n'est pas
        // rappele : seul le constructeur de copie l'est.
        var copie = forme with { Largeur = 3 };
        Console.WriteLine(copie); // Carre { Largeur = 3, Hauteur = 2, Cote = 2 }

        // Meme resultat sans passer par la base : c'est l'invariant du carre
        // qui tombe, par un membre init herite.
        Carre carre = new(2);
        Console.WriteLine(carre with { Largeur = 3 }); // Carre { Largeur = 3, Hauteur = 2, Cote = 2 }
    }
}

La copie est un Carre de trois sur deux, et le résultat est le même avec une variable typée Carre : c'est l'invariant du carré qui tombe. Un record dérivé hérite des propriétés init de sa base, que with affecte sans repasser par le constructeur principal. Le rectangle offre une opération, changer une dimension sans l'autre, que le carré ne peut pas accepter : immuable ou non, il n'en est pas un sous-type. La version juste ne fait hériter aucun des deux de l'autre :

using System;

namespace Geometrie;

// L'abstraction ne promet que ce que toutes les formes tiennent : une aire.
// Aucune dimension modifiable, donc rien qu'un carre doive refuser.
public abstract record Forme
{
    public abstract double Aire { get; }
}

public sealed record Rectangle(double Largeur, double Hauteur) : Forme
{
    public override double Aire => Largeur * Hauteur;
}

// Frere du rectangle, pas son fils : sa seule donnee est son cote.
public sealed record Carre(double Cote) : Forme
{
    public override double Aire => Cote * Cote;
}

static class Demonstration
{
    public static void Executer()
    {
        Forme[] formes = [new Rectangle(3, 2), new Carre(2)];
        foreach (var forme in formes)
        {
            Console.WriteLine(forme);
        }

        // Rectangle { Aire = 6, Largeur = 3, Hauteur = 2 }
        // Carre { Aire = 4, Cote = 2 }

        Console.WriteLine(new Carre(2) with { Cote = 3 }); // Carre { Aire = 9, Cote = 3 }

        // new Carre(2) with { Largeur = 3 } ne compile plus :
        //   CS0117, Carre ne contient pas de definition pour Largeur.
    }
}

Forme ne promet que ce que les deux tiennent, une aire, et Carre ne porte que son côté : with ne peut plus fabriquer un carré de trois sur deux. L'abus inverse tient tout comportement différent pour une violation. Un sous-type qui journalise, met en cache ou va plus vite respecte le principe, puisqu'il tient les mêmes promesses ; et un type qu'aucun appelant ne manipule à travers sa base n'a rien à substituer.

Ségrégation des interfaces : à la mesure du client

Aucun client ne doit dépendre de méthodes qu'il n'utilise pas. Le principe regarde l'interface depuis ceux qui l'appellent, pas depuis ceux qui l'implémentent : une interface grossit par accumulation des besoins de tous, et chaque client finit couplé aux besoins des autres.

using System;
using System.Collections.Generic;
using System.Linq;
using Facturation;

namespace Facturation.Stockage;

// Le depot tel qu'il a grossi : une methode par besoin, de tous ses clients.
public interface IDepotFactures
{
    Facture? Charger(string numero);

    IReadOnlyList<Facture> Impayees(DateOnly auJour);

    void Enregistrer(Facture facture);

    void Supprimer(string numero);

    byte[] ExporterComptabilite(int annee);
}

// La selection des relances lit une methode, et depend des cinq.
public sealed class SelectionRelances(IDepotFactures depot)
{
    public IReadOnlyList<string> Destinataires(DateOnly auJour) =>
        depot.Impayees(auJour).Select(f => f.Courriel).Distinct().ToList();
}

// Son double de test : une methode utile, quatre a remplir d'exceptions.
public sealed class DepotDeTest(params Facture[] factures) : IDepotFactures
{
    public IReadOnlyList<Facture> Impayees(DateOnly auJour) =>
        factures.Where(f => !f.Payee && f.Echeance < auJour).ToList();

    public Facture? Charger(string numero) => throw new NotImplementedException();

    public void Enregistrer(Facture facture) => throw new NotImplementedException();

    public void Supprimer(string numero) => throw new NotImplementedException();

    public byte[] ExporterComptabilite(int annee) => throw new NotImplementedException();
}

La sélection des relances lit une méthode et en porte cinq. Le coût se voit dans le double de test, rempli d'exceptions, et dans ce qui n'est pas encore arrivé : quand l'export comptable changera de signature, ce double devra être réécrit, pour une modification qui ne concerne pas la relance. Et une implémentation qui ne sait pas tout faire, une réplique en lecture seule par exemple, est condamnée à lever NotSupportedException sur l'écriture : l'interface trop large fabrique des violations de Liskov.

using System;
using System.Collections.Generic;
using System.Linq;
using Facturation;

// Plusieurs projets montres ensemble : chaque bloc namespace en est un.

namespace Facturation.Relances // projet Application
{
    // Declaree par la relance, a sa mesure : ce qu'elle lit, rien d'autre.
    public interface IFacturesImpayees
    {
        IReadOnlyList<Facture> Impayees(DateOnly auJour);
    }

    public sealed class SelectionRelances(IFacturesImpayees factures)
    {
        public IReadOnlyList<string> Destinataires(DateOnly auJour) =>
            factures.Impayees(auJour).Select(f => f.Courriel).Distinct().ToList();
    }
}

namespace Facturation.Emission // projet Application
{
    // Declaree par l'emission, qui enregistre les factures qu'elle produit.
    // Charger, Supprimer et l'export comptable suivent le meme chemin : chacun
    // dans l'interface declaree par les clients qui s'en servent.
    public interface IEcritureFactures
    {
        void Enregistrer(Facture facture);
    }

    public sealed class EmissionFactures(IEcritureFactures factures)
    {
        public void Emettre(Facture facture) => factures.Enregistrer(facture);
    }
}

namespace Facturation.Stockage // projet Infrastructure
{
    using Facturation.Emission;
    using Facturation.Relances;

    // L'implementation reelle reunit autant de roles qu'elle a de clients ;
    // chaque client ne voit que le sien.
    public sealed class DepotFactures : IFacturesImpayees, IEcritureFactures
    {
        private readonly Dictionary<string, Facture> _factures = [];

        public IReadOnlyList<Facture> Impayees(DateOnly auJour) =>
            _factures.Values.Where(f => !f.Payee && f.Echeance < auJour).ToList();

        public void Enregistrer(Facture facture) => _factures[facture.Numero] = facture;
    }
}

namespace Facturation.Tests // projet de tests
{
    using Facturation.Relances;

    // Le double de test n'a plus rien a remplir de vide.
    public sealed class FacturesDeTest(params Facture[] liste) : IFacturesImpayees
    {
        public IReadOnlyList<Facture> Impayees(DateOnly auJour) =>
            liste.Where(f => !f.Payee && f.Echeance < auJour).ToList();
    }

    static class DemonstrationSelection
    {
        public static void Executer()
        {
            var payee = Exemple.Facture() with { Numero = "F-2026-042", Courriel = "[email protected]", Payee = true };
            var selection = new SelectionRelances(new FacturesDeTest(Exemple.Facture(), payee));

            Console.WriteLine(string.Join(", ", selection.Destinataires(new DateOnly(2026, 9, 25))));
            // [email protected]
        }
    }
}

L'interface appartient au client : IFacturesImpayees vit à côté de la relance et dit ce qu'elle lit, IEcritureFactures à côté de l'émission et dit ce qu'elle enregistre. Le dépôt réel implémente autant de rôles qu'il a de clients, l'exemple en montre deux, et chaque client ne voit plus que le sien. L'abus est l'émiettement : une interface par méthode, taillée d'avance pour des clients imaginaires, jusqu'à ne plus savoir quel objet fait quoi. Une interface large que tous ses clients utilisent entière ne viole rien. Le IRepository<T> générique de quinze méthodes, dont chaque service ne touche que deux, est le cas qui mérite qu'on découpe.

Inversion des dépendances, qui n'est pas l'injection

Les modules de haut niveau, ceux qui décident, ne dépendent pas des modules de bas niveau, ceux qui exécutent : les deux dépendent d'abstractions, et les abstractions ne dépendent pas des détails. L'inversion porte sur le sens de la dépendance de code source. À l'exécution, la relance appelle toujours l'API de courriel ; à la compilation, c'est l'infrastructure qui référence l'application. L'injection de dépendances est autre chose : une technique qui fournit à un objet ses collaborateurs au lieu de le laisser les créer. Elle sert l'inversion et ne la garantit pas, comme le montre ce code qui injecte tout, et qui reprend IFacturesImpayees de la section précédente :

using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Facturation;

// Deux projets montres ensemble : chaque bloc namespace en est un.

namespace Facturation.Infrastructure
{
    // Un detail : l'API HTTP d'un prestataire de courriel.
    public sealed class ClientApiCourriel(HttpClient http)
    {
        public async Task Poster(string destinataire, string sujet, string corps, CancellationToken annulation)
        {
            var reponse = await http.PostAsJsonAsync("messages", new { destinataire, sujet, corps }, annulation);
            reponse.EnsureSuccessStatusCode();
        }
    }
}

namespace Facturation.Relances
{
    using Facturation.Infrastructure;

    // Injection de dependances : oui, tout arrive par le constructeur.
    // Inversion : non. La politique nomme le detail, et le projet qui la
    // contient doit referencer celui de l'infrastructure.
    public sealed class ServiceRelance(IFacturesImpayees factures, ClientApiCourriel courriel)
    {
        public async Task<int> Relancer(CancellationToken annulation = default)
        {
            var impayees = factures.Impayees(DateOnly.FromDateTime(DateTime.Today));
            foreach (var facture in impayees)
            {
                await courriel.Poster(
                    facture.Courriel,
                    $"Facture {facture.Numero} impayee",
                    $"Echeance du {facture.Echeance:yyyy-MM-dd} depassee.",
                    annulation);
            }

            return impayees.Count;
        }
    }
}

Tout arrive par le constructeur, un conteneur l'assemblerait sans peine, et la politique — qui relancer, à quelle date, avec quel message — nomme pourtant une classe HTTP. Le projet de l'application doit référencer celui de l'infrastructure, tester la relance exige un faux serveur, et DateTime.Today fait dépendre le résultat du jour où le test tourne. Extraire une interface IClientApiCourriel à côté de la classe ne changerait rien : une interface qui a la forme du détail et vit dans son projet laisse la flèche dans le même sens.

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Facturation;

// Quatre projets montres ensemble : chaque bloc namespace en est un.

namespace Facturation.Relances // projet Application : ne reference aucun detail
{
    // Declaree par la politique, dans ses mots : une relance, pas un POST.
    public interface IExpediteurRelances
    {
        Task Relancer(Facture facture, CancellationToken annulation);
    }

    public sealed class ServiceRelance(
        IFacturesImpayees factures,
        IExpediteurRelances expediteur,
        TimeProvider horloge)
    {
        public async Task<int> Relancer(CancellationToken annulation = default)
        {
            var impayees = factures.Impayees(DateOnly.FromDateTime(horloge.GetLocalNow().DateTime));
            foreach (var facture in impayees)
            {
                await expediteur.Relancer(facture, annulation);
            }

            return impayees.Count;
        }
    }
}

namespace Facturation.Infrastructure // projet Infrastructure : reference Application, pas l'inverse
{
    using Facturation.Relances;

    public sealed class ExpediteurApiCourriel(HttpClient http) : IExpediteurRelances
    {
        public async Task Relancer(Facture facture, CancellationToken annulation)
        {
            var message = new
            {
                destinataire = facture.Courriel,
                sujet = $"Facture {facture.Numero} impayee",
                corps = $"Echeance du {facture.Echeance:yyyy-MM-dd} depassee.",
            };
            var reponse = await http.PostAsJsonAsync("messages", message, annulation);
            reponse.EnsureSuccessStatusCode();
        }
    }
}

namespace Facturation.Hote // projet hote : le point d'entree, seul a connaitre tout le monde
{
    using Facturation.Infrastructure;
    using Facturation.Relances;
    using Facturation.Stockage;

    public static class Composition
    {
        // La racine de composition : l'inversion sans conteneur, des new
        // ecrits a un seul endroit.
        public static ServiceRelance Relance(DepotFactures depot, HttpClient http) =>
            new(depot, new ExpediteurApiCourriel(http), TimeProvider.System);
    }
}

namespace Facturation.Tests // projet de tests : reference Application seulement
{
    using Facturation.Relances;

    // Pour tester la politique : ni reseau, ni calendrier.
    sealed class ExpediteurEnMemoire : IExpediteurRelances
    {
        public List<string> Relancees { get; } = [];

        public Task Relancer(Facture facture, CancellationToken annulation)
        {
            Relancees.Add(facture.Numero);
            return Task.CompletedTask;
        }
    }

    sealed class HorlogeFixe(DateTimeOffset instant) : TimeProvider
    {
        public override DateTimeOffset GetUtcNow() => instant;
    }

    static class DemonstrationRelance
    {
        public static async Task Executer()
        {
            var expediteur = new ExpediteurEnMemoire();
            var horloge = new HorlogeFixe(new DateTimeOffset(2026, 9, 25, 12, 0, 0, TimeSpan.Zero));
            var service = new ServiceRelance(new FacturesDeTest(Exemple.Facture()), expediteur, horloge);

            Console.WriteLine(await service.Relancer());               // 1
            Console.WriteLine(string.Join(", ", expediteur.Relancees)); // F-2026-041
        }
    }
}

L'abstraction a changé de propriétaire. IExpediteurRelances est déclarée par la relance, dans ses mots, et l'infrastructure l'implémente en référençant l'application : la flèche s'est retournée. La racine de composition, seul endroit qui connaît tout le monde, assemble le graphe avec des new : c'est l'inversion sans conteneur. Dans une application ASP.NET Core, la même racine s'écrit par enregistrements, et la conception ne bouge pas :

using System;
using System.Threading;
using Facturation.Infrastructure;
using Facturation.Relances;
using Facturation.Stockage;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

// Program.cs d'une application ASP.NET Core : la racine de composition de
// l'exemple precedent, ecrite par enregistrements.
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<DepotFactures>();
builder.Services.AddSingleton<IFacturesImpayees>(services => services.GetRequiredService<DepotFactures>());
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHttpClient<IExpediteurRelances, ExpediteurApiCourriel>(client =>
    client.BaseAddress = new Uri("https://courriel.example/api/"));
builder.Services.AddScoped<ServiceRelance>();

var app = builder.Build();

app.MapPost("/relances", async (ServiceRelance relance, CancellationToken annulation) =>
    new { relancees = await relance.Relancer(annulation) });

app.Run();

Le conteneur automatise l'assemblage ; il ne décide pas à qui appartient l'abstraction, et il aurait injecté le ClientApiCourriel concret tout aussi volontiers. Le cours sur le DDD tactique applique le même principe à l'interface du repository, déclarée dans le domaine.

Le principe ne demande pas une interface par classe. Le couple IFactureService et FactureService, l'une recopiant l'autre, n'inverse rien : l'interface épouse le détail au lieu d'exprimer un besoin. Dépendre de ce qui est stable reste sain : string, List<T>, ou TimeProvider, abstraction que .NET fournit depuis la version 8 et que la relance utilise telle quelle. L'inversion paie à la frontière des détails qui changent ou qui gênent les tests — réseau, base, fichiers, horloge, prestataires. À l'intérieur du cœur métier, une classe concrète qui en appelle une autre n'a rien à inverser.

Ce cours vous a servi ? Offrir un café Signaler une erreur