📖 Cet article fait partie de la série sur le thème SOLID
SOLID ?
SOLID est un acronyme correspondant à cinq principes de programmation orientée objet (POO) destinés à produire des architectures logicielles plus compréhensibles, flexibles et maintenables.
Cet ensemble de principes a été popularisé sous cette forme par l'ingénieur logiciel Robert C. Martin, plus connu sous le nom de Uncle Bob. Il est également renommé pour ses publications de référence telles que les excellents Clean Code ou Clean architecture. Il est également un des rédacteurs du Manifeste Agile
Les cinq principes SOLID :
- Single Responsibility principle
- Open/closed principle
- Liskov substitution principle
- Interface Segregation principle
- Dependency inversion principle
Ils permettent de créer des logiciels faciles à maintenir et à faire évoluer. Bien appliqués, ils évitent les codes smells, permettent de refactoriser facilement et souvent, et sont des alliés de poids dans tout projet de développement Agile.
Il existe beaucoup d'articles présentant les principes SOLID, mais la plupart traitent les cinq d'un coup ce qui n'est pas suffisant pour détailler les subtilités de chacun d'entre eux. Nous allons traiter chacun dans un article séparé, afin de bien saisir leur importance et leur valeur.
Le tout sera accompagné d'exemples en Typescript (parce qu'on 💙 Typescript à Gojob), mais il est entendu que ce qui va suivre s'applique dans tout langage de programmation supportant le paradigme orienté objet.
SRP - Single Responsibility Principle
Un module ne devrait jamais avoir plus d'une seule raison de changer. Autrement dit, chaque module ne devrait avoir qu'une seule responsabilité.
Définition
Un module désigne ici tout composant logiciel encapsulant une responsabilité fonctionelle : une classe en est l'exemple le plus courant, mais cela peut aussi s'appliquer à une fonction, une méthode, etc. À un plus haut niveau, ce principe s'applique aussi lors de la conception d'une architecture logicielle, par exemple d'une architecture en micro-services : dans ce cas, chaque service représente un module.
Qu'est ce qu'une raison de changer, et quid de la notion de responsabilité ?
Au premier abord, on pourrait comprendre que le SRP dicte le fait qu'un module ne doit faire qu'une seule chose. C'est incorrect (et cette mauvaise interprétation est très répandue) : le vrai enseignement est qu'un module ne doit avoir qu'une seule raison de changer.
Il est naturellement possible qu'un module ne fasse qu'une seule chose, mais ce qui est vraiment important... c'est qu'il n'ait qu'une seule raison de changer 😏
Une seule raison de changer
Si un module n'a qu'une seule raison de changer, tout changement du produit pour une autre raison n'aura aucun impact sur ce module et nous sommes assurés qu'il continuera de fonctionner de la manière souhaitée.
Tom Hombergs, Get Your Hands Dirty on Clean Architecture
Les besoins d'un produit évoluent régulièrement : que ce soit pour s'adapter aux demandes des utilisateurs, du marché, prendre en compte un changement technique d'un service tiers avec lequel le produit s'interface, etc. les spécifications évoluent et il faut souvent modifier le comportement du produit.
C'est normal et sain : c'est l'illustration même de l'agilité.
Cependant il est difficile voire dangereux de modifier un comportement si celui ci est situé dans un module qui a plusieurs raisons de changer. C'est ainsi que les développeurs, voire les décideurs, sont parfois réticents à l'idée de modifier du code legacy / ne respectant pas le SRP, car ils savent d'expérience que des modifications dans cette partie du produit peuvent avoir des effets de bord incontrôlés.
C'est ainsi que les équipes se retrouvent parfois contraintes à implémenter des décisions architecturales tordues et coûteuses dans le simple but d'éviter de modifier de tels modules.
Illustration par l' exemple
Imaginons que nous sommes amenés à travailler sur une application existante qui permet à ses utilisateurs de proposer et de voter pour des noms de chat 🐱 (application novatrice et ambitieuse s'il en est !).
Pour le moment, la logique de gestion des noms proposés se trouve dans la classe suivante :
import fs from 'fs';
import path from 'path';
interface Cache {
[index: number]: string;
}
export default class FileStore {
constructor(private readonly directory: string, private readonly cache: Cache = {}) {}
public async save(id: number, catName: string) {
console.log(`Saving File ${id}.`);
const fileFullName = this.getFile(id);
try {
fs.writeFileSync(fileFullName, catName);
this.cache[id] = catName;
console.log(`File saved ${id}.`);
} catch (error) {
console.log(`Error saving file ${id}.`);
}
}
public read(id: number): string {
console.log(`Reading File ${id} from store.`);
const fileFullName = this.getFile(id);
const exists = fs.existsSync(fileFullName);
if (!exists) {
console.log(`No file ${id} found.`);
return '';
}
if (!this.cache.hasOwnProperty(id)) {
console.info(`File id ${id} not in cache.`);
const data = fs.readFileSync(fileFullName, 'utf8');
this.cache[id] = data;
}
const message = this.cache[id];
console.log(`Returning File ${id}.`);
return message;
}
private getFile(id: number) {
return path.join(__dirname, this.directory, `cat-${id}.txt`);
}
}
Cette classe, d'apparence "relativement simple", a cependant plusieurs responsabilités et donc plusieurs raisons de changer. En effet, elle gère :
- Le logging
- La gestion du cache
- Les interactions avec le système de fichier
- mais aussi, et c'est moins évident, l'orchestration
C'est là que le bât blesse : une altération de n'importe lequel de ces mécanismes va fatalement nécessiter la modification de cette classe, dont la seule responsabilité, à priori, devrait être les interactions avec les fichiers.
Il est temps de refactoriser tout cela, et de séparer les responsabilités. Commençons par extraire le processus de logging dans une classe dédiée :
export default class Logger {
public log(message: string): void {
console.log(message);
}
public error(message: string): void {
console.error(message);
}
public warn(message: string): void {
console.warn(message);
}
public info(message: string): void {
console.info(message);
}
}
Cette classe n'a qu'une seule responsabilité (l'écriture de logs) et qu'une seule raison de changer (la manière d'écrire des logs).
Intéressons nous maintenant à la gestion du stockage des messages dans un MessageStore
, que nous allons
également extraire dans une classe dédiée.
Ce store comporte un système de caching simple, qui pourrait être remplacé si nécessaire sans impacter le
reste de l'application.
import Logger from './Logger';
interface Cache {
[index: number]: string;
}
export default class MessageStore {
constructor(private readonly logger: Logger, private readonly cache: Cache = {}) {}
public addOrUpdate(id: number, message: string): void {
this.cache[id] = message;
}
public getOrAdd(id: number, message?: string): string {
this.logger.log(`Reading file ${id} from cache.`);
if (!this.exists(id)) {
this.logger.info(`File id ${id} not in cache.`);
this.addOrUpdate(id, message);
}
return this.cache[id];
}
public exists(id: number): boolean {
return this.cache.hasOwnProperty(id);
}
}
Là aussi, cette classe n'a qu'une seule responsabilité et qu'une seule raison de changer.
Elle fait certes appel au logger, mais seulement pour lui déléguer la gestion de l'écriture des logs : elle n'en porte pas la responsabilité.
Débarassée de ces deux responsabilités, notre classe FileStore
respecte déjà mieux le SRP, et les raisons
pour qu'elle subisse un effet de bord sont drastiquement réduites. Cependant, il reste lui reste encore une
responsabilité de trop : l'orchestration.
En effet, elle ne devrait pas être responsable de déterminer si le fichier doit être retourné par le cache ou par le système de fichiers : son rôle devrait se limiter aux interactions avec les fichiers.
Nous pouvons adresser ce problème en introduisant une classe d'orchestration, qui sera le point d'entrée du
reste de notre application et qui fera elle même appel au FileStore
:
import FileStore from './FileStore';
import MessageStore from './MessageStore';
export default class Orchestrator {
constructor(private readonly messageStore: MessageStore, private readonly fileStore: FileStore) {}
public async save(id: number, message: string) {
await this.fileStore.save(id, message);
this.messageStore.addOrUpdate(id, message);
}
public read(id: number): string {
if (!this.messageStore.exists(id)) {
const message = this.fileStore.read(id);
this.messageStore.getOrAdd(id, message);
}
return this.messageStore.getOrAdd(id);
}
}
La classe FileStore
n'a désormais plus qu'une seule responsabilité : la gestion des fichiers.
import fs from 'fs';
import path from 'path';
import Logger from './Logger';
export default class FileStore {
constructor(private readonly directory: string, private readonly logger: Logger) {}
public async save(id: number, catName: string): Promise<any> {
this.logger.log(`Saving File ${id}.`);
const fileFullName = this.getFile(id);
try {
fs.writeFileSync(fileFullName, catName);
this.logger.log(`File saved ${id}.`);
} catch (error) {
this.logger.error(`Error saving file ${id}.`);
}
}
public read(id: number): string {
this.logger.log(`Reading file ${id} from store.`);
const fileFullName = this.getFile(id);
if (!fs.existsSync(fileFullName)) {
this.logger.warn(`No file ${id} found.`);
return '';
}
return fs.readFileSync(fileFullName, 'utf-8');
}
private getFile(id: number) {
return path.join(__dirname, this.directory, `cat-${id}.txt`);
}
}
Cette refactorisation nous a permis d'augmenter la cohésion du code en réunissant ce qui a la même raison de changer dans les mêmes modules tout en diminuant le couplage entre ces modules aux responsabilités distinctes.
Nous pouvons donc être sereins : si l'un des mécanismes ainsi isolé devait changer, seul le module concerné serait amener à évoluer et le reste de notre application ne serait pas impacté !