l’image empruntée de LosTechies.
Pour continuer le sujet que j’ai introduit dans un de mes posts précédents [Design Patterns] Est-ce que votre code est S.O.L.I.D(e) - Introduction je m’attaque à la première bonne pratique:
Single Responsibility Principle (SRP) : Principe de Responsabilité Unique.
“Une classe ne doit avoir qu'une raison pour qu'elle change”
Qu’est-ce que cela veut dire ? Chaque développeur dans son travail de tous les jours a des problèmes plus ou moins complexes à résoudre. Il est souvent très difficile de résoudre un problème en le traitant dans sa globalité, surtout quand les paramètres qui le définissent changent de temps en temps. En découpant le “gros” problème en des mini-problèmes permet de les résoudre beaucoup plus facilement. En fait, chaque mini-problème peut être assimilé à une tâche concrète qui à son tour peut être implémentée à l’aide d’une simple classe. Puisque chaque classe a une simple tâche à accomplir il y a par conséquence une seule raison pour qu’elle change. Nous pouvons donc changer juste cette classe si la tâche correspondante change. Comme vous-vous en doutez une tâche = une responsabilité.
Cela signifie également que nous devons apprendre à découper les problèmes car les problèmes complexes sont difficile à résoudre en tant que l’ensemble indivisible. Cela permet de réduire la complexité de l’ensemble car chaque mini-problème peut être traité séparément.
L’application de SRP est aussi bénéfique à la qualité de votre code ainsi qu’à la modélisation objet que vous faites. Pourquoi ? Car le code est:
- plus robuste.
- plus facile à lire et à comprendre.
- plus facile à tester et à maintenir.
- plus facile à étendre avec de nouvelles fonctionnalités.
Qu’en est-il dans la pratique ?
Exemple initial
Nous allons créer une petite application console pour illustrer le problème. A cette application console nous ajoutons une couche d’accès aux données qui est sous la forme d’une bibliothèque de classes. Dans ma couche d’accès aux données j’ajoute un Entity Framework Data Model basé sur la base de données Northwind et la table Products:
Admettons que nos règles métier nous demandes d’implémenter le scénario suivant:
“Nous voulons récupérer une liste de produits (Products) en appliquant éventuellement 10% de remise suivant le cas.”
Nous créons donc une classe ProductDAO qui nous permettra de récupérer notre liste de produit qui correspond à l’énoncé ci-dessus. La classe possède une méthode GetProducts(bool applyDiscount) qui prend en paramètre une valeur pour indiquer si on veut appliquer la ristourne. L’implémentation n’est pas un best practice donc ne me blâmez pas là-dessus mais elle peut ressembler à ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
public class ProductDAO { private readonly NorthwindEntities dataContext;
public ProductDAO() { // 1: création de la connexion dataContext = new NorthwindEntities(); }
public IEnumerable<Products> GetProducts(bool applyDiscount) { // 2: calcul de la remise var discount = applyDiscount ? .90m : 1;
var products = (from p in dataContext.Products select p).AsEnumerable();
// 3: requête de sélection return from p in products select new Products { CategoryID = p.CategoryID, Discontinued = p.Discontinued, ProductID = p.ProductID, ProductName = p.ProductName, QuantityPerUnit = p.QuantityPerUnit, ReorderLevel = p.ReorderLevel, SupplierID = p.SupplierID, UnitPrice = p.UnitPrice * discount, // 4: appliquer la remise UnitsInStock = p.UnitsInStock, UnitsOnOrder = p.UnitsOnOrder }; } } |
L’appel à cette classe se fait dans l’application console de la manière suivante :
1 2 3 4 5 6 7 8 9 |
static void Main(string[] args) { var productDAO = new ProductDAO();
foreach (var p in productDAO.GetProducts(true, true)) Console.WriteLine(p.ProductName);
Console.ReadKey(); }
|
Comme vous pouvez le constater cette petite fonctionnalité nous permet de récupérer de produits et d’appliquer une remise de 10%. Mais est-ce qu’il y a pas quelque chose qui vous choque ? Bien entendu, la classe ProductDAO ne respecte pas de principe SRP car elle a trop de responsabilités ! Et pourtant il n’y a qu’une requête Linq !
Cet exemple bien que banal, je l’ai vu implémenté d’une manière similaire à de nombreuses reprises sur divers projets. Pour des petits projet ceci peut-être n’est pas gênant mais d’une manière générale c’est une galère à maintenir. Nous avons dit à l’instant que cette classe ne respecte pas de principe SRP, donc elle a plus d’une responsabilité et donc par conséquence plus d’une raison de changer. Voyons les responsabilité qu’elle endosse (le numéros font référence aux commentaires dans le code) :
- Création de la connexion. De plus ceci suppose que la connexion est configurée correctement au niveau du fichier .config de votre application car nous utilisons un constructeur sans paramètres.
- Calcule de la remise. Le fait que le taux soit défini en dur c’est moche, mais je ne voulais plus d’ajouter des paramètres pour une question de simplicité. Le taux aurait pu être passé en paramètre ou récupéré d’une autre manière.
- L’exécution de la requête.
- L’application de la remise.
- La requête Linq implicitement faire référence à un schéma de données relationnel de la base de données.
Potentiellement on a dans cette petite classe 5 raisons possible pour que cette classe change. Si par exemple la remise doit être également calculée en fonction da la catégorie du produit ? Si la connexion est passée par une autre classe ou un autre paramètre ?
Toutes ces raisons nous feront modifier notre classe à chaque fois ! Pire encore, si les questions que nous-nous posons dépendent du contexte de l’appelant. Lorsque la méthode GetProducts est appelée d’une autre classe ces conditions peuvent être différentes que lorsqu’on l’appelle de notre application console. Il nous faudrait à ce moment là de gérer le contexte de l’appelant dans la classe par les biais de if/switch, ce qui est MAUVAIS.
Analyse
Lorsqu’on a implémenté notre couche de données, l’assistant d’Entity Framework a généré la classe Products à la volé. Le problème de cette implémentation est que le modèle métier est au niveau de la couche d’accès aux données. Ce n’est pas de la responsabilité de la couche d’accès aux données d’implémenter une classe comme Products. De même le fait de déterminer si la remise doit être appliquée c’est du ressort de la logique métier donc modèle métier.
Attention: le but de ce poste n’est pas de critiquer ou de donner les best practices quant à l’utilisation de EF. On s’en sert pour démontrer le problème souvent rencontré dans beaucoup d’implémentations de projets. On aurait pu aussi bien utiliser ADO.NET, Linq2SQL ou autre chose pour illustrer le problème. Pour plus d’informations sur EF n’hésitez pas à jeter un œil sur le blog de Matthieu Mezil.
Un autre problème est que la couche de présentation (application console) référence directement la couche d’accès aux données. C’est un sujet pour un autre post que je vais traiter bientôt.
Ici je vais essayer de présenter les démarches de refactoring pour séparer les responsabilités qui dans l’exemple ci-dessus se trouvent au sein de la méthode GetProducts(bool applyDiscount).
Refactoring 1
Objet: “Introduire un modèle métier.”
But: “Les objets du modèle métier ont souvent un comportement qui est déterminé par les règles métier. Ici, nous pouvons placer la responsabilité de décider si la ristourne doit être appliquée ou pas.”
Tout d’abord il faut ajouter une couche qui contiendra notre modèle métier et nos objets métier (je n’utilise pas le mot clé entités pour ne pas confondre avec les entités du modèle de persistance crée avec EF). Notre couche métier (Domain) que j’ajoute à la solution de notre application ressemble maintenant à ceci:

La couche Domain ne doit référencer aucune autre couche.
Ensuite nous définissons noter objet métier Product qui est un POCO (Plain Old Clr Object) tout simple. Le but de cette démarche est de découper la notion de la persistance du modèle métier car au final ce qui m’intéresse se sont les interactions entre les objets métier et non la manière dont les données sont persistées (BD, XML ou même un fichier texte). Mon objet est tout simple et se présente comme ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Product { public string Name { get; set; } public decimal? Price { get; set; } public int? UnitsInStock { get; set; }
public Product ApplyDiscount(bool applyDiscount) { // 2: calcul de la remise var discount = applyDiscount ? .90m : 1;
return new Product(this.Name, this.Price * discount, this.UnitsInStock);
} } |
Comme vous pouvez le constater à part les propriétés publiques les plus communes l’objet la classe possède juste une responsabilité qui est d’appliquer et de renvoyer un objet Product avec la ristourne.
Refactoring 2
Objet: “Introduire une abstraction pour récupérer et sauvegarder les produits à partir d’une source de données.”
But: “Ce n’est pas de la responsabilité de l’objet métier de savoir comment il doit être récupéré et sauvegardé dans une source de données.”
Ce qui se fait généralement pour ajouter de l’abstraction aux accès aux données est d’implémenter le pattern Repository. Nous devons donc définir une classe dans notre modèle métier. La classe que je vais définir s’appelle ProductRepository et se présente comme ceci:
1 2 3 4 |
public abstract class ProductRepository { public abstract IEnumerable<Product> GetProducts(); } |
Cette classe tout simplement récupère une liste des produits (j’ai omis la sauvegarde car ce n’est pas le but de ce poste).
A quoi cette classe nous servira ? Comme vous l’avez remarqué elle est abstraite. En fait puisque elle se trouve dans notre modèle métier elle servira de la classe de base pour les implémentations concrètes de Product repository. Dans notre cas nous implémentons EF donc dans notre couche d’accès aux données nous allons définir une class EntityProductRepository qui dérivera de la classe ProductRepository.
Nous aurions pu définir également une classe
XmlProductRepository et récupérer nos produits à partir d’un fichier XML si cela nous intéressait. Ce qu’il faut retenir et qu’un niveau de la couche Domain nous nous fichons comment les produits seront récupérés.
Refactoring 3
Objet: “Implémenter l’accès aux données”
But: “Il est important de récupérer les données à partir d’une source de données. La seule responsabilité de cette classe est d’exécuter la requête sans se préoccuper des règles métier comme de savoir si une remise doit être appliquée ou pas.”
Nous allons maintenant dans notre couche d’accès aux données (NorthwindDAL). Il faut implémenter une classe qui permettra de récupérer les produits à partir de la base de données. Elle s’appelle EntityProductRepository et dérive à partir de la classe abstraite ProductRepository. C’est une implémentation concrète de notre abstraction qui est manipulé par les autres couches. Elle se présente de cette manière:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class EntityProductRepository : ProductRepository { private readonly NorthwindEntities dataContext;
public EntityProductRepository(string connectionString) { dataContext = new NorthwindEntities(connectionString); }
public override IEnumerable<Product> GetProducts() { var products = (from p in dataContext.Products select p).AsEnumerable();
return from p in products select p.ToDomainProduct(); } } |
Rappelez-vous la classe que je vous ai présenté au début de ce post. Regardez comment maintenant la requête Linq est petite quand on commence à enlever les responsabilités et de les placer dans des classes à part ! Elle se contente juste de récupérer la liste de produits de la base de données.
A noter cependant que le constructeur prend maintenant en paramètre la chaîne de connexion qui n’est plus maintenant récupéré directement par la classe. La chaîne de connexion est injectée dans le constructeur par le biais du pattern DI à partir de la l’application console (poor man DI mais qui marche bien dans cet exemple).
La méthode d’extension (non présenté ici) ToDomainProduct() transforme juste les entités EF à nos vraies classes POCO de la couche du Domaine.
Refactoring 4
Objet: “Fournir les objets métiers à l’application console pour les afficher”
But: “Maintenant que nous savons comment récupérer les produits de la base de données nous devons fournir un service qui permettra de les manipuler.”
Ce service se trouvera au niveau de la couche Domain et utilisera notre classe ProductRepository pour manipuler les produits. La classe s’appelle ProductProvider :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class ProductProvider { private readonly ProductRepository repository;
public ProductProvider(ProductRepository repository) { this.repository = repository; }
public IEnumerable<Product> GetDiscountedProducts() { return from p in this.repository.GetProducts() select p.ApplyDiscount(true); } } |
Comme vous pouvez le constater, la classe se sert juste de notre repository pour récupérer les produits avec la ristourne et les retourne à l’appelant. Vous constaterez que la classe ne fait rien d’autre que de demander les produits avec la ristourne. Le calcul de la ristourne est fait au niveau de la classe Product qui sait comment calculer la ristourne.
Refactoring 5
Objet: “Configurer l’application”
But: “Nous devons maintenant récupérer la chaîne de connexion et d’instancier notre EntityProductRepository.”
Cette responsabilité incombe à la classe configurateur que j’ai appelé Bootstraper et qui est évoquée dans le (composition root) de l’application (au niveau le plus haut, la où l’application démarre). Je l’appelle au début de la méthode Main de mon application console. La classe est toute simple:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class Bootstrap { public static ProductProvider ConfigureProducts() { string connectionString = ConfigurationManager.ConnectionStrings["NorthwindEntities"].ConnectionString;
string productRepositoryTypeName = ConfigurationManager.AppSettings["ProductRepositoryType"];
var productRepositoryType = Type.GetType(productRepositoryTypeName, true);
var repository = (ProductRepository)Activator.CreateInstance(productRepositoryType, connectionString);
return new ProductProvider(repository); } } |
Elle récupère la chaîne de connexion, instancie le repository concret et retourne notre ProductProvider qui permettra à l’application console d’afficher les produits. Remarquez comment nous injectons la chaîne de connexion dans la couche la plus basse. Notre ProductProvider est ensuite configuré avec les repository et retourné à l’application.
Afficher les produits
Notre application console se présente finalement de cette manière.
1 |
var productService = Bootstrap.ConfigureProducts(); foreach (var p in productService.GetProducts()) Console.WriteLine(p.Name + "|" + p.Price);
|
Où sont nos responsabilités ?
Comme vous l’avez remarquez nous avons découpé notre classe ProductDAO qui avait trop de responsabilités.
- Le calcul de la ristourne est de la responsabilité de la classe Product. Elle fait partie du modèle métier donc elle doit avoir la connaissance de cette règle métier.
- La classe ProductRepository est une abstraction qui permet de récupérer les produits à partir de la base de données. Les produits eux mêmes (Product) ignore complètement d’où ils viennent (base de données, xml, etc.). L’exécution de la requête sur la base de données est assurée par la classe EntityProductRepository.
- L’application de la remise est possible grâce à la classe ProductProvider.
- Configuration de l’application et la récupération de la chaîne de connexion est assurée par la classe Bootstrap.
- L’affichage (le formatage à l’affichage) est assuré par notre application console (GUI).
Etant donné que les responsabilités sont plus clairement découpés le fait d’en modifier une n’entraîne pas de modification des autres classes.
Conclusion
Il n’est pas toujours possible d’avoir une responsabilité unique au niveau de la classe, le but et d’en avoir le moins possible et être conscient de nos choix lorsqu’on en introduit plus. Le SRP est un principe le plus simple à comprendre mais peut-être le plus difficile à mettre en place. Attention, ici j’ai donnée l’exemple avec les couches applicatives mais le SRP s’applique aussi bien au niveau d’une classe ou d’une seule assembly. Je vais faire un autre poste à ce sujet pour démontrer l’applicabilité de ce principe au niveau d’une seule classe.
J’espère que cette explication est assez claire, n’hésitez pas à me laisser vos commentaire et vous remarques. Merci à ceux qui ont réussi à lire ce long post.
A bientôt.