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.
| Famille | Problème traité | Patrons du chapitre |
|---|---|---|
| Création | comment créer les objets | Fabrique, Singleton |
| Structure | comment assembler classes et objets | (Adaptateur, Façade, Décorateur… non traités ici) |
| Comportement | comment les objets collaborent et se répartissent les responsabilités | Stratégie, Observateur |
| Architecture (hors Gang of Four) | comment organiser l'application en couches | Repository, 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.
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 :
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) :
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.
| Repository | DAO (Data Access Object) |
|---|---|
| orienté métier : une collection d'objets du domaine | orienté table : une classe par table, des méthodes CRUD |
NonResolus(), TrouverParNumero() | Insert(), Update(), Delete(), SelectById() |
| l'interface appartient à la couche métier | la 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.
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.
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]}"),
};
}
}
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);
}
}
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.
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; }
}
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.
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));
}
}
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");
[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éremment | Stratégie |
une classe XxxDAO, XxxRepository, des méthodes getLesXxx(), findById() qui contiennent du SQL | Repository / DAO |
une méthode statique creer(type, …) avec un switch qui renvoie un type mère | Fabrique |
un constructeur private et une méthode statique getInstance() | Singleton |
des méthodes abonner, notifier, une liste d'écouteurs, des event | Observateur |
des dossiers model, view, controller | MVC |
Exercices
Exercice 1 sur 4 Quel patron ? Réussi À revoir
Pour chaque besoin, nomme le patron adapté.
- L'application doit pouvoir stocker ses données dans MySQL en production et en mémoire pendant les tests.
- 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.
- Quand une licence arrive à expiration, il faut prévenir le service achats, journaliser et afficher une alerte, et d'autres réactions viendront.
- Un fichier d'import contient des lignes de plusieurs types (employé, poste, logiciel), à transformer chacune en objet.
- Le code existant contient
Journal.getInstance().ecrire(…)dans quarante classes.
Voir la réponseMasquer la réponse de l'exercice 1
- Repository : une interface, une implémentation MySQL, une implémentation en mémoire.
- 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. - Observateur : la licence publie un événement « expire bientôt », chaque réaction s'y abonne.
- Fabrique : un seul endroit choisit la classe à créer selon le type de ligne.
- Singleton, et un problème : quarante classes dépendent d'un état global. Il faudrait injecter un
IJournaldans 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
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.
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
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 :
- une classe
Serveur : Materielqui redéfinitDureeAmortissementAns => 5(et ses propres données, par exemple le nombre de processeurs) ; - une ligne dans le
switchdeMaterielFactory.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é ?