Design Pattern Factory Method
Réduire les couplages forts dans votre code
Introduction
Factory Method est un design pattern de création. Son objectif est de déléguer la création d'un objet à une méthode spécialisée, afin que le code manipule une abstraction, plutôt que des classes. Les classes dérivées de cette abstraction décident alors du type d'objet à instancier, en redéfinissant cette méthode.
Problématique
Dans une application, il est fréquent de créer différents types d’objets selon un contexte. Une première solution consiste à utiliser directement le mot-clé new suivi du nom de la classe, au sein d’une structure conditionnelle telle que if ou switch. Cette approche est simple à implémenter, mais elle crée progressivement un fort couplage entre le code métier et les classes concrètes utilisées.
Prenons l’exemple d’une application capable d’envoyer différents types de notification. Une notification peut être envoyée soit par e-mail, soit par SMS. Sans ce design pattern, nous pouvons directement instancier la classe appropriée à l’aide d’un switch.
public enum NotificationType
{
Email = 1,
Sms = 2
}
public class NotificationService
{
public void Send(NotificationType type, string message)
{
var sender = type switch
{
NotificationType.Email => new EmailNotificationSender(),
NotificationType.Sms => new SmsNotificationSender(),
_ => throw new ArgumentOutOfRangeException(
nameof(type),
type,
"Type de notification non pris en charge.")
};
sender.Send(message);
}
}
Cette implémentation est simple, mais la classe NotificationService connaît directement toutes les implémentations de notification disponibles (EmailNotificationSender et SmsNotificationSender). Ainsi, chaque ajout d’un nouveau mode de notification impose de modifier cette classe.
Conception
Le diagramme de classes ci-dessous présente les différents éléments du design pattern Factory Method, ainsi que leurs relations :
Dans ce diagramme, chaque classe et interface joue un rôle précis :
-
La classe NotificationService représente le Creator. Elle contient la méthode CreateSender(), qui constitue la Factory Method.
-
EmailNotificationService et SmsNotificationService sont les Concrete Creators qui redéfinissent CreateSender(), afin d'implémenter l'envoi de la notification appropriée.
-
INotificationSender représente le Product, c'est-à-dire le contrat commun utilisé par les Creators. Les classes EmailNotificationSender et SmsNotificationSender sont les Concrete Products.
Implémentation
L’interface INotificationSender représente un contrat que doivent implémenter les classes envoyant des notifications.
Création des Concrete Products
La classe EmailNotificationSender permet d’envoyer une notification par e-mail.
public class EmailNotificationSender : INotificationSender
{
public void Send(string message)
{
Console.WriteLine($"Envoi d'un e-mail : {message}");
}
}
Et la classe SmsNotificationSender permet d’envoyer une notification par SMS.
public class SmsNotificationSender : INotificationSender
{
public void Send(string message)
{
Console.WriteLine($"Envoi d'un SMS : {message}");
}
}
Implémentation de la classe abstraite
La classe abstraite NotificationService propose la méthode abstraite CreateSender(), représentant notre Factory Method, chargée de créer l’objet utilisé pour envoyer la notification.
public abstract class NotificationService
{
protected abstract INotificationSender CreateSender();
public void SendNotification(string message)
{
INotificationSender sender = CreateSender();
sender.Send(message);
}
}
Ainsi, la méthode SendNotification ne connaît pas la classe à utiliser pour envoyer la notification. Elle manipule uniquement un objet au travers de l’interface INotificationSender.
Implémentation des classes concrètes
La classe EmailNotificationService détermine l’objet à utiliser pour envoyer une notification par e-mail.
public class EmailNotificationService : NotificationService
{
protected override INotificationSender CreateSender()
=> new EmailNotificationSender();
}
Et la classe SmsNotificationService crée quant à elle un objet de type SmsNotificationSender.
public class SmsNotificationService : NotificationService
{
protected override INotificationSender CreateSender()
=> new SmsNotificationSender();
}
Chaque classe concrète redéfinit la Factory Method afin de fournir l’implémentation appropriée.
Utilisation
Le service peut ensuite être utilisé de la manière suivante :
NotificationService emailService = new EmailNotificationService();
emailService.SendNotification("Votre commande a été expédiée.");
NotificationService smsService = new SmsNotificationService();
smsService.SendNotification("Votre code de validation est 123456.");
Nous commençons par instancier notre Creator (NotificationService) en fonction du type de notification à envoyer. Puis nous appelons la méthode SendNotification pour envoyer la notification. Seule la création de l’objet exposé au travers de l'interface INotificationSender varie selon la classe concrète utilisée.
Ajout d'un nouveau type de notification
Supposons que nous devons prendre en charge un nouveau type de notification : les notifications Push. Il suffit alors d'ajouter un nouveau Concrete Product (nouvelle implémentation de INotificationSender), ainsi qu'un nouveau Concrete Creator (service dérivant de NotificationService) :
public class PushNotificationSender : INotificationSender
{
public void Send(string message)
{
Console.WriteLine($"Envoi d'une notification Push : {message}");
}
}
public class PushNotificationService : NotificationService
{
protected override INotificationSender CreateSender()
=> new PushNotificationSender();
}
Aucune modification n'a été nécessaire dans la classe NotificationService. Le traitement commun défini par cette classe est réutilisé tel quel, tandis que seule la création de l'objet est adaptée.
Le code est ainsi ouvert à l'extension et fermé à la modification, conformément au principe Open/Closed (OCP) des principes SOLID. Cette approche facilite la maintenabilité et l'évolutivité de l'application.
Utilisation des classes au sein d'une structure switch
Dans l'exemple précédent, le type de service était choisi directement lors de son instanciation. Dans une application, ce choix dépend souvent d'une valeur reçue dynamiquement, par exemple depuis une interface utilisateur, un paramètre de configuration, etc. Dans ce cas, il est tout à fait possible d'utiliser les classes du design pattern Factory Method, au sein d'une structure switch afin de sélectionner le type de notifications approprié :
public enum NotificationType
{
Email = 1,
Sms = 2,
Push = 3
}
public class NotificationDispatcher
{
public void Send(
NotificationType notificationType,
string message)
{
NotificationService notificationService =
notificationType switch
{
NotificationType.Email
=> new EmailNotificationService(),
NotificationType.Sms
=> new SmsNotificationService(),
NotificationType.Push
=> new PushNotificationService(),
_ => throw new ArgumentOutOfRangeException(
nameof(notificationType),
notificationType,
"Type de notification non pris en charge.")
};
notificationService.SendNotification(message);
}
}
Cet exemple montre que le design pattern Factory Method n'a pas vocation à remplacer les structures conditionnelles comme le switch. Son objectif est de limiter les couplages forts entre les classes.
Factory Method et injection de dépendances
Dans les applications utilisant l'injection de dépendances, le conteneur d'injections de dépendance (IoC) prend généralement en charge la création des objets. Ce design pattern reste néanmoins utile lorsqu'une classe de base définit un traitement commun, tout en laissant les classes dérivées déterminer l'implémentation concrète à utiliser.
Il est également utile lorsque la création d'un objet nécessite une logique spécifique qui ne peut pas être entièrement déléguée au conteneur d'injection de dépendances.
Avantages
Le design pattern Factory Method apporte plusieurs avantages :
-
Il réduit le couplage entre le code métier et les classes concrètes. Le traitement principal dépend d’une abstraction plutôt que des classes qu’il instancie.
-
Il centralise également la création des objets dans des méthodes ou des classes dédiées.
-
L’ajout d’un nouveau produit nécessite généralement la création d’une nouvelle implémentation et d’un nouveau créateur, sans modification du traitement commun.
-
Enfin, ce pattern facilite les tests unitaires, car les responsabilités de création et d’utilisation des objets sont mieux séparées.
