Architecture & conception

Patrons GoF au quotidien

Strategy, decorator, factory, adapter, et ceux qu'on n'utilise jamais.

Vérifié en septembre 2026 · .NET 10, et .NET 11 RC 1 pour C# 15 · environ 17 min

En octobre 1994, Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides publient chez Addison-Wesley « Design Patterns: Elements of Reusable Object-Oriented Software », qu'on désigne depuis par le surnom de ses auteurs, la bande des quatre (Gang of Four, d'où GoF). Le livre catalogue vingt-trois patrons en trois familles, création, structure et comportement, et donne à chacun un nom, une intention, une structure et des conséquences. Son apport durable est le vocabulaire : dire « un décorateur » en revue de code épargne une demi-page. Ses exemples sont écrits en C++ et en Smalltalk, et une partie des patrons compensait ce que C++ n'offrait pas : Peter Norvig, dans un exposé de 1996, en comptait seize sur vingt-trois simplifiés ou effacés par les fonctionnalités de Lisp ou de Dylan. C# et .NET en ont absorbé une bonne part. Ce cours garde les quatre qu'on écrit encore chaque semaine, puis passe en revue ceux que le langage ou le framework écrivent à notre place, et dit à chaque fois si le patron paie. Les intentions citées sont traduites du livre ; les exemples tournent sur .NET 10.

Strategy : des algorithmes interchangeables

L'intention : définir une famille d'algorithmes, encapsuler chacun et les rendre interchangeables, pour que l'algorithme varie indépendamment des clients qui l'utilisent. C'est l'application directe des deux principes que pose le premier chapitre du livre : programmer pour une interface, pas pour une implémentation, et préférer la composition d'objets à l'héritage de classe. En C#, la stratégie prend deux formes. Une interface quand la variante a un nom, plusieurs membres ou un état ; un délégué quand elle se résume à une signature, et une méthode existante comme Math.Floor fait alors l'affaire sans rien déclarer.

using System;
using System.Globalization;

// Culture invariante : l'affichage est le meme sur toutes les machines.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// Le client choisit la variante ; Panier ne la connait pas.
var standard = new Panier(new FraisStandard());
var express = new Panier(new FraisExpress());
Console.WriteLine(standard.Total(montantHT: 42.00m, poidsKg: 2m)); // 49.30
Console.WriteLine(standard.Total(montantHT: 80.00m, poidsKg: 2m)); // 80.00
Console.WriteLine(express.Total(montantHT: 80.00m, poidsKg: 2m));  // 96.50

// Une strategie sans etat ni nom a porter : un delegue suffit.
var dixPourCent = new Remise(montant => montant * 0.90m);
var arrondi = new Remise(Math.Floor); // un groupe de methodes existant fait l'affaire
Console.WriteLine(dixPourCent.Appliquer(49.30m)); // 44.3700
Console.WriteLine(arrondi.Appliquer(49.30m));     // 49

// La strategie en interface : un contrat nomme, une classe par variante.
public interface ICalculFraisPort
{
    decimal Calculer(decimal montantHT, decimal poidsKg);
}

public sealed class FraisStandard : ICalculFraisPort
{
    public decimal Calculer(decimal montantHT, decimal poidsKg) =>
        montantHT >= 50m ? 0m : 4.90m + poidsKg * 1.20m;
}

public sealed class FraisExpress : ICalculFraisPort
{
    public decimal Calculer(decimal montantHT, decimal poidsKg) => 12.50m + poidsKg * 2m;
}

public sealed class Panier(ICalculFraisPort frais)
{
    public decimal Total(decimal montantHT, decimal poidsKg) =>
        montantHT + frais.Calculer(montantHT, poidsKg);
}

public sealed class Remise(Func<decimal, decimal> regle)
{
    public decimal Appliquer(decimal montant) => regle(montant);
}

Panier ne teste aucun mode de livraison : une variante de plus est une classe de plus, et c'est le principe ouvert-fermé du cours SOLID en acte. Le délégué retire la cérémonie, mais aussi le nom : dans une pile d'appels ou un conteneur, un Func<decimal, decimal> ne dit pas ce qu'il calcule. Et une stratégie qui n'a qu'une implémentation, sans autre en vue, n'est qu'une indirection : c'est l'abstraction spéculative que le même cours déconseille.

Avec l'injection de dépendances, la variante se choisit à l'un de deux moments. Fixée à la construction du consommateur, elle passe par une clé : depuis .NET 8, AddKeyedSingleton et [FromKeyedServices] désignent l'implémentation voulue, et le cours Injection de dépendances en détaille les règles. Connue seulement à l'exécution, d'après une donnée de la commande, elle passe par l'ensemble des variantes, indexé une fois.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var services = new ServiceCollection();

// Choix fixe par consommateur : une cle par variante (.NET 8 et suivants).
services.AddKeyedSingleton<ICalculFraisPort, FraisStandard>(ModeLivraison.Standard);
services.AddKeyedSingleton<ICalculFraisPort, FraisExpress>(ModeLivraison.Express);
services.AddSingleton<ReexpeditionSav>();

// Choix a l'execution, d'apres une donnee : toutes les variantes.
// Un service enregistre avec cle n'apparait pas dans IEnumerable<T> ;
// GetKeyedServices avec KeyedService.AnyKey rend toutes celles enregistrees
// avec une cle precise, et ce sont les memes instances que ci-dessus.
services.AddSingleton(sp =>
    new SelecteurFrais(sp.GetKeyedServices<ICalculFraisPort>(KeyedService.AnyKey)));

using var fournisseur = services.BuildServiceProvider();

var sav = fournisseur.GetRequiredService<ReexpeditionSav>();
Console.WriteLine(sav.Frais(poidsKg: 1m)); // 14.50

var selecteur = fournisseur.GetRequiredService<SelecteurFrais>();
Console.WriteLine(selecteur.Pour(ModeLivraison.Standard).Calculer(30m, 1m)); // 6.10
Console.WriteLine(selecteur.Pour(ModeLivraison.Express).Calculer(30m, 1m));  // 14.50

public enum ModeLivraison { Standard, Express }

public interface ICalculFraisPort
{
    ModeLivraison Mode { get; }
    decimal Calculer(decimal montantHT, decimal poidsKg);
}

public sealed class FraisStandard : ICalculFraisPort
{
    public ModeLivraison Mode => ModeLivraison.Standard;
    public decimal Calculer(decimal montantHT, decimal poidsKg) =>
        montantHT >= 50m ? 0m : 4.90m + poidsKg * 1.20m;
}

public sealed class FraisExpress : ICalculFraisPort
{
    public ModeLivraison Mode => ModeLivraison.Express;
    public decimal Calculer(decimal montantHT, decimal poidsKg) => 12.50m + poidsKg * 2m;
}

// Le service apres-vente reexpedie toujours en express : la cle le dit a la construction.
public sealed class ReexpeditionSav(
    [FromKeyedServices(ModeLivraison.Express)] ICalculFraisPort frais)
{
    public decimal Frais(decimal poidsKg) => frais.Calculer(0m, poidsKg);
}

// Le mode vient de la commande : un dictionnaire indexe les strategies par leur mode.
public sealed class SelecteurFrais(IEnumerable<ICalculFraisPort> strategies)
{
    private readonly Dictionary<ModeLivraison, ICalculFraisPort> _parMode =
        strategies.ToDictionary(s => s.Mode);

    public ICalculFraisPort Pour(ModeLivraison mode) => _parMode[mode];
}

Chaque stratégie annonce son mode, et le sélecteur n'en nomme aucune : une troisième s'enregistre sans le toucher. Un service enregistré avec clé n'apparaît pas dans IEnumerable<T> ; GetKeyedServices avec KeyedService.AnyKey, appelé dans la fabrique du sélecteur, les rend toutes, et ce sont les instances qu'on obtient par leur clé.

Decorator : ajouter autour, sans toucher

L'intention : attacher dynamiquement des responsabilités supplémentaires à un objet, le décorateur offrant une alternative souple à la dérivation pour étendre une fonctionnalité. Il implémente le même contrat que l'objet qu'il enveloppe, reçoit cet objet et lui délègue, avant ou après son propre travail. Le compteur qui enveloppe un panier, dans la section « Composition plutôt qu'héritage » du cours Programmation orientée objet, en était déjà un. Le conteneur de Microsoft.Extensions.DependencyInjection n'a pas de méthode pour décorer : on enregistre le composant sous son type concret, et l'interface par une fabrique qui assemble la pile.

using System;
using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var services = new ServiceCollection();

// Le composant decore s'enregistre sous son type concret...
services.AddSingleton<CatalogueSql>();
// ... et l'interface pointe sur la pile, assemblee a la main de l'interieur vers l'exterieur.
services.AddSingleton<ICatalogue>(sp =>
    new CatalogueJournalise(
        new CatalogueEnCache(
            sp.GetRequiredService<CatalogueSql>())));

using var fournisseur = services.BuildServiceProvider();
var catalogue = fournisseur.GetRequiredService<ICatalogue>();

Console.WriteLine(catalogue.Prix("A12"));
Console.WriteLine(catalogue.Prix("A12"));
// journal : Prix(A12)
// base : lecture de A12
// 19.90
// journal : Prix(A12)
// 19.90

public interface ICatalogue
{
    decimal Prix(string reference);
}

public sealed class CatalogueSql : ICatalogue
{
    public decimal Prix(string reference)
    {
        Console.WriteLine($"base : lecture de {reference}");
        return 19.90m;
    }
}

// Chaque decorateur implemente le contrat, recoit le suivant et lui delegue.
public sealed class CatalogueEnCache(ICatalogue suivant) : ICatalogue
{
    private readonly ConcurrentDictionary<string, decimal> _prix = new();

    public decimal Prix(string reference) => _prix.GetOrAdd(reference, suivant.Prix);
}

public sealed class CatalogueJournalise(ICatalogue suivant) : ICatalogue
{
    public decimal Prix(string reference)
    {
        Console.WriteLine($"journal : Prix({reference})");
        return suivant.Prix(reference);
    }
}

L'ordre de la pile est une décision. Le journal, à l'extérieur, voit chaque appel ; placé sous le cache, il ne verrait que ceux qui atteignent la base, ce qui est peut-être ce qu'on veut mesurer. Cette écriture à la main a trois angles morts. L'interface ne doit pas vivre plus longtemps que le composant décoré : si CatalogueSql était scoped, la fabrique singleton le capturerait, et c'est la dépendance captive du cours Injection de dépendances ; ValidateScopes, actif en Development avec les hôtes de .NET, la refuse à la première résolution par « Cannot resolve scoped service 'CatalogueSql' from root provider ». ValidateOnBuild, lui, ne voit pas à travers une fabrique, comme le rappelle le même cours. Et CatalogueSql reste résoluble seul : qui le demande par son type contourne la pile. La bibliothèque Scrutor fournit une méthode Decorate qui fait cet assemblage ; nuget.org la donne sous licence MIT, en version 7.0.0 du 24 novembre 2025, et elle n'est pas utilisée ici. Le framework, lui, en est plein : BufferedStream ajoute un tampon à un autre flux, et chaque DelegatingHandler d'un HttpClient enveloppe le gestionnaire suivant.

Factory : trois fabriques, dont une qu'on a déjà

Le livre décrit deux patrons. La méthode de fabrique définit une interface pour créer un objet, mais laisse les sous-classes décider de la classe à instancier. La fabrique abstraite fournit une interface pour créer des familles d'objets liés ou interdépendants sans spécifier leurs classes concrètes. Ni l'une ni l'autre n'est une méthode statique de création : TimeSpan.FromSeconds est utile, mais aucune sous-classe n'y décide rien. ADO.NET réunit les deux vraies.

// Paquet Microsoft.Data.Sqlite 10.0.12
// (Microsoft.Data.Sqlite.Core et SQLitePCLRaw.bundle_e_sqlite3).
using System;
using System.Data.Common;
using Microsoft.Data.Sqlite;

// La racine de composition choisit la famille, une fois, par son nom.
DbProviderFactories.RegisterFactory("sqlite", SqliteFactory.Instance);

// Fabrique abstraite : une interface qui cree toute une famille d'objets lies.
// Le code qui suit ne nomme plus aucune classe concrete de SQLite.
DbProviderFactory fabrique = DbProviderFactories.GetFactory("sqlite");

using DbConnection connexion = fabrique.CreateConnection()!;
connexion.ConnectionString = "Data Source=:memory:";
connexion.Open();

// Methode de fabrique : CreateCommand, publique et non virtuelle, appelle
// CreateDbCommand, protegee et abstraite, que chaque fournisseur redefinit.
using DbCommand commande = connexion.CreateCommand();
commande.CommandText = "SELECT $a + $b";

DbParameter a = fabrique.CreateParameter()!;
a.ParameterName = "$a";
a.Value = 2;
DbParameter b = commande.CreateParameter();
b.ParameterName = "$b";
b.Value = 3;
commande.Parameters.Add(a);
commande.Parameters.Add(b);

Console.WriteLine($"{connexion.GetType().Name}, {commande.GetType().Name}, {a.GetType().Name}");
Console.WriteLine(commande.ExecuteScalar());
// SqliteConnection, SqliteCommand, SqliteParameter
// 5

DbConnection.CreateCommand est la méthode de fabrique : elle délègue à CreateDbCommand, abstraite, que SqliteConnection redéfinit pour rendre une SqliteCommand. Le patron paie ici parce que la hiérarchie existait déjà, chaque fournisseur dérivant de toute façon de DbConnection ; bâtir une hiérarchie pour lui seul revient à écrire en héritage ce qu'une stratégie passée au constructeur ferait par composition. DbProviderFactory est la fabrique abstraite : connexion, commande et paramètre sortent de la même famille, et la choisir par son nom change de fournisseur sans toucher au code qui crée ces objets, à défaut du SQL qu'il leur passe. Dans une application, la famille se choisit le plus souvent une fois, au démarrage, et une méthode d'extension qui enregistre ensemble les services d'un fournisseur tient ce rôle sans type de plus.

Reste la fabrique qu'on écrit par réflexe, alors que le conteneur en est déjà une. Celle-ci ne choisit rien : elle construit toujours la même classe, à la main, et prend au conteneur la libération de ce qu'elle crée, sans l'assurer.

using System;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<IFabriqueExpediteur, FabriqueExpediteur>();
services.AddScoped<Relance>();

using var fournisseur = services.BuildServiceProvider();
using (var portee = fournisseur.CreateScope())
{
    var relance = portee.ServiceProvider.GetRequiredService<Relance>();
    relance.Envoyer("[email protected]");
    relance.Envoyer("[email protected]");
}
Console.WriteLine("fin de la portee");
// connexion SMTP 1 ouverte vers smtp.exemple.test
// [email protected] : Relance de facture
// connexion SMTP 2 ouverte vers smtp.exemple.test
// [email protected] : Relance de facture
// fin de la portee

public interface IExpediteur
{
    void Envoyer(string destinataire, string objet);
}

// Ouvre une connexion a sa creation ; seul Dispose la ferme.
public sealed class ClientSmtp : IExpediteur, IDisposable
{
    private static int _compteur;
    private readonly int _numero = ++_compteur;

    public ClientSmtp(string hote) =>
        Console.WriteLine($"connexion SMTP {_numero} ouverte vers {hote}");

    public void Envoyer(string destinataire, string objet) =>
        Console.WriteLine($"{destinataire} : {objet}");

    public void Dispose() => Console.WriteLine($"connexion SMTP {_numero} fermee");
}

// Une fabrique qui ne choisit rien : toujours la meme classe, construite a la main.
public interface IFabriqueExpediteur
{
    IExpediteur Creer();
}

public sealed class FabriqueExpediteur : IFabriqueExpediteur
{
    public IExpediteur Creer() => new ClientSmtp("smtp.exemple.test");
}

public sealed class Relance(IFabriqueExpediteur fabrique)
{
    public void Envoyer(string destinataire) =>
        fabrique.Creer().Envoyer(destinataire, "Relance de facture");
}
using System;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
// Le conteneur est deja la fabrique : la lambda fournit ce qui n'est pas un service,
// le nom d'hote, et le conteneur garde la construction, la duree de vie et la liberation.
services.AddScoped<IExpediteur>(_ => new ClientSmtp("smtp.exemple.test"));
services.AddScoped<Relance>();

using var fournisseur = services.BuildServiceProvider();
using (var portee = fournisseur.CreateScope())
{
    var relance = portee.ServiceProvider.GetRequiredService<Relance>();
    relance.Envoyer("[email protected]");
    relance.Envoyer("[email protected]");
}
Console.WriteLine("fin de la portee");
// connexion SMTP 1 ouverte vers smtp.exemple.test
// [email protected] : Relance de facture
// [email protected] : Relance de facture
// connexion SMTP 1 fermee
// fin de la portee

public interface IExpediteur
{
    void Envoyer(string destinataire, string objet);
}

// Ouvre une connexion a sa creation ; seul Dispose la ferme.
public sealed class ClientSmtp : IExpediteur, IDisposable
{
    private static int _compteur;
    private readonly int _numero = ++_compteur;

    public ClientSmtp(string hote) =>
        Console.WriteLine($"connexion SMTP {_numero} ouverte vers {hote}");

    public void Envoyer(string destinataire, string objet) =>
        Console.WriteLine($"{destinataire} : {objet}");

    public void Dispose() => Console.WriteLine($"connexion SMTP {_numero} fermee");
}

// Relance recoit ce dont elle se sert, et ne sait plus comment on le construit.
public sealed class Relance(IExpediteur expediteur)
{
    public void Envoyer(string destinataire) =>
        expediteur.Envoyer(destinataire, "Relance de facture");
}

Dans la première version, deux relances ont ouvert deux connexions et aucune n'a été fermée : le conteneur ignore l'objet, et Relance ne sait pas qu'il est jetable, puisque IExpediteur ne le dit pas. La seconde confie tout au conteneur : la lambda d'enregistrement est la fabrique, et ne sert qu'à passer ce qui n'est pas un service, ici le nom d'hôte. Une fabrique écrite à la main se justifie quand elle décide vraiment : un paramètre connu seulement à l'appel, que ActivatorUtilities.CreateInstance combine avec les services du conteneur, ou un objet neuf par message traité. Le conteneur de .NET ne fournit pas Func<T> de lui-même, contrairement à Autofac : sa documentation range cette injection paresseuse parmi ce qu'il n'offre pas.

Adapter : brancher ce qu'on ne peut pas modifier

L'intention : convertir l'interface d'une classe en une autre, celle qu'attendent les clients, pour faire travailler ensemble des classes que des interfaces incompatibles en empêchaient. Le cas typique est le SDK d'un prestataire, avec ses mots, ses formats et ses codes de retour, qu'on ne modifie pas. L'application déclare l'interface dont elle a besoin, dans ses termes, et une classe traduit.

using System;

INotificateur notificateur = new NotificateurSms(new PasserelleSmsV2());
var client = new Client("Mme Martin", "06 12 34 56 78");

notificateur.Notifier(client, "Votre commande est prete.");
try
{
    notificateur.Notifier(client, new string('x', 200));
}
catch (InvalidOperationException e)
{
    Console.WriteLine(e.Message);
}
// SMS +33612345678 : Votre commande est prete.
// Envoi refuse par la passerelle (code 413).

// L'interface que l'application attend, dans ses mots.
public interface INotificateur
{
    void Notifier(Client client, string message);
}

public sealed record Client(string Nom, string Telephone);

// La classe du prestataire, qu'on ne modifie pas : ses mots, ses codes de retour.
public sealed class PasserelleSmsV2
{
    public int Transmettre(string numeroE164, string corps, bool prioritaire)
    {
        if (corps.Length > 160)
            return 413; // refuse : rien n'est envoye
        Console.WriteLine($"SMS {numeroE164} : {corps}");
        return 0;
    }
}

// L'adaptateur implemente l'interface attendue et traduit vers celle qui existe.
public sealed class NotificateurSms(PasserelleSmsV2 passerelle) : INotificateur
{
    public void Notifier(Client client, string message)
    {
        string numero = "+33" + client.Telephone.Replace(" ", "").TrimStart('0');
        int code = passerelle.Transmettre(numero, message, prioritaire: false);
        if (code != 0)
            throw new InvalidOperationException($"Envoi refuse par la passerelle (code {code}).");
    }
}

Le numéro passe au format international, le code 413 devient une exception, la priorité disparaît : ce que l'application ignore du prestataire tient dans cette classe, et un second prestataire sera un second adaptateur. Le livre décrit deux variantes. L'adaptateur de classe hérite de la classe adaptée, ce que l'héritage simple de C# interdit dès que la cible est elle-même une classe, et qui expose en prime toutes ses méthodes publiques ; l'adaptateur d'objet, celui de l'exemple, la compose. Trois patrons se ressemblent ici et se distinguent par ce qu'ils font de l'interface : l'adaptateur la change, le décorateur la garde et ajoute un comportement, la façade en offre une plus simple devant tout un sous-système. À l'échelle d'une architecture, la même idée donne la couche anticorruption du cours DDD stratégique, qui traduit en plus d'un modèle à l'autre, et les adaptateurs du cours Couches, oignon, hexagonale, Clean.

Absorbés par le langage : Iterator, Visitor, Command

Iterator

L'intention : accéder séquentiellement aux éléments d'un agrégat sans exposer sa représentation. IEnumerable et foreach en sont la forme standard depuis la première version de C#, et yield return, arrivé avec C# 2 en 2005, laisse le compilateur écrire l'itérateur.

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

var rayon = new Categorie("Informatique",
[
    new Categorie("Peripheriques", [new Categorie("Claviers", []), new Categorie("Souris", [])]),
    new Categorie("Ecrans", []),
]);

foreach (string nom in rayon.Parcourir())
    Console.WriteLine(nom);
// Informatique
//   Peripheriques
//     Claviers
//     Souris
//   Ecrans

// Le parcours est paresseux : Take(2) arrete la visite apres deux elements.
Console.WriteLine(string.Join(", ", rayon.Parcourir().Take(2).Select(n => n.Trim())));
// Informatique, Peripheriques

public sealed record Categorie(string Nom, IReadOnlyList<Categorie> Enfants)
{
    // yield return : le compilateur ecrit l'iterateur, une machine a etats
    // qui implemente IEnumerable<string> et IEnumerator<string>.
    public IEnumerable<string> Parcourir(string retrait = "")
    {
        yield return retrait + Nom;
        foreach (Categorie enfant in Enfants)
            foreach (string nom in enfant.Parcourir(retrait + "  "))
                yield return nom;
    }
}

Le compilateur génère une classe qui implémente IEnumerable<string> et IEnumerator<string>, et découpe la méthode en machine à états : chaque yield return suspend le parcours, que l'appel suivant à MoveNext reprend. L'interface du livre, First, Next, IsDone et CurrentItem, n'a plus de raison d'être écrite. Le parcours est paresseux, avec les conséquences que le cours Collections et LINQ décrit pour l'exécution différée. Un arbre profond a un coût à connaître : chaque niveau ajoute un itérateur imbriqué, que chaque élément traverse en remontant.

Visitor

L'intention : représenter une opération à effectuer sur les éléments d'une structure d'objets, et définir une nouvelle opération sans changer les classes de ces éléments. Le livre demande pour cela une méthode Accept dans chaque élément et une méthode Visit par type dans chaque visiteur. Le filtrage par motif de C# obtient le même résultat sans rien ajouter aux éléments.

using System;
using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// Prix HT de 40, remise de 10 %, plus 5 de port.
Expr prix = new Somme(new Produit(new Nombre(40m), new Nombre(0.9m)), new Nombre(5m));

Console.WriteLine(Operations.Afficher(prix)); // ((40 * 0.9) + 5)
Console.WriteLine(Operations.Evaluer(prix));  // 41.0

// Les elements : des donnees, sans methode Accept ni connaissance des operations.
public abstract record Expr;
public sealed record Nombre(decimal Valeur) : Expr;
public sealed record Somme(Expr Gauche, Expr Droite) : Expr;
public sealed record Produit(Expr Gauche, Expr Droite) : Expr;

// Chaque operation est une fonction de plus, ecrite hors des elements.
public static class Operations
{
    public static decimal Evaluer(Expr e) => e switch
    {
        Nombre n => n.Valeur,
        Somme(var g, var d) => Evaluer(g) + Evaluer(d),
        Produit(var g, var d) => Evaluer(g) * Evaluer(d),
        // Expr est ouverte : le compilateur ne peut pas savoir que la liste est complete.
        _ => throw new ArgumentOutOfRangeException(nameof(e), e, "Expression inconnue"),
    };

    public static string Afficher(Expr e) => e switch
    {
        Nombre n => n.Valeur.ToString(),
        Somme(var g, var d) => $"({Afficher(g)} + {Afficher(d)})",
        Produit(var g, var d) => $"({Afficher(g)} * {Afficher(d)})",
        _ => throw new ArgumentOutOfRangeException(nameof(e), e, "Expression inconnue"),
    };
}

Une opération de plus est une fonction de plus, et les records restent des données. Le visiteur classique garde un avantage, le principal argument en sa faveur : un type d'élément ajouté ajoute une méthode abstraite au visiteur, et chaque visiteur cesse de compiler tant qu'il ne la traite pas. Ici, la hiérarchie ouverte impose le bras _, et un type Difference ajouté compilerait pour lever à l'exécution ; sans ce bras, l'avertissement CS8509 marque chaque switch, même complet. C# 15, qui sortira avec .NET 11 (release candidate depuis le 8 septembre 2026, sortie annoncée le 10 novembre 2026), réduit ce trou : une classe marquée closed n'a de descendants directs que dans son assembly, la restriction ne portant pas plus bas (cours Nouveautés, de C# 8 à C# 15). Avec le SDK 11.0.100-rc.1.26425.128 et la cible net11.0, l'exemple où closed remplace abstract devant Expr, privé de ses deux bras _, compile sans avertissement et affiche la même sortie ; un Difference ajouté fait alors apparaître CS8509 sur chaque switch : un avertissement, pas une erreur, sauf si le projet traite les avertissements en erreurs. Le SDK 10.0.300 ne reconnaît pas ce mot-clé. Le framework garde des visiteurs classiques là où les opérations sont des réécritures d'arbre, comme ExpressionVisitor pour les arbres d'expression de LINQ.

Command

L'intention : encapsuler une requête dans un objet, pour paramétrer des clients avec différentes requêtes, les mettre en file ou les journaliser, et annuler des opérations. Pour paramétrer, différer ou mettre en file, un délégué suffit : Action est une commande sans cérémonie. L'objet redevient utile quand la commande porte plus qu'un appel, à commencer par son annulation.

using System;
using System.Collections.Generic;

// Differer ou mettre en file une action : un delegue est deja une commande.
var aFaire = new Queue<Action>();
aFaire.Enqueue(() => Console.WriteLine("envoyer la facture F-12"));
aFaire.Enqueue(() => Console.WriteLine("archiver la facture F-12"));
while (aFaire.TryDequeue(out Action? action))
    action();
// envoyer la facture F-12
// archiver la facture F-12

// Annuler demande plus qu'une methode : un objet qui sait se defaire.
var panier = new List<string>();
var historique = new Historique();
historique.Executer(new AjouterArticle(panier, "clavier"));
historique.Executer(new AjouterArticle(panier, "souris"));
historique.Annuler();
Console.WriteLine(string.Join(", ", panier)); // clavier

public interface ICommande
{
    void Executer();
    void Annuler();
}

public sealed record AjouterArticle(List<string> Panier, string Article) : ICommande
{
    public void Executer() => Panier.Add(Article);
    public void Annuler() => Panier.Remove(Article);
}

public sealed class Historique
{
    private readonly Stack<ICommande> _faites = new();

    public void Executer(ICommande commande)
    {
        commande.Executer();
        _faites.Push(commande);
    }

    public void Annuler()
    {
        if (_faites.TryPop(out ICommande? derniere))
            derniere.Annuler();
    }
}

Le délégué ne sait que s'exécuter ; l'objet sait aussi se défaire, s'afficher, se sérialiser pour une file durable. Le cours CQRS et Event Sourcing emploie le même mot pour des records qui portent une intention de changement jusqu'à leur gestionnaire : la parenté tient à l'objet qui voyage, pas à l'annulation.

Absorbés par le framework : Singleton, Observer, et les autres

Singleton

L'intention : garantir qu'une classe n'a qu'une instance, et fournir un point d'accès global à celle-ci. La première moitié est un besoin réel ; la seconde est le problème. Ce singleton est correct au sens du livre, sûr entre threads grâce à Lazy<T>, et il fait échouer un test pour une raison qui n'a rien à voir avec ce test.

using System;
using System.Collections.Concurrent;

// Deux tests independants, executes l'un apres l'autre dans le meme processus.
TestCommandeJournalisee();
TestJournalNeuf();
// test 1 : 1 entree(s), attendu 1
// test 2 : 1 entree(s), attendu 0

static void TestCommandeJournalisee()
{
    new ServiceCommande().Commander("clavier");
    Console.WriteLine($"test 1 : {JournalAudit.Instance.Entrees.Count} entree(s), attendu 1");
}

static void TestJournalNeuf()
{
    Console.WriteLine($"test 2 : {JournalAudit.Instance.Entrees.Count} entree(s), attendu 0");
}

// Le singleton du GoF : une seule instance, et un point d'acces global.
public sealed class JournalAudit
{
    private static readonly Lazy<JournalAudit> _instance = new(() => new JournalAudit());

    private JournalAudit() { }

    public static JournalAudit Instance => _instance.Value;

    public ConcurrentQueue<string> Entrees { get; } = new();
}

// Le constructeur ne dit rien de la dependance : elle est lue au milieu du code.
public sealed class ServiceCommande
{
    public void Commander(string article) =>
        JournalAudit.Instance.Entrees.Enqueue($"commande {article}");
}
using System;
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;

// L'application : une seule instance, parce que le conteneur n'en construit qu'une.
var services = new ServiceCollection();
services.AddSingleton<JournalAudit>();
services.AddTransient<ServiceCommande>();
using (var fournisseur = services.BuildServiceProvider())
{
    fournisseur.GetRequiredService<ServiceCommande>().Commander("clavier");
    fournisseur.GetRequiredService<ServiceCommande>().Commander("souris");
    var journal = fournisseur.GetRequiredService<JournalAudit>();
    Console.WriteLine($"application : {journal.Entrees.Count} entree(s)");
}
// application : 2 entree(s)

// Les tests : chacun construit le sien.
TestCommandeJournalisee();
TestJournalNeuf();
// test 1 : 1 entree(s), attendu 1
// test 2 : 0 entree(s), attendu 0

static void TestCommandeJournalisee()
{
    var journal = new JournalAudit();
    new ServiceCommande(journal).Commander("clavier");
    Console.WriteLine($"test 1 : {journal.Entrees.Count} entree(s), attendu 1");
}

static void TestJournalNeuf()
{
    var journal = new JournalAudit();
    Console.WriteLine($"test 2 : {journal.Entrees.Count} entree(s), attendu 0");
}

// Une classe ordinaire : l'unicite est une decision d'enregistrement, pas de conception.
public sealed class JournalAudit
{
    public ConcurrentQueue<string> Entrees { get; } = new();
}

// La dependance est dans le constructeur, visible et remplacable.
public sealed class ServiceCommande(JournalAudit journal)
{
    public void Commander(string article) => journal.Entrees.Enqueue($"commande {article}");
}

Le second test voit l'entrée du premier : l'instance vit autant que le processus, et chaque test hérite de l'état laissé par ceux qui ont tourné avant lui. ServiceCommande ne dit rien de sa dépendance, qu'on ne peut ni voir sans lire son code ni remplacer. AddSingleton garde l'unicité et retire l'accès global : une instance par conteneur, donc par application, et chaque test construit la sienne avec new. La documentation de .NET garantit que la fabrique d'un singleton n'est appelée qu'une fois, par un seul thread ; l'exemple enregistre un type, dont le conteneur sert ensuite toujours la même instance. Ses méthodes, elles, restent à protéger, d'où la ConcurrentQueue. L'instance statique garde un usage : un objet sans état ni dépendance, comme StringComparer.Ordinal, qu'aucun test n'a besoin de remplacer.

Observer

L'intention : définir entre des objets une dépendance d'un à plusieurs, pour que tous ceux qui dépendent d'un objet soient avertis et mis à jour automatiquement quand il change d'état. En C#, c'est le mot-clé event : le sujet déclare l'event, les observateurs s'abonnent par += et se retirent par -=.

using System;

var stock = new Stock("A12", quantite: 5);

// Les observateurs s'abonnent ; le sujet ne connait que la signature de l'event.
EventHandler<StockBas> alerteAchats = (_, e) =>
    Console.WriteLine($"achats : reapprovisionner {e.Reference}");
stock.SeuilAtteint += alerteAchats;
stock.SeuilAtteint += (_, e) =>
    Console.WriteLine($"vitrine : {e.Reference} bientot epuise ({e.Restant})");

stock.Retirer(2); // 3 restants : personne n'est prevenu
stock.Retirer(1); // 2 restants : seuil atteint
stock.SeuilAtteint -= alerteAchats;
stock.Retirer(1);
// achats : reapprovisionner A12
// vitrine : A12 bientot epuise (2)
// vitrine : A12 bientot epuise (1)

public sealed record StockBas(string Reference, int Restant);

// Le sujet : il notifie ses observateurs quand son etat franchit le seuil.
public sealed class Stock(string reference, int quantite)
{
    private const int Seuil = 2;
    private int _quantite = quantite;

    public event EventHandler<StockBas>? SeuilAtteint;

    public void Retirer(int nombre)
    {
        _quantite -= nombre;
        if (_quantite <= Seuil)
            SeuilAtteint?.Invoke(this, new StockBas(reference, _quantite));
    }
}

Les pièges de l'event, le désabonnement oublié qui retient l'observateur et le déclenchement depuis plusieurs threads, sont traités par le cours Generics, delegates et events. .NET fournit aussi IObservable<T> et IObserver<T>, que leur documentation présente comme le mécanisme général de notification poussée, autrement dit le patron observateur, avec une fin de flux et une erreur en plus. Elle invite à envisager d'autres outils avant de les implémenter : un event pour une notification simple dans une application, Rx.NET, le paquet System.Reactive, pour composer, filtrer et transformer des flux. RxJS, qu'emploie Angular, est de la même famille ReactiveX.

Les autres

Le reste du catalogue se rencontre surtout déjà écrit, et presque jamais sous son nom.

PatronOù on le rencontre en .NETAvis
BuilderWebApplicationBuilder, HostApplicationBuilder, StringBuilder : des builders au sens large ; le Builder du livre, avec son directeur, est plus rare utilisé tous les jours, rarement écrit
Composite l'arbre Categorie de l'exemple Iterator, où une feuille est une catégorie sans enfants ; CompositeFileProvider, qui présente plusieurs fournisseurs de fichiers comme un seul naturel dès qu'il y a un arbre
Mediator MediatR, qui en tire son nom et achemine chaque requête vers son gestionnaire (cours CQRS et Event Sourcing, licence comprise) à connaître, sans obligation
State une classe par état quand chaque état porte beaucoup de comportement ; sinon un switch sur un enumselon le nombre d'états et de comportements
Template Method une base conçue pour la dérivation, avec des méthodes protégées virtuelles (cours Programmation orientée objet) à connaître
Chain of Responsibility le pipeline de middlewares d'ASP.NET Core : chacun décide de passer ou non la requête au suivant utilisé sans l'écrire
Proxyles proxys de chargement différé d'EF Core, DispatchProxyrarement à écrire
Prototypewith sur un record ; ICloneable, que sa documentation déconseille dans une API publique with suffit
Flyweightl'internement des chaînes littérales, string.Internrarement utile à la main
Facadeune classe de service qui cache un sous-système derrière quelques méthodescourant, rarement nommé
Bridge, Memento, Interpretercas précis : une bibliothèque graphique, un éditeur, un langage dédiépresque jamais dans une application de gestion
Ce cours vous a servi ? Offrir un café Signaler une erreur