Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1323 minIntermédiaire

Design patterns : Repository, Stratégie, Fabrique, Singleton

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • isoler l'accès aux données avec un Repository ;
  • rendre un algorithme interchangeable avec une Stratégie ;
  • centraliser la création d'objets avec une Fabrique ;
  • expliquer le Singleton et ses limites ;
  • réagir à un événement avec l'Observateur.

4 exercices corrigés1 schéma

Un patron de conception (design pattern) est une solution éprouvée et nommée à un problème de conception qui revient souvent : comment changer d'algorithme sans toucher au code qui l'utilise, comment isoler l'accès aux données, comment centraliser la création d'objets. Les patrons ont été popularisés en 1994 par le livre de quatre auteurs surnommés le « Gang of Four », qui en décrit 23. Ce chapitre présente, en C# et sur le cas GestParc, ceux que tu utiliseras dans tes réalisations : Repository, Stratégie, Fabrique, Singleton et Observateur.

Ce qu'est (et n'est pas) un patron de conception

Un patron n'est pas une bibliothèque à installer ni un bout de code à copier. C'est une idée de structure : quelles classes et interfaces créer, et comment elles collaborent. Son nom est un vocabulaire commun : dire « l'échéance est calculée par une stratégie » évite trois paragraphes d'explication à l'oral de l'E6 ou dans une revue de code.

FamilleProblème traitéPatrons du chapitre
Créationcomment créer les objetsFabrique, Singleton
Structurecomment assembler classes et objets(Adaptateur, Façade, Décorateur… non traités ici)
Comportementcomment les objets collaborent et se répartissent les responsabilitésStratégie, Observateur
Architecture (hors Gang of Four)comment organiser l'application en couchesRepository, MVC

Repository : isoler l'accès aux données

Problème. Le code métier a besoin de retrouver et d'enregistrer des tickets. S'il contient lui-même les requêtes SQL, il dépend de MySQL, ne se teste pas sans base et mélange deux responsabilités.

Solution. Une interface décrit les opérations de stockage dans le vocabulaire du métier ; des classes l'implémentent pour chaque technique. Le reste de l'application ne connaît que l'interface.

csharp
public interface ITicketRepository
{
    Ticket? TrouverParNumero(int numero);
    IReadOnlyList<Ticket> NonResolus();
    int ProchainNumero();
    void Enregistrer(Ticket ticket);
}

Une première implémentation garde les tickets en mémoire. Elle sert pour les tests et pour démarrer l'application avant que la base existe :

csharp
public class TicketRepositoryMemoire : ITicketRepository
{
    private readonly Dictionary<int, Ticket> tickets = [];

    public Ticket? TrouverParNumero(int numero) => tickets.GetValueOrDefault(numero);

    public IReadOnlyList<Ticket> NonResolus() =>
        tickets.Values.Where(t => t.Statut != StatutTicket.Resolu).OrderBy(t => t.Numero).ToList();

    public int ProchainNumero() => tickets.Count == 0 ? 1 : tickets.Keys.Max() + 1;

    public void Enregistrer(Ticket ticket) => tickets[ticket.Numero] = ticket;
}

Une seconde interroge la base parc avec des requêtes préparées. Voici sa méthode de recherche, avec le paquet NuGet MySqlConnector (dotnet add package MySqlConnector) :

csharp
public class TicketRepositoryMySql : ITicketRepository
{
    private readonly string chaineConnexion;

    public TicketRepositoryMySql(string chaineConnexion) => this.chaineConnexion = chaineConnexion;

    public Ticket? TrouverParNumero(int numero)
    {
        using MySqlConnection cnx = new MySqlConnection(chaineConnexion);
        cnx.Open();
        using MySqlCommand cmd = new MySqlCommand(
            @"SELECT t.id_ticket, t.titre, t.priorite, t.date_ouverture,
                     e.id_employe, e.nom, e.prenom, e.email
              FROM ticket t JOIN employe e ON e.id_employe = t.id_demandeur
              WHERE t.id_ticket = @numero", cnx);
        cmd.Parameters.AddWithValue("@numero", numero);
        using MySqlDataReader lecteur = cmd.ExecuteReader();
        if (!lecteur.Read())
        {
            return null;
        }
        Employe demandeur = new Employe(lecteur.GetInt32("id_employe"), lecteur.GetString("nom"),
                                        lecteur.GetString("prenom"), lecteur.GetString("email"));
        return new Ticket(lecteur.GetInt32("id_ticket"), lecteur.GetString("titre"),
                          VersPriorite(lecteur.GetString("priorite")), demandeur,
                          lecteur.GetDateTime("date_ouverture"));
    }

    private static Priorite VersPriorite(string valeur) => valeur switch
    {
        "basse" => Priorite.Basse,
        "haute" => Priorite.Haute,
        "critique" => Priorite.Critique,
        _ => Priorite.Normale,
    };

    // NonResolus, ProchainNumero et Enregistrer suivent le même modèle
}

C'est ici, et seulement ici, que le monde relationnel (lignes, colonnes, clés étrangères id_demandeur) est traduit en objets (un Ticket qui référence un Employe). Le paramètre @numero empêche toute injection SQL ; le cours SQL détaille les requêtes préparées et les transactions côté code.

RepositoryDAO (Data Access Object)
orienté métier : une collection d'objets du domaineorienté table : une classe par table, des méthodes CRUD
NonResolus(), TrouverParNumero()Insert(), Update(), Delete(), SelectById()
l'interface appartient à la couche métierla classe appartient à la couche d'accès aux données

Les deux noms sont souvent employés l'un pour l'autre, et les sujets d'E6 et d'E7 parlent volontiers de classes DAO. L'idée commune compte : le code métier ne contient pas de SQL. Leur place dans une architecture en couches est détaillée dans le chapitre Repository, DAO et injection de dépendances du cours Architecture MVC.

Stratégie : changer d'algorithme sans changer le code qui l'utilise

Problème. L'échéance de résolution d'un ticket dépend du contrat de service : le contrat standard accorde 4 heures pour un ticket critique, 8 pour un ticket de priorité haute, 24 pour une priorité normale et 72 pour une priorité basse ; le contrat premium divise ces délais par deux. Un if (contrat == "premium") dans chaque méthode qui calcule une échéance violerait le principe ouvert/fermé.

Solution. Chaque algorithme devient une classe qui implémente une interface commune. L'objet qui en a besoin reçoit une stratégie et l'appelle, sans savoir laquelle c'est.

TicketService- calculEcheance+ EnRetard()«interface»ICalculEcheance+ Echeance(ticket) : DateTimeEcheanceStandard+4, 8, 24 ou 72 hEcheancePremiumdélais divisés par 2EcheanceEconomiqueajoutée sans rienmodifier d'autre
Stratégie : TicketService ne connaît que l'interface, les contrats sont interchangeables
csharp
public interface ICalculEcheance
{
    DateTime Echeance(Ticket ticket);
}

public class EcheanceStandard : ICalculEcheance
{
    public DateTime Echeance(Ticket ticket) => ticket.DateOuverture.AddHours(ticket.Priorite switch
    {
        Priorite.Critique => 4,
        Priorite.Haute => 8,
        Priorite.Normale => 24,
        _ => 72,
    });
}

public class EcheancePremium : ICalculEcheance
{
    private readonly EcheanceStandard standard = new();

    // Délais du contrat standard divisés par deux
    public DateTime Echeance(Ticket ticket)
    {
        TimeSpan delai = standard.Echeance(ticket) - ticket.DateOuverture;
        return ticket.DateOuverture + delai / 2;
    }
}

Pour un ticket de priorité haute ouvert le 2 novembre 2026 à 9 h, EcheanceStandard donne 17 h et EcheancePremium 13 h. EcheancePremium réutilise le calcul standard par composition, au lieu de recopier le barème : un changement du barème standard se répercute automatiquement. Le service qui utilise la stratégie la reçoit par son constructeur, comme on le verra au chapitre suivant ; changer de contrat, c'est passer un autre objet.

La stratégie est le patron que tu utiliseras le plus : calcul d'un tarif, d'une remise, d'un délai, choix d'un format d'export (IFormatRapport du chapitre précédent), choix d'un mode d'authentification. En C#, une stratégie très simple peut aussi être une simple fonction passée en paramètre (Func<Ticket, DateTime>) : c'est ce que font les lambdas de LINQ.

Fabrique : centraliser la création des objets

Problème. L'import d'un fichier CSV doit créer un Poste, un Portable ou une Imprimante selon la première colonne. Si chaque endroit qui lit des données contient ce switch, ajouter un type de matériel oblige à les retrouver tous.

Solution. Une fabrique (factory) est le seul endroit qui sait quelle classe instancier. Le code appelant lui demande un Materiel et reçoit le bon objet, sans connaître les classes concrètes.

csharp
static class MaterielFactory
{
    public static Materiel Creer(string ligneCsv)
    {
        string[] c = ligneCsv.Split(';');
        if (c.Length != 5)
        {
            throw new FormatException($"Ligne mal formée : {ligneCsv}");
        }
        decimal prix = decimal.Parse(c[4], CultureInfo.InvariantCulture);
        return c[0] switch
        {
            "fixe" => new Poste(c[1], c[2], c[3], prix),
            "portable" => new Portable(c[1], c[2], c[3], prix),
            "imprimante" => new Imprimante(c[1], c[2], c[3], prix, couleur: false),
            _ => throw new FormatException($"Type de matériel inconnu : {c[0]}"),
        };
    }
}
csharp
string[] lignes =
[
    "fixe;PC-0011;Dell;OptiPlex 7020;899.00",
    "portable;PC-0012;Lenovo;ThinkPad T14;1290.00",
    "imprimante;IM-0004;Brother;HL-L8360;529.00",
    "scanner;SC-0001;Fujitsu;fi-8170;1090.00",
];
foreach (string ligne in lignes)
{
    try
    {
        Materiel m = MaterielFactory.Creer(ligne);
        Console.WriteLine($"{m.GetType().Name,-10} {m.NumInventaire} amorti sur {m.DureeAmortissementAns} ans");
    }
    catch (FormatException e)
    {
        Console.WriteLine(e.Message);
    }
}
text
Poste      PC-0011 amorti sur 4 ans
Portable   PC-0012 amorti sur 3 ans
Imprimante IM-0004 amorti sur 5 ans
Type de matériel inconnu : scanner

Le switch sur le type existe toujours, mais il est unique : c'est le seul endroit du programme qui choisit une classe concrète. Partout ailleurs, le polymorphisme prend le relais. La méthode de fabrique statique du chapitre 4 (Poste.DepuisCsv) est la version la plus simple de cette idée ; le Gang of Four décrit des variantes plus élaborées (Méthode de fabrique, Fabrique abstraite).

Singleton : une seule instance

Problème. Certains objets ne doivent exister qu'en un exemplaire : la configuration chargée au démarrage, un cache partagé.

Solution. La classe rend son constructeur privé et expose elle-même son unique instance, créée au premier usage.

csharp
sealed class Configuration
{
    private static readonly Lazy<Configuration> instance = new(() => new Configuration());

    private Configuration()
    {
        NomEntreprise = "Sorbier Logistique";
    }

    public static Configuration Instance => instance.Value;

    public string NomEntreprise { get; }
}
csharp
Configuration c1 = Configuration.Instance;
Configuration c2 = Configuration.Instance;
Console.WriteLine(ReferenceEquals(c1, c2));     // True

Lazy<T> crée l'objet au premier accès et garantit qu'un seul est créé, même si plusieurs tâches y accèdent en même temps.

Observateur : prévenir sans connaître

Problème. Quand un ticket change de statut, plusieurs actions doivent suivre : écrire dans le journal, prévenir le demandeur par courriel, mettre à jour un tableau de bord. Si Ticket appelle lui-même ces trois services, il en dépend, et chaque nouvelle réaction oblige à le modifier.

Solution. Le sujet observé publie un événement ; les observateurs s'y abonnent. Le sujet ne sait pas qui l'écoute. En C#, le patron est intégré au langage avec le mot-clé event.

csharp
class StatutChangeEventArgs(int numero, string ancien, string nouveau) : EventArgs
{
    public int Numero { get; } = numero;
    public string Ancien { get; } = ancien;
    public string Nouveau { get; } = nouveau;
}

class Ticket(int numero)
{
    public int Numero { get; } = numero;
    public string Statut { get; private set; } = "ouvert";

    public event EventHandler<StatutChangeEventArgs>? StatutChange;

    public void ChangerStatut(string nouveau)
    {
        string ancien = Statut;
        Statut = nouveau;
        StatutChange?.Invoke(this, new StatutChangeEventArgs(Numero, ancien, nouveau));
    }
}
csharp
Ticket t = new Ticket(4);
t.StatutChange += (sender, e) => Console.WriteLine($"[journal] ticket {e.Numero} : {e.Ancien} → {e.Nouveau}");
t.StatutChange += (sender, e) =>
{
    if (e.Nouveau == "resolu")
    {
        Console.WriteLine($"[courriel] le demandeur est prévenu de la résolution du ticket {e.Numero}");
    }
};
t.ChangerStatut("en_cours");
t.ChangerStatut("resolu");
text
[journal] ticket 4 : ouvert → en_cours
[journal] ticket 4 : en_cours → resolu
[courriel] le demandeur est prévenu de la résolution du ticket 4

+= abonne un gestionnaire, -= le désabonne. ?.Invoke ne déclenche l'événement que s'il a au moins un abonné. C'est exactement le mécanisme des événements WinForms : bouton.Click += BoutonValider_Click; abonne ta méthode au clic (les événements WinForms). En JavaScript, addEventListener suit la même logique.

MVC et les patrons d'architecture

MVC (Modèle, Vue, Contrôleur) n'est pas un patron de classes comme les précédents, mais un patron d'architecture : il répartit toute l'application en trois rôles. Le modèle contient les classes métier de ce cours (Ticket, Poste) et l'accès aux données (les repositories) ; la vue affiche ; le contrôleur reçoit les actions de l'utilisateur et fait travailler le modèle. Il combine d'ailleurs plusieurs patrons de ce chapitre : la vue observe le modèle, le contrôleur est une stratégie de la vue. Le cours Architecture MVC le détaille (le modèle MVC), avec ses variantes et les architectures en couches.

Reconnaître un patron dans un sujet

Indice dans le code ou l'énoncéPatron probable
une interface avec plusieurs classes interchangeables qui calculent ou font la même chose différemmentStratégie
une classe XxxDAO, XxxRepository, des méthodes getLesXxx(), findById() qui contiennent du SQLRepository / DAO
une méthode statique creer(type, …) avec un switch qui renvoie un type mèreFabrique
un constructeur private et une méthode statique getInstance()Singleton
des méthodes abonner, notifier, une liste d'écouteurs, des eventObservateur
des dossiers model, view, controllerMVC

Exercices

Exercice 1 sur 4 Quel patron ? Réussi À revoir

Pour chaque besoin, nomme le patron adapté.

  1. L'application doit pouvoir stocker ses données dans MySQL en production et en mémoire pendant les tests.
  2. Le calcul des indemnités kilométriques dépend de la puissance fiscale du véhicule, selon un barème qui change chaque année.
  3. Quand une licence arrive à expiration, il faut prévenir le service achats, journaliser et afficher une alerte, et d'autres réactions viendront.
  4. Un fichier d'import contient des lignes de plusieurs types (employé, poste, logiciel), à transformer chacune en objet.
  5. Le code existant contient Journal.getInstance().ecrire(…) dans quarante classes.
Voir la réponseMasquer la réponse de l'exercice 1
  1. Repository : une interface, une implémentation MySQL, une implémentation en mémoire.
  2. Stratégie : une classe par barème (ou par année), derrière une interface ICalculIndemnite ; changer de barème, c'est changer d'objet.
  3. Observateur : la licence publie un événement « expire bientôt », chaque réaction s'y abonne.
  4. Fabrique : un seul endroit choisit la classe à créer selon le type de ligne.
  5. Singleton, et un problème : quarante classes dépendent d'un état global. Il faudrait injecter un IJournal dans leur constructeur.

Tu avais trouvé ?

Exercice 2 sur 4 Ajouter une stratégie Réussi À revoir

Le contrat économique double les délais du contrat standard, et une échéance qui tombe un samedi ou un dimanche est reportée au lundi, même heure. Écris la classe EcheanceEconomique, qui implémente ICalculEcheance. Donne l'échéance de chacun des trois contrats pour un ticket de priorité normale ouvert le vendredi 6 novembre 2026 à 15 h.

Voir la réponseMasquer la réponse de l'exercice 2
csharp
public class EcheanceEconomique : ICalculEcheance
{
    private readonly EcheanceStandard standard = new();

    public DateTime Echeance(Ticket ticket)
    {
        DateTime e = ticket.DateOuverture + (standard.Echeance(ticket) - ticket.DateOuverture) * 2;
        while (e.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
        {
            e = e.AddDays(1);
        }
        return e;
    }
}
  • Standard : 15 h + 24 h = samedi 7 novembre, 15 h (le contrat standard ne reporte pas les week-ends).
  • Premium : 15 h + 12 h = samedi 7 novembre, 3 h.
  • Économique : 15 h + 48 h = dimanche 8 novembre, 15 h, reporté au lundi 9 novembre, 15 h.

Aucune ligne des stratégies existantes ni du code qui les utilise n'a été modifiée : c'est le principe ouvert/fermé en action.

Tu avais trouvé ?

Exercice 3 sur 4 Abonnements et désabonnements Réussi À revoir

L'événement StatutChange d'un ticket transmet le nouveau statut (EventHandler<string>). Donne l'affichage.

csharp
Ticket t = new Ticket(9);
EventHandler<string> journal = (s, statut) => Console.WriteLine($"journal : {statut}");
EventHandler<string> sms = (s, statut) => Console.WriteLine($"SMS : {statut}");
t.StatutChange += journal;
t.ChangerStatut("en_cours");
t.StatutChange += sms;
t.ChangerStatut("resolu");
t.StatutChange -= journal;
t.ChangerStatut("en_cours");
Voir la réponseMasquer la réponse de l'exercice 3
text
journal : en_cours
journal : resolu
SMS : resolu
SMS : en_cours

Au premier changement, seul le journal est abonné. Au deuxième, les deux le sont, dans l'ordre d'abonnement. Au troisième, le journal s'est désabonné : seul le SMS réagit. Pour pouvoir désabonner une lambda, il faut l'avoir rangée dans une variable, comme ici.

Tu avais trouvé ?

Exercice 4 sur 4 Ajouter un type à la fabrique Réussi À revoir

Le service achète des serveurs, amortis sur 5 ans. Les lignes d'import commencent par serveur. Que faut-il modifier dans le programme ? Qu'est-ce qui ne change pas ?

Voir la réponseMasquer la réponse de l'exercice 4

Deux ajouts seulement :

  1. une classe Serveur : Materiel qui redéfinit DureeAmortissementAns => 5 (et ses propres données, par exemple le nombre de processeurs) ;
  2. une ligne dans le switch de MaterielFactory.Creer : "serveur" => new Serveur(c[1], c[2], c[3], prix),.

Rien d'autre ne change : le calcul de la valeur nette, l'export CSV, la liste du parc et les écrans manipulent des Materiel et profitent du polymorphisme. La fabrique est le seul endroit qui connaît la liste des classes concrètes.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Injection de dépendances et attributs en C#Revoir le sommaire du cours