Introduction

A travers ma lecture du livre Clean architecture de Robert C. Martin aka Uncle Bob ma première réaction a été :

why are we still here ? just to suffer ? - Kaz, Metal gear solid

Puis après plusieurs relectures et une très certaine concentration :

mind-blow

Je pense sans exagérer que ce livre est une clé permettant de découvir un domaine méconnu mais pourtant très important qu'est l'architecture dans notre domaine.

Une fois que nous aurons vu les grandes bases, je vous propose de construire ensemble un petit jeu : 🦀 CrabKingdom 🦀

CrabKingdom permettra au joueur d'incarner un crabe ayant pour seul but la conquête du monde !

Afin de créer ce jeu sympathique, nous ferons appel à la clean archi à travers quelques exemples !

La clean archi... Mais pourquoi ?

Parfois vu comme un contexte de haut niveau coupé de tout détail bas niveau, l'architecture est en réalité un mix des deux.

Les détails bas niveau ainsi que les structures haut niveau font tous deux partie du design du projet.

Pour illustrer ceci, on peut prendre l'exemple d'un architecte:

Si demain vous faites appel à un architecte pour les plans de votre maison qu'allez vous voir ? Les murs, ainsi que l'agencement des pièces. Mais également tous les petits détails comme l'emplacement des lampes et des interrupteurs les contrôlant, l'emplacement réservé au four, chaque radiateur... Tous les détails pour que l'habitat soit parfaitement optimisé dans son architecture, pour que rien ne soit laissé au hasard.

On pourrait alors penser que ces décisions ne sont pas applicables au design d'une application, alors quel est l'intérêt de penser l'architecture de ses devs quotidiens ?

The goal of software architecture is to minimize the human resources required to build and maintain the required system - Uncle Bob

Concrètement, cela veut dire qu'au plus la conception d'un logiciel est laisée au hasard, au plus il sera difficile d'y ajouter des fonctionnalités dans le temps. Il faudra donc (beaucoup) plus de ressources pour produire une même fonctionnalité.

Alors que le coût de production de chaque release d'un projet lambda augmenterait de manière exponentielle avec le temps, notre 🦀CrabKingdom🦀 lui aura dès le début un coût de production plus élevé, mais qui ne devrait pas beaucoup augmenter dans le temps. Cela fait un peu penser au lièvre et la tortue 🐢. Sur le long terme, nous sommes donc gagnants ! 🦀 happy crab noises 🦀

Pour illustrer ceci, voici un diagramme tiré du livre qui représente le coût par ligne de code d'un projet créée par une équipe sous pression constante du business :

coût-par-ligne-de-code-sans-clean-archi

On peut déduire plusieurs choses de ce diagramme :

  • La productivité de l'équipe a diminué
  • La DX (expérience developpeur) est probablement au plus bas
  • Sur le long terme, il faudra plus de développeurs afin de combler le manque de productivité créé par la dette technique générée

Voilà pourquoi il est important de se poser 🐢, de bien saisir la globalité du projet ainsi que de bien penser l'implémentation architecturale qui sera mise en place !

Notez cependant que ceci est moins vrai pour un projet de petite envergure, qui n'est pas voué à évoluer dans le temps, ce qui est assez rare.

Mais alors comment justifier de passer du temps à developper son architecture plutôt que créer de la valeur directe ?

  • If you give me a program that works perfectly but is impossible to change, then it won't work when the requirements change, and I won't be able to make it work. Therefore, the program will be useless - Uncle Bob
  • If you give me a program that does not work but is easy to change, then I can make it work, and keep it working as requirements change. Therefore, the program will remain continually useful - Uncle Bob

Cette citation nous apprend que la valeur ne vient pas forcément de la fonctionnalité en elle même, mais de la capacité à pouvoir changer le comportement de son application facilement afin de pouvoir s'adapter au mieux aux exigences business.

La matrice d'Eisenhower vient bien compléter ce propos :

I have two kinds of problems, the urgent and the important. The urgent are not important and the important are never urgent - Dwight D. Eisenhower

matrice-eisenhower

Les fonctionnalités d'un logiciel sont souvent urgentes mais pas toujours importantes, et l'architecture est importante, mais jamais vraiment urgente !

Il est donc du devoir des développeurs avertis de communiquer sur l'importance de l'architecture au dessus des fonctionnalités urgentes.

Maintenant que nous sommes d'accord sur l'importance de l'architecture dans un projet, rentrons dans le vif du sujet !

La clean architecture - divide to conquer

L'objectif principal de la clean architecture est de diviser le code en briques, chaque brique traitant d'un sujet. Les briques sont elles-même divisées en couches. Chaque brique comporte au moins une couche d'accès au business (sinon cela reviendrait à créer du dead code) et une couche d'interfaçage permettant la connexion inter-briques.

L'avantage majeur de cette division est :

  • L'indépendance vis-à-vis des frameworks : les frameworks sont alors utlisés comme des outils plutôt que l'inverse
  • L'indépendance vis-à-vis de l'interface utilisateur
  • L'indépendance vis-à-vis de la base de données
  • La granularité des tests : il est facile de cibler précisémment une couche / brique

L'image ci-dessous représente bien cette division :

clean-archi

La règle la plus importante ici est celle de la dépendance, ou plutôt de l'inversion de dépendances (càd la dépendance des couches basses envers les couches hautes). Sur le schéma, cela veut dire qu'aucune couche interne ne peut pointer sur une couche externe, le code bas niveau ne peut avoir accès à aucune donnée des niveaux supérieurs.

De fait, il devient aisé de modifier les couches supérieures qui sont les plus à même de varier dans le temps, les règles métier étant le coeur même de l'application, on les appellera cas d'usages.

Une entité peut être un objet avec des méthodes ou un ensemble de structures de données et de fonctions. Ces entités vont être amenées à être utilisées par les règles plus haut niveau, voire des applications externes dédiées.

Les cas d'usages contiennent des règles métier spécifiques à l'application. Ils encapsulent et implémentent tous les cas d'utilisation du système. Si des modifications du comportement de l'application doivent avoir lieu, cette couche sera impactée.

La couche d'interfaçage sert de passerelle entre les entités / cas d'usages et le framework utilisé (ex: base de donnée, UI). Cette couche va permettre de transférer les données dans le format souhaité d'un côté à l'autre.

Voyons maintenant à travers quel type de programmation nous pouvons utiliser cela à bon escient.

The journey begins ! 🦀

Terminons cette première partie par un peu de pratique toute simple.

Nous allons implémenter ensemble une interface qui sera utilisée par plusieurs services. Cette interface nous permettra de gérer notre héro : Mister Crabo un crabe 🦀 très stylé adorant la pizza 🤌

La prémière chose que nous voulons implémenter est la possibilité pour une créature de faire des happy noises lorsqu'il est heureux 🦀

folder-archi

Tout d'abord, voici l'arborescence du projet. On peut y voir 3 parties bien disctinctes:

Tout d'abord le domaine. C'est là où nous créérons nos interfaces. Le domaine représente toute la connaissance métier de notre application, c'est ici que nous pourrons définir nos règles de typage et nos formats de données. Le domaine est le coeur même de notre application, c'est ce qui la définit.

Ensuite, l'applicatif. C'est la partie active de notre application qui, dictée par notre domaine, fera les actions demandées par l'infrastructure.

Enfin, la partie infrastructure. C'est à cet endroit que nos use cases sont déclenchés, juste après une action utilisateur par exemple. En fait, la partie infrastructure sert de relais entre les services externes et l'applicatif.

Il y a en réalité une 4ème couche appelée injecteur ou configuration. Elle se situe au dessus de l'infrastructure et injecte les bons services à cette dernière. Par exemple: si dans une application nous avons accès à une base de données depuis l'infrastructure et que nous souhaitons changer l'écriture en base de données par l'écriture en mémoire, il est possible de spécifier le service utilisé directement dans l'injecteur ! De ce fait, notre infrastructure ne dépend plus des services qu'elle utilise.

Pour résumer:

  • Le domaine représente les règles métier de notre application, c'est ce qui la définit.
  • L'applicatif appelé depuis l'infrastructure peut effectuer des actions (use cases) modulées par le domaine.
  • L'infrastructure déclenche les use cases en fonction de ce qui l'a appelé (action utilisateur). Il n'a pas connaissance du domaine (et donc du métier, ce qui permet de protéger ce dernier).
  • L'injecteur permet l'injection des dépendances à notre infrastructure, ce qui permet à l'infrastructure de ne pas être dépendante de services tiers.

Domain

Crééons notre domain en premier lieu ! C'est ce qui nous aidera par la suite à construire notre applicatif.

Tout d'abord, nous avons besoin de définir ce qu'est une créature. Dans notre cas, un type, un nom unique qui nous permettra d'identifier nos entités et leurs caractéristiques.

domain/models/creature.ts

export type CreatureType = 'Human' | 'Crab';
 
export type Creature<GenericCreatureData> = {
  type: CreatureType;
  name: string;
  data: GenericCreatureData;
};

Et c'est tout ! Le plus important ici est le polymorphisme de Creature qui peut prendre n'importe quel type de data.

Ensuite, définissons notre entité Crab:

domain/models/crab.ts

export interface Crab {
  age: number;
  numberOfClaws: number;
  isHappy: boolean;
  noise: string;
}

Et notre entité Human:

domain/models/human.ts

export interface Human {
  age: number;
  isHappy: boolean;
  noise: string;
}

Nous venons de définir 2 entités faisant partie d'un type regroupant toutes les créatures !

Maintenant, il faut que nous puissions définir quelles actions nos créatures étant des crustacés peuvent faire. Par simplicité, je n'ajoute pas le type GenericCrustaceonData, mais voyez le comme un parent de Crab.

Ensuite, voyons l'interface nous permettant de générer des happy noises :

domain/models/crustaceon.interface.ts

export interface Crustaceon<GenericCrustaceonData> {
  clicClac(creature: Creature<GenericCrustaceonData>);
}

Cette interface nous indique que la méthode clicClac pourra être utilisée pour une entité de type crustacé (et donc par un Crab).

Maintenant, passons à l'applicatif !

Applicative

Nous allons tout simplement implémenter l'interface vue juste au dessus. L'objectif est qu'un crustacé puisse faire clicClac !

Avant toute chose le test:

applicative/crab-noises/crab-noises-generator.spec.ts

describe('CrabNoisesGenerator', () => {
  const crabNoisesGenerator = new CrabNoisesGenerator();
 
  const misterCrabo: Creature<Crab> = {
    type: 'Crab',
    data: {
      age: 2,
      isHappy: true,
      name: 'Mister crabo',
      noise: 'clic clac stylééé la pizza 🤌',
      numberOfClaws: 2,
    },
  };
 
  it('should make some happy noises if it is happy', () => {
    expect(crabNoisesGenerator.clicClac(misterCrabo)).toEqual(
      `${misterCrabo.data.name} is making some happy noises 🦀 : ${misterCrabo.data.noise}`,
    );
  });
});

Le test réprésente les règles métier que nous voulons implémenter. C'est notre phare dans cet obscurité, voilà pourquoi il devrait toujours être écrit en premier (TDD).

Ici, nous voulons qu'à travers un générateur de crabNoises nous puissions appeler la méthode clicClac produisant un bruit pour un crabe donné, Mister Crabo

Voici son implémentation !

applicative/crab-noises/crab-noises-generator.ts

export class CrabNoisesGenerator implements Crustaceon<Crab> {
  clicClac(creature: Creature<Crab>) {
    if (creature.data.isHappy) {
      console.log(`${creature.data.name} is making some happy noises 🦀 : ${creature.data.noise}`);
    }
  }
}

Voilà tout l'intéret d'avoir divisé nos composants ainsi. On peut maintenant créer n'importe quel crustacé et lui faire faire des happy noises !

Bien ! Maintenant, si nous voulons laisser la possibilité au joueur de personnaliser MisterCrabo en fonction de ses caractéristiques personnelles, il va nous falloir une API permettant d'envoyer ou recevoir ces informations. Etant donné que ce n'est pas le sujet ici, considérons que nous avons déjà cette API, et qu'il ne nous manque qu'à la brancher à nos composants !

Pensez-vous qu'on peut le faire directement ? Si nous faisons cela, nous ne respecterons pas la règle de dépendance et essaierons de brancher 2 couches qui ne sont pourtant pas directement liées d'après le diagramme représentant la clean architecture vu au dessus (infrastructure => use cases). Il nous manque la couche infrastructure !

Adapteurs

Pour brancher notre API à nos uses cases, nous allons créer des adapteurs qui seront là pour adapter les flux de données entrant / sortant. Le but ? Ne pas avoir de dépendance entre notre API et nos use cases. Il est en effet possible que l'implémentation de l'API change demain, ainsi que les flux de données (in/out). Il est donc important que nos interfaces définissant nos uses cases ne soient pas impactées par ce changement !

Il y a 2 types d'adapteurs :

  • Adapteurs de gauche, aussi appelés "driving adapters"
  • Adapteurs de droite, aussi appélés "driven adapters"
adapters-and-ports

Sur le schéma précédent, les adapteurs de gauches se situent... A gauche ! Entre l'interface utilisateur et le coeur de l'application.

Les adapteurs de droite se situent quant à eux entre le coeur de l'application et des services externes, comme une base de données par exemple.

  • Le but des adapteurs de gauche est de dire de faire quelque chose au coeur de l'application depuis une commande lancée depuis une interface utilisateur (CLI, REST...).

  • Les adapteurs de droites sont quant à eux appelés par le coeur de l'application pour faire quelque chose. Cela pourrait être une requête à une base de données, un appel à une API externe permettant d'envoyer un SMS...

Pour pouvoir se brancher entre les deux couches raccordées aux adapteurs, ils ont besoin d'un port, qui peut être représenté sous la forme d'une interface. Un port est ni plus ni moins une définition de l'interfaçage entre le coeur et les outils externes.

Adapteurs de gauche

Supposons que notre joueur dispose d'une magnifique interface, et que cette interface communique avec nous à travers un controlleur REST et resolver GraphQL.

Le but ici est de créer une commande permettant de générer une créature. Pour ceci, nous avons tout d'abord besoin d'un port. L'utilisateur pourra donner les différents attributs à sa créature :

  • nom
  • age
  • taille
  • couleur de cheveux

infrastructure/create-creature/create-creature.service.ts

export interface InputCreature {
  name: string;
  age: number;
  size: number;
  hairColor: string;
}
 
export class CreateCreatureService {
  createCreature(inputCreature: InputCreature) {
    if (!inputCreature) {
      throw new Error('Error parsing inputCreature');
    }
 
    let creature: Creature<Crab | Human>;
    if (inputCreature.name === 'Crab') {
      creature = {
        type: 'Crab',
        data: {
          age: inputCreature.age,
          name: 'MisterCrabo',
          isHappy: true, // as always
          noise: `MisterCrabo is ${inputCreature.age} years old !`,
          numberOfClaw: 2,
        },
      };
    } else {
      creature = {
        type: 'Human',
        data: {
          name: inputCreature.name,
          age: inputCreature.age,
          isHappy: inputCreature.age > 0 && inputCreature.age < 150,
          noise: `Hello I'm ${inputCreature.name} and my hair color is ${inputCreature.hairColor}`,
        },
      };
    }
 
    // execute use case (applicative) depending on the data parsed beyond
    // we could for example call clicClac if creature is a crustaceon !
  }
}

Et nos deux adapteurs !

infrastructure/create-creature/create-creature.http.controller.ts

@Controller('crabKingdom')
export class CreateCreatureHttpController {
  constructor(private readonly createCreatureService: CreateCreatureService) {}
 
  @Post('/createCreature')
  @ApiOperation({ summary: 'Create a creature' })
  async createCreature(@Body() body: CreatureData): Promise<void> {
    try {
      this.createCreatureService.createCreature(body);
    } catch (error) {
      throw new HttpException('Bad request', HttpStatus.BAD_REQUEST);
    }
  }
}

infrastructure/create-creature/create-creature.resolver.ts

@Resolver()
export class CreateCreatureGraphqlResolver {
  constructor(private readonly createCreatureService: CreateCreatureService) {}
 
  @Mutation()
  async create(@Args('input') input: CreatureData): Promise<void> {
    this.createCreatureService.createCreature(input);
  }
}

Adapteurs de droite

Maintenant que vous pouvons créer une créature depuis une interface utilisateur à travers 2 adapteurs de gauche, il serait bien d'inscrire les nouvelles créatures à venir dans un repository. Cet "ordre" d'écrire la donnée se fait depuis le coeur de l'application.

Supposons que nous voulions sauvegarder nos créatures précédemment créées dans un repository (fichier en local, DB...), voici notre interface :

infrastructure/creature-repository/creature.repository.ts

export interface CreatureRepository {
  save(creature: Creature<Human | Crab>): boolean;
}

La différence remarquable avec un adapteur de gauche est que nous allons maintenant implémenter cette interface, plutôt que de retourner un objet correspondant à cette interface:

On peut dans un premier temps imaginer avoir besoin d'une écriture des données en local dans un fichier :

infrastructure/creature-repository/file-system.creature.repository.ts

export class FileSystemCreatureRepository implements CreatureRepository {
  save(creature: Creature<Human | Crab>) {
    // fs.writeFileSync
    return true;
  }
}

On pourrait également avoir besoin d'écrire dans une base de données :

infrastructure/creature-repository/postgre-system.creature.repository.ts

export class PostgreSystemCreatureRepository implements CreatureRepository {
  save(creature: Creature<Human | Crab>) {
    // knex.insert
    return true;
  }
}

Et enfin, ajoutons ceci à notre Service:

infrastructure/create-creature/create-creature.service.ts

  constructor(
    private readonly creatureRepository: CreatureRepository
  ) {}
 
    // ...
 
    const convertedCreature =
      this.creatureRepository.save(creature);

Le gros avantage des adapteurs de droite et de gauche est de pouvoir être indépendant des services utilisés, que ce soit une interface utilisateur qui peut être amenée à changer demain, ou une base de données. L'importance est de pouvoir utiliser ces élements externes comme des outils, plutôt que l'inverse !

Nous pouvons désormais créer une créature depuis une interface, et l'inscrire en base de données, le tout en étant parfaitement indépendant des services externes ! Let's make some happy noises 🦀

happy