Les principes SOLID

Petit aide mémoire des bonnes pratiques de programmation orientée objet (POO)

Les principes SOLID sont des règles écritent pas Robert C. Martin AKA Uncle Bob (l'auteur du livre Clean Code). Ces principes permettent d'écrire du code plus robuste grâce à 5 points clefs. Comme j'ai eu un peu de mal à les comprendre, je vais tenter de les expliquer à ma façon et avec Javascript.

S : Single Responsability

Cela signifie que les classes doivent avoir une responsablité et une seule. Elles ne doivent pas faire le café (ou alors elle ne doivent faire que le café ;) ).

Par exemple j'ai une classe "Post" qui permet d'ajouter des articles et de le afficher sur mon blog. Est-ce que ma classe "Post" gère l'insertion de l'article dans la base de données ? non, on va créer une autre classe pour ça.

Les cas les plus fréquents où on peut oublier ce principe sont, selon moi :

l'enregistrement en base de données

la gestion du cache

les calls d'API

le payement

...

Cela permet de découpler fortement les composants des applications, d'éviter les classes à rallonge et d'avoir un code bien plus maintenable.

O : Open/Close Principle

Cela signifie qu'il ne faut pas modifier nos classes mais plutôt les étendre. Une fois que le code est terminé, sauf bug dans notre classe, on va utiliser d'autres classes avec un héritage pour lui rajouter du code. Quel intéret ?

On évite de réécrire nos tests unitaires

On évite les régressions de code

Alors comment ça marche ?

Je prends une classe "Vehicule"

class Vehicule {
  roule(){
    console.log("le véhicule roule")
  }
}

const voiture = new Vehicule
voiture.roule()

Ca marche impeccable pour les voitures, les vélos, les chars Leclerc et les poussettes. Mais imaginons que je veuille faire un avion qui lui va voler. Je ne vais pas rajouter une méthode "vole" comme ceci :

class Vehicule {
  roule(){
    console.log("le véhicule roule")
  }
  
  vole(){
    console.Log("le véhicule vole")
  }
}

const avions = new Vehicule
voiture.vole()

Mais plutôt étendre ma classe Vehicule en VehiculeVolant :

class Vehicule {
  avance(){
    console.log("le véhicule avance")
  }
}

class VehiculeVolant extends Vehicule{
  vole(){
    console.log("le véhicule vole")
  }
}

const avion = new VehiculeVolant
avion.vole()

Ainsi je ne risque pas de tout péter et devoir refaire mes tests unitaires.

L : Principe de Liskov

Cela signifie qu'une classe parent doit pouvoir se substituer à une classe enfant. Si je prends le même exemple et que je le modifie pour ajouter une méthode klaxonne. Problème ! Les véhicules volants ne klaxonnent pas (je ne suis pas expert mais j'ai dans l'idée que les avions ne se klaxonnent pas entre eux). Du coup je vais devoir gérer une exception.

class Vehicule {
  roule(){
    console.log("le véhicule roule")
  }
  
  klaxonne(){
    console.log("pouet pouet !")
  }
}

class VehiculeVolant extends Vehicule{
  vole(){
    console.log("le véhicule vole")
  }
  
  klaxonne(){
    throw("action non disponible")
  }
}

const avion = new VehiculeVolant
try{
  avion.klaxonne()
}catch(error){
  console.log(error)
}

On voit ici le principe de Liskov en action : le véhicule parent ne peut pas se substituer au véhicule enfant (volant). Du coup on doit gérer des exceptions et là ça commence à puer.

I : Interface Segregation

Une interface, en programmation objet, permet de décrire une classe, mais sans la coder. Surprise, cela n'existe pas en Javascript ... Mais en en Typescript oui ! On peut se fatiguer à émuler les classes en JS, mais avec l'arrivée de Typescript je n'en vois plus vraiment l’intérêt. Je vais donc abandonner Js pour ce principe et l'expliquer avec le Ts ce qui sera plus clair.

Exemple :

On définit une classe Vehicule qui implémente l'interface VehiculeInterface.

interface VehiculeInterface{
  type:string,
  speed:number,
  fuel:string,
  avance()
}

class Vehicule implements VehiculeInterface {
  constructor(type:string,speed:number,fuel:string){
    this.type=type
    this.speed=speed
    this.fuel=fuel
  }
  avance(){
    console.log(`${this.type} avance à ${this.speed}kmh`)
  }
}

vehicule = new Vehicule("voiture",110,"essence")
vehicule.avance()

Tout va bien pour les voitures, les avions, les trottinettes électriques ... qui ont tous une source d'énergie : "gazoil", "essence", "électricité"

Mais comment faire avec les véhicules qui n'ont pas besoin d'énergie pour fonctionner (donc pas besoin de la propriété fuel) ? Comment faire avec les vélos par exemple ?

Dans ce cas on va splitter notre interface en deux : VehiculeInterface et VehiculeAMoteurInterface. Chacune ne contiendra que le strict nécessaire. Ensuite notre classe Vehicule va implémenter les deux interfaces :

interface VehiculeInterface{
  type:string,
  speed:number,
  avance()
}

interface VehiculeAMoteurInterface{
  fuel:string
}

class Vehicule implements VehiculeInterface,VehiculeAMoteurInterface  {
  constructor(type:string,speed:number,fuel:string){
    this.type=type
    this.speed=speed
    this.fuel=fuel
  }
  avance(){
    console.log(`${this.type} avance à ${this.speed}kmh`)
  }
}

vehicule = new Vehicule("voiture",110,"essence")
vehicule.avance()

velo = new Vehicule("vélo",25)
velo.avance()

L'idée de cette règle, est qu'il faut essayer de diviser au maximum les interfaces pour qu'elles soient le plus simple possible.

Quel intérêt ? Pouvoir réutiliser son code et avoir des tests plus simples.

D : Dependency inversion
La règle ici est "Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau".
Les modules bas niveau sont proche de la machine. Ils permettent de dialoguer avec la base de données, envoyer des mails ...
les modules haut niveau contiennent le code fonctionnel de l'application.

Il faut donc avoir une séparation claire entre les deux. L'un ne doit pas dépendre de l'autre et ainsi le code peut évoluer bien plus librement. On évite ainsi de couple lourdement son application.

Pour cela on va mettre de l'abstraction entre nos modules haut et bas niveaux avec ce qu'on appelle un "pattern adapter" que l'on va mettre entre les deux.