Challenger l’architecture d’un projet en tant que développeur junior (Partie 1)

Récemment diplômés en informatique et fiers d’être estampillés développeurs, on est confrontés rapidement à notre première mission. On est face à un projet dont le besoin n’est pas bien défini et on a 3 mois pour délivrer un MVP. On commence à définir les premières briques du contexte métier de l’application mais le besoin évolue continuellement.

Pendant les développements et les relectures de merge requests, il est difficile de remettre en question les choix de nomenclature et d’architecture sur une classe et sur l’ensemble du projet.

Pour répondre à ce problème, notre tech lead a partagé ses connaissances sur quelques principes du Clean Code et sur la Clean Architecture. On a pu commencer à les appliquer sur cette mission et sur les suivantes. Parallèlement, on a pu continuer à approfondir ces sujets en discutant avec d'autres développeurs plus expérimentés, en lisant des articles de blog, des livres techniques et même des lettres.

En tant que développeur, on rencontre des notions de Clean Code et d’architecture logicielle de manière dispersée dans notre carrière. Parfois, ces notions s'entremêlent et le lien entre tout ça n’est pas très clair. Mon objectif ici est de vous faire visualiser une carte mentale de toutes ces notions et comprendre comment les intégrer entre elles. Cet article est le premier d’une série de deux articles. Le but étant de partager aux développeurs des outils pour leur permettre de proposer des choix de nomenclatures et d’architectures à différents niveaux d’un projet. On va donc aborder quelques notions de conception logicielle associées à des exemples de code simplifiés en TypeScript et non exhaustifs. Ce premier article se concentre plutôt sur des principes du Clean Code et comment les appliquer de manière spécifique. Le second permettra de comprendre comment appliquer ces principes à un niveau plus global au sein d’un écosystème entier.

Les 5 principes SOLID

Cet acronyme regroupe un ensemble de principes de conception de logiciels qui enseignent une façon de structurer nos fonctions et nos classes. Ils ne sont pas nécessairement utilisés tous en même temps. Vous pouvez appliquer un ou plusieurs de ces principes selon votre contexte.

La responsabilité unique (Single responsibility)

Une classe ou une méthode doit avoir une seule responsabilité. Cela implique qu’il faut regrouper les parties du code qui changent pour les mêmes raisons. Et aussi, qu’il faut séparer celles qui changent pour des raisons différentes.

❌  Voici un exemple de code qui ne respecte pas le principe de responsabilité unique :

export class User {
  id: number;
  username: string;

 constructor(private keycloak: Keycloak, id: number, username: string) {
    this.id = id;
    this.username = username;
  }

  async login(): Promise<void> {
    return this.keycloak.login();
  }

  async logout(): Promise<void> {
    return this.keycloak.logout();
  }
}

Dans le code ci-dessus, la classe User a plusieurs responsabilités :

  • Définir l’entité User ;
  • En cas de règles de gestion sur le format de l’attribut username, la classe User devra en être responsable car l’attribut username est une primitive ;
  • Gestion de l’authentification avec Keycloak.

Admettons qu’on souhaite changer de gestionnaire d’authentification. On est obligés de modifier la classe User alors que ça n’implique aucun changement sur cette entité. De la même façon, si on souhaite ajouter un attribut dans l’entité User, le design de l’authentification ne devrait pas changer. Par ailleurs, en cas d’un dysfonctionnement dans l’une des responsabilités, cela impacte les autres. Donc votre code devient difficile à faire évoluer.

✅  Code qui respecte le principe de responsabilité unique :

export class User {
  id: UserId;
  username: Username;

  constructor(id: UserId, username: Username) {
    this.id = id;
    this.username = username;
  }
}

export class AuthenticationService {
  constructor(private keycloak: Keycloak) {}

  async login(): Promise<void> {
    return this.keycloak.login();
  }

  async logout(): Promise<void> {
    return this.keycloak.logout();
  }
}

Dans le code ci-dessus, chaque classe a une seule responsabilité :

  • Une entité User définit la représentation d’un utilisateur ;
  • Chacun des attributs de User est une value object représentée par une classe. Cela permet d’encapsuler la logique de chacun de ces attributs. Par exemple, pour ajouter des règles de gestion sur le type Username, il suffira de tester en isolation et d’enrichir le type Username ;
  • AuthenticationService implémente la gestion de l’authentification.

Important : Les values objects sont souvent mises de côté au profit de primitives mais elles peuvent apporter beaucoup de simplification.

Le principe d’ouverture-fermeture (Open-closed principle)

Une classe ou une méthode doit être ouverte à l’extension mais fermée à la modification.

Si on reprend la classe AuthenticationService précédente, on utilise Keycloak pour gérer l’authentification. Maintenant, si on veut évoluer vers un autre gestionnaire d’authentification, il faudra modifier la classe AuthenticationService. De ce fait, on casse le principe ouvert/fermé.

✅  Voici une alternative qui respecte le principe ouverture-fermeture :

export interface AuthenticationPort {
  login(): Promise<void>;
  logout(): Promise<void>;
}

export class KeycloakAuthenticationAdapter implements AuthenticationPort {
  constructor(private keycloak: Keycloak) {}

  async login(): Promise<void> {
    return this.keycloak.login();
  }

  async logout(): Promise<void> {
    return this.keycloak.logout();
  }
}

export class OktaAuthenticationAdapter implements AuthenticationPort {
  constructor(private okta: Okta) {}

  async login(): Promise<void> {
    return this.okta.login();
  }

  async logout(): Promise<void> {
    return this.okta.logout();
  }
}

Dans le code ci-dessus, on définit une interface AuthenticationPort contenant les méthodes dont notre application a besoin. Cette interface fera office de contrat afin d’assurer qu’on a bien à disposition l’ensemble de ces méthodes en cas d’évolution de la fonctionnalité d’authentification.

À chaque fois qu’on utilisera notre service d’authentification, il sera typé avec AuthenticationPort et non pas avec la classe qui implémente.

De cette façon, on peut implémenter AuthenticationPort dans notre AuthenticationService (renommé KeycloakAuthenticationAdapter) car il s’adapte à l’API de Keycloak. Et si on souhaite changer de gestionnaire d’authentification, il suffit de créer un nouvel adaptateur qui implémente à nouveau AuthenticationPort (par exemple OktaAuthenticationAdapter pour s’adapter à l’API d’Okta).

Le principe de substitution de Liskov (Liskov substitution principle)

Une instance de type A doit pouvoir être remplacée par une instance de type B, tel que B soit un sous-type de A et sans que cela impacte la cohérence du programme. Il ne s’agit pas là d’héritage mais bien de sous-typage. Chaque implémentation d’une interface est un sous-type de cette dernière.

Ce principe est facile à comprendre à première vue mais dans la pratique il est plus subtil. Il existe plusieurs règles concernant les sous-types autour de ce principe :

  • Contravariance des paramètres des méthodes : il faut utiliser un type équivalent ou plus générique que celui spécifié à l'origine ;
  • Covariance des types de retours : il faut utiliser un type équivalent ou plus spécifique que celui utilisé à l'origine ;
  • Les invariants doivent être conservés.

De nos jours, il est beaucoup moins fréquent de déroger à ces règles car beaucoup de langages de programmation appliquent plus ou moins ces règles à la compilation.

Il s’agit plus souvent d’erreurs liées à la structure et/ou la sémantique métier de l’application. Ce genre d’erreur peut être évité en développant la logique métier à travers du TDD (Test Driven Development), mais comment ?

Prenons un exemple : Créer un carré et un rectangle.

Intuitivement, vous allez vouloir créer une classe Square et une classe Rectangle. Peut-être que vous allez considérer qu’un carré est un rectangle spécifique ou un quadrilatère en utilisant une interface commune ou en appliquant une relation d’héritage.

Cela peut poser souci car les propriétés d’un carré diffèrent tout de même d’un rectangle même s’ils en ont en commun. C’est là qu’intervient le TDD car le design va émerger des contraintes plutôt que de la représentation.

Pour créer un carré et un rectangle, vous allez probablement devoir tester :

  • la création d’un côté (il ne doit pas être négatif ou égal à zéro et doit correspondre à un nombre) ;
  • la création d’un carré en renseignant un seul côté ;
  • la création d’un rectangle en renseignant un côté pour la largeur et un côté pour la longueur.

Ça peut ressembler à ceci pour la création d’un côté :

it('should not allow negative side', () => {
 expect(new Side(-1)).toThrowError('Side cannot be negative')
});

it('should not allow zero side', () => {
 expect(new Side(0)).toThrowError('Side cannot be zero')
});

it('should not allow not a number side', () => {
 expect(new Side(+'deux')).toThrowError('Side must be a number')
});

it('should get side', () => {
 expect(new Side(2).get()).toBe(2)
});

it('should compare sides for equality', () => {
 expect(new Side(2).equals(new Side(2))).toBe(true)
});

class Side {
 constructor(private readonly value: number) {
   if (value < 0) throw new Error('Side cannot be negative');
   if (value === 0) throw new Error('Side cannot be zero');
   if (Number.isNaN(value)) throw new Error('Side must be a number');
 }

 get(): number {
   return this.value;
 }

 equals(other: Side): boolean {
   return this.value === other.value;
 }
}

Un exemple concernant le carré :

it('should get square attributes', () => {
 const square = new Square(2);
 expect(square.getSide().equals(new Side(2))).toBe(true);
});

class Square {
 private readonly side: Side;

 constructor(side: number) {
   this.side = new Side(side);
 }

 public getSide(): Side {
   return this.side;
 }
}

Et enfin pour le rectangle :

it('should set width of rectangle builder', () => {
 const rectangleBuilder: RectangleBuilder = new Rectangle.builder();
 rectangleBuilder.setWidth(2);
 expect(rectangleBuilder.getWidth()).toBe(2);
});

it('should set height of rectangle builder', () => {
 const rectangleBuilder: RectangleBuilder = new Rectangle.builder();
 rectangleBuilder.setHeight(3);
 expect(rectangleBuilder.getHeight()).toBe(3);
});

it('should build rectangle', () => {
 const rectangleBuilder: RectangleBuilder = new Rectangle.builder();
 rectangleBuilder.setWidth(2);
 rectangleBuilder.setHeight(3);
 const rectangle: Rectangle = rectangleBuilder.build();
 expect(rectangle.getWidth().equals(new Side(2))).toBe(true);
 expect(rectangle.getHeight().equals(new Side(3))).toBe(true);
});

interface RectangleBuilder {
 getWidth(): number;
 getHeight(): number;
 setWidth(width: number): void;
 setHeight(height: number): void;
 build(): Rectangle;
}

class Rectangle {
 private readonly width: Side;
 private readonly height: Side;

 private constructor(rectangleBuilder: RectangleBuilder) {
   this.width = new Side(rectangleBuilder.getWidth());
   this.height = new Side(rectangleBuilder.getHeight());
 }
 
 public getWidth(): Side {
   return this.width;
 }
 public getHeight(): Side {
   return this.height;
 }

 static builder = class builder implements RectangleBuilder {
   width: number = 0
   height: number = 0

   getWidth(): number {
     return this.width;
   }
   getHeight(): number {
     return this.height;
   }
   setWidth(width: number): RectangleBuilder {
     this.width = width;
     return this;
   }
   setHeight(height: number): RectangleBuilder {
     this.height = height;
     return this;
   }
   build(): Rectangle {
     return new Rectangle(this);
   }
 }
}

Ce qui est important, c’est que vous ayez pu révéler les subtilités de chaque concept avant d’établir la structure de votre code. Vous pouvez donc les prendre en compte dans votre design plutôt que de la faire apparaître a posteriori.

En résumé, ici le TDD permet de développer pas à pas les besoins concrets de votre sémantique métier tout en vérifiant leur fonctionnement. Plutôt que de se baser sur une représentation globale du monde réel (un grand schéma UML par exemple), le TDD va faire émerger une définition alignée et précise entre vos classes et le contexte métier.

Ça ne veut pas dire que les schémas ne sont pas appropriés mais qu’il ne faut pas se baser entièrement sur un schéma statique pour le design.

La distinction des interfaces (Interface segregation)

Préférer utiliser une interface par classe ou client dès que possible plutôt qu’une interface commune. Plusieurs indications permettent de détecter qu’il y a peut-être une atteinte à ce principe :

  • Une interface avec beaucoup de méthodes ;
  • Des classes qui n'implémentent pas toutes les méthodes d’une interface et renvoient une erreur ;
  • Des paramètres non utilisés lorsqu’on implémente une méthode d’une interface ;
  • Nécessité de redéployer du code impacté par une modification qui ne le concerne pas.

❌  Code qui ne respecte pas le principe de ségrégation des interfaces :

interface PaymentMethodPort {
  pay(): void;
  storeFingerprint(): void;
}

class CreditCardAdapter implements PaymentMethodPort {
  pay(): void {
    console.log("Pay with third party payment manager.")
  }

  storeFingerprint(): void {
    console.log("Store fingerprint.")
  }
}

class StoreCardAdapter implements PaymentMethodPort {
  pay(): void {
    console.log("Pay with store points.")
  }
  
  storeFingerprint(): void {
    throw new Error('No fingerprint for store card.');
  }
}

Dans le code ci-dessus, on propose une interface pour gérer différentes méthodes de paiement. La classe StoreCardAdapter n’a pas de notion d’empreinte digitale car c’est un paiement avec les points présents dans la carte du magasin.

Du coup, elle n’implémente pas toutes les méthodes de l’interface PaymentMethod. Cela veut dire que cette interface doit être découpée ou être plus générique.

✅  Voici une alternative possible qui respecte le principe de ségrégation des interfaces :

interface PaymentMethodPort {
  pay(): void;
}
interface BiometricPaymentMethodPort extends PaymentMethodPort {
  storeFingerprint(): void;
}
class CreditCardAdapter implements BiometricPaymentMethodPort {
  pay(): void {
    console.log("Pay with third party payment processor.")
  }
  storeFingerprint(): void {
    console.log("Store fingerprint.")
  }
}
class StoreCardPort implements PaymentMethodPort {
  pay(): void {
    console.log("Pay with store points.")
  }
}

Ci-dessus, on utilise une interface par type de paiement. Aussi, on utilise une interface commune pour gérer le paiement en lui-même avec la méthode pay() mais on peut très bien imaginer que chaque interface expose sa propre méthode de paiement.

L’inversion de dépendance (Dependency inversion)

Dépendre un maximum des abstractions plutôt que des implémentations. On ne souhaite pas que notre logique métier soit altérée par des technologies externes. Par exemple, le formatage ou la validation pour correspondre aux systèmes extérieurs n’est pas souhaitable car on devient dépendant de l’extérieur.

En d’autres termes, par ce principe vous pouvez choisir “Qui va dépendre de qui ?”. C’est une question très importante parce que plus vous dépendez de technologies extérieures, plus vous êtes sujet à des changements dont vous n’avez pas le contrôle.

Dans nos exemples précédents, on a pu utiliser une interface AuthenticationPort pour définir ce dont notre application avait besoin pour la gestion de l’authentification. De cette manière, on protège les parties du code qui nécessitent cette dépendance par un contrat d’interface qui stipule quelles méthodes sont disponibles quoi qu’il arrive de l’extérieur.

Pourquoi utiliser les principes SOLID ?

  • Faciliter la compréhension du code ;
  • Diminuer les dépendances entre les différentes classes et interfaces ;
  • Éviter ou diminuer les effets de bords lorsque le code évolue ;
  • Faciliter l’écriture des tests.

En attendant la suite… quelques bonnes pratiques

Il existe une multitude de pratiques pour simplifier et optimiser son code. Certaines pratiques sont applicables rapidement et d'autres demandent plus d'effort et de temps. Donc, il est préférable dans un premier temps d’essayer d’appliquer de manière très itérative, des principes simples puis d’en ajouter au fur et à mesure de votre niveau d’apprentissage.

KISS, YAGNI, DRY ?

Définitions

KISS : “Keep It Simple, Stupid” préconise que toute complexité non indispensable devrait être évitée si possible.

YAGNI : “You’re Aint Gonna Need It” consiste à implémenter des choses lorsque l’on en a vraiment besoin.

DRY : “Don't repeat yourself” est un principe visant à réduire la répétition des schémas logiciels, en les remplaçant par des abstractions ou en utilisant la normalisation des données pour éviter les redondances.

Approche pour les appliquer

Lorsqu’on découvre un ou plusieurs de ces principes, on a tendance à les appliquer de manière dogmatique ce qui peut conduire à des modèles plus compliqués. L’idée avant d’appliquer ces principes serait de challenger votre choix de design. En se posant quelques questions pour savoir si vous en avez besoin ou pas :

  • Quelle problématique ce design vise-t-il à résoudre dans notre contexte ?
  • Quels sont les impacts négatifs ?
  • Cela va-t-il engendrer des interdépendances ?
  • Des problèmes de performances ?
  • Des problèmes d’état ?

Toutes les réponses à ces questions vont dépendre du contexte métier de votre projet ainsi que le stade auquel il se trouve. Il est très souvent préférable de vérifier si en appliquant l’un de ces principes vous ne cassez pas un des principes SOLID.

Dans le prochain article, on présentera comment utiliser ces principes dans l’architecture d’un projet en concluant sur les avantages apportés par ces derniers et dans quelles mesures ils peuvent être appliqués.