Dans cet article, j'explique ma vision de la clean architecture dans une application frontend. Appliquer ces principes permet de structurer l'implémentation les règles métier d'une app front même lorsqu'elles deviennent complexes, tout en restant découplé des frameworks.

Après une une introduction expliquant plus en détail ce que j'entends par clean architecture côté front, nous verrons une application de ces principes avec un exemple concret, via l'utilisation de redux et redux-thunk (le code final est accessible sur ce dépôt GitHub).

Disclaimer : cet article n'est pas une référence détaillant comment il faut mettre en place la clean archi, et quelles sont les bonnes pratiques à suivre. Ces quelques bouts de code présentent ma façon de faire, qui peut être différente de ce dont vous avez l'habitude ; n'hésitez pas à les adapter à votre sauce.

Introduction

Pour synthétiser la clean archi en quelques mots (et c'est pas évident), c'est une façon d'organiser le code en d'établissant une séparation bien claire entre le code relatif aux règles métiers et le celui relatif aux frameworks. Voyons la partie règles métier (le domaine) et les frameworks (l'infrastructure) en deux temps.

Le domaine

Côté backend, la notion de "règle métier" est assez intuitive, ce sont les fonctionnalités du produit : créer un compte, ajouter au panier, payer une commande, etc. Mais côté front, ce n'est pas aussi évident, car notre domaine est composé de règles relatives à l'affichage et aux comportements de la page rendue par un navigateur.

Un exemple de règle frontend pourrait être :

"Lorsque l'utilisateur sélectionne une réponse à une question de QCM, elle doit être mise en évidence."

Notez qu'il n'est pas précisé comment la réponse doit être mise en évidence. Notre règle métier est abstraite, elle se contente de définir l'état "cette réponse est sélectionnée", et rien de plus.

Mais au bout d'un moment, il faudra bien afficher la réponse sélectionnée d'une certaine manière : en gras, sur fond bleu ou en comic sans. Et bien ça, le comment, ça ne fait pas partie de notre domaine, ça sera géré par un framework, et fera donc parti de l'infrastructure.

Mais revenons un instant sur notre règle métier. Prenons par exemple une app permettant de répondre à un questionnaire. Notre client aura emis un besoin, qui sera traduit par notre PM en un ensemble de règles, de spécifications, qui définissent ce qu'il doit se passer lorsque l'utilisateur intéragit avec l'app.

Par exemple :

  • lorsque l'utilisateur sélectionne une réponse à une question
    • si cette réponse est déjà sélectionnée, alors elle doit être désélectionnée
    • sinon
      • si la question est à choix multiples, alors la réponse doit être ajoutée à la sélection
      • si la question est à choix unique
        • si une réponse était déjà sélectionnée alors elle doit être désélectionnée
        • la nouvelle réponse doit être sélectionnée

Là, il commence à y avoir un petit algorithme qui se dessine. Et c'est ça, le domaine.

Le domaine, c'est un ensemble d'instructions, de conditions, de boucles et d'appels de fonctions, et qui, correctement agencés, permettent de répondre aux besoins métiers.

C'est là que ce trouve "l'intelligence de l'application". Et il n'y a pas de limite : les règles métier à implémenter peuvent devenir aussi complexe que nécessaire, sans pour autant devoir faire de compromis sur la qualité du code et des tests.

En définitive, nos règles métier nous permettront de savoir comment mettre à jour l'état abstrait de l'application (dans notre cas, un gros objet JSON), sans se soucier de comment cet état est affiché.

Notre domaine sera donc du typescript pur, qui ne repose sur aucune bibliothèque externe, formant ainsi un petit monde à lui tout seul, entièrement fonctionnel et cohérent, et qui ne fait que manipuler l'état de l'app.

L'infrastructure

Maintenant que nous voyons un peu mieux ce que sont les règles métiers frontend, let's talk about l'infrastructure, c'est-à-dire tout ce qui ne concerne pas le domaine.

La brique principale de la partie infra d'une application frontend est celle qui permet de transformer notre état, notre gros JSON, en éléments du DOM tout beaux tout propres et pleins de CSS. De nombreuses bibliothèques existent pour cela, vous ne connaissez qu'elles, je parle bien sûr de React, Vue, Svelte, Mithril, SolidJS et bien d'autres.

Dans cet article, j'ai choisi d'utiliser la bibliothèque la plus populaire à l'heure ou j'écris ces lignes : React. Bien sûr, les principes de la clean archi restent les mêmes quelle que soit la bibliothèque de rendu utilisée.

En clean archi, on sépare la couche infrastructure en deux catégories :

  • La partie primaire, c'est le code de l'infra qui invoque le domaine. Dans notre cas, ce sera principalement React, car c'est lui qui appelle le code métier en réaction à un événement. Par exemple, lorsqu'un composant est monté, ou bien en réponse à un clic.
  • La partie secondaire, c'est le code de l'infra qui est invoqué par le domaine (via une abstraction — on y reviendra). Par exemple, pour afficher une notification ou faire un call api.

Pour mettre en place une clean architecture avec React, nous voulons avoir d'un côté nos règles métier qui font évoluer l'état de l'app, et de l'autre React qui tient à jour l'interface en fonction de l'état actuel.

Avec React, pour que l'état de l'app soit lié à l'UI, il faut passer par des useState (ou useReducer) pour que les appels à setState trigger des re-render. Et ça, bah ça va poser problème : notre domaine doit être capable de manipuler l'état de l'app tout en étant découplé du reste du monde (et en particulier de notre bibliothèque de rendu préférée). Arf zut... Comment peut-on faire ?

Découpler notre code domaine de React n'est en réalité pas si compliqué : ce qu'on veut, c'est être capable de faire évoluer l'état de l'app, et que cet état soit correctement lié à l'UI.

Ce comportement, c'est exactement ce que permettent les bibliothèques de gestion d'état, comme Akita, Elf, Recoil, MobX, React Query ou encore Redux. Et c'est ce dernier que nous utilisons, car il a le bon goût d'exposer une API "bas niveau" : relativement simple et flexible.

But wait a minute, du coup notre code domaine va être couplé à Redux, alors qu'on voulait qu'il reste entièrement standalone !

Alors oui, effectivement. On va s'autoriser cette unique dépendance, nécessaire pour être capable de faire le lien entre notre état et React. C'est toujours mieux que notre domaine repose sur l'api de redux plutôt que celle de React, non ?

On a vu la partie primaire de l'infra, la bibliotèque de rendu, qui sera en charge d'appeler le domaine (via redux) en réaction à des événements liés à l'UI. Concernant la partie secondaire, elle intervient lorsque le domaine a besoin de faire un call API, de trigger un redirect, remonter une erreur sur bugsnag ou sentry, accéder au local storage, ou encore faire un simple appel à setTimeout() ou new Date().

Toutes ces fonctionnalités ont un point commun : elles sont relatives aux frameworks (externes au domaine), mais dont le domaine dépend. On reviendra plus tard sur cette partie de l'infra.

Lien entre le domaine et l'infrastructure

Nous avons donc pris la peine de séparer le code en deux partie, très bien, voyons maintenant comment ces deux parties vont pouvoir communiquer. Tout d'abord, plutôt que de parler de "partie", on va plutôt utiliser le mot "couche" ("layer" en anglais). On va représenter la couche domaine au centre, comme le noyeau d'un fruit, entouré par l'infrastructure, la chaire du fruit.

Au coeur de la clean architecture se trouve une règle qui va driver notre façon de concevoir, d'architecturer notre code : "The Dependency Rule", selon Robert C. Martins (Uncle Bob). Cette règle nous impose une chose :

Les couches internes ne peuvent pas avoir connaissance des couches externes.

Dans notre cas, ça veut dire que la couche domaine n'a pas le droit de connaître (de dépendre) de la couche infra. Et finalement, c'est quelque chose que nous avons établis dès le début : le domaine ne doit dépendre de rien.

  • Petit aparté : qu'est-ce que cette règle signifie ? Prenez le temps d'y réfléchir, car c'est sur cette règle que repose la clean architecture (et pas que dans le monde frontend). Ma compréhension de cette règle, c'est qu'elle permet de protéger le domaine. En la respectant, il est possible de changer l'infra comme bon nous semble, en êtant certain de ne pas avoir cassé de règle métier. Autrement dit, la seule et unique raison qui justifie un changement du code domaine, c'est un changement des règles métier. Cette propriété devient incroyablement puissante lorsque le métier est complexe, car elle nous permet de changer de framework en toute confiance (bibliohèque de routing, voire de rendu, d'ORM côté back, etc.), et ce sans impacter le domaine.
clean-archi-uncle-bob

Si vous avez déjà connaissance de la clean architecture, ce schema devrait vous parler, car c'est celui utilisé par Uncle Bob. Lui, il découpe le code en plus de couches, alors que moi je n'ai gardé que la couche bleue ("Frameworks & Drivers") et jaune ("Entreprise Business Rules").

En clean archi frontend, je n'ai jamais eu besoin d'aller plus loin, mais il est toujours possible d'ajouter des couches intermédiaires lorsque le code devient complexe.


On va représenter notre clean archi de cette manière, avec le domaine au centre, une partie de l'infra à gauche, la partie primaire, et l'autre à droite, la partie secondaire. Voici un exemple de flow d'éxécution du code, faisant intervenir nos deux couches.

clean-archi-schema

Notez qu'il y a des flèches qui pointent du domaine vers la partie secondaire de l'infra, ce qui casse notre règle d'or. Mais… c'est tout de même possbile si le domaine dépend d'une abstraction et non pas directement d'une implémentation. Nous verrons comment on met ça en place un peu plus loin dans l'article.

Je reviens tout de même rapidement sur une notion présente sur le schema d'Uncle Bob, la couche applicative. Cette couche contient le code qui permet de faire le lien entre le domaine et l'infra, via ce qu'on appelle souvent des use cases (ou interactors).

Pour nous, côté front, on va concidérer que cette couche applicative est confondue la couche domaine, notre code métier sera donc en quelques sorte implémenté directement dans nos use cases.

To sum up, l'objectif de la clean archi est de découpler le code métier des frameworks, en passant par un gestionnaire d'état pour s'abstraire de la bibliothèque de rendu. En définitive, l'objectif est de n'avoir aucun code relatif à l'intelligence de l'application dans les composants react et les hooks, permettant ainsi de faire évoluer et de tester nos règles métier sans être contraint par les problématiques liées aux frameworks.

Let's code

Ahhh… Voyons maintenant ce que ça donne en pratique, ça va sûrement éclaircir pas mal de choses.

Une connaissance de redux est un pré-requis pour bien comprendre les exemples qui arrivent. Si vous n'avez jamais eu l'occasion d'utiliser cette bibliothèque, j'ai écrit un article pour en expliquer les bases. Connaître redux-thunk n'est cependant pas nécessaire.

Nous allons utiliser redux pour implémenter la logique d'une app front, permettant à l'utilisateur de répondre à un questionnaire. Nous ne verrons pas ici la partie infra, car afficher des textes, des inputs et des boutons n'est pas l'objectif de l'article (référez-vous au dépôt GitHub si vous voulez voir comment c'est fait).

Nous implémenterons deux use cases : "(dé)sélectionner une réponse", et "valider la réponse sélectionnée". Ici, nos use cases seront simplement des fonctions qui vont encapsuler de la logique métier.

Les données que nous allons manipuler, les entités métier, sont des questions et des réponses, représentées par les types typescript suivants :

type Question = {
  id: string;
  text: string;
  answers: Answer[];
  validated: boolean;
};
 
type Answer = {
  text: string;
  selected: boolean;
};

Premier use case à implémenter : sélectionner et désélectionner une réponse. On va assumer que la question est à choix unique, il ne sera donc pas possible d'avoir plusieurs réponses sélectionnées simultanément (libre à vous de gérer les QCM pour vous entraîner si ça vous dit).

Nos règles métier sont donc :

  • lorsqu'une réponse est sélectionnée, elle doit être mise en évidence
  • si elle était déjà sélectionnée, alors elle doit être désélectionnée
  • si une autre réponse était déjà sélectionnée alors cette dernière doit être désélectionnée et la nouvelle doit être sélectionnée

Notre use case est une fonction qui prend deux paramètres : la réponse à toggle et le store redux (oui, il n'y a pas de mot pour dire "toggle" en français, mais on se comprend).

const toggleAnswer = (answer: Answer, store: Store) => {
  // we use a selector to retrieve the currently selected answer (if any)
  const selectedAnswer = selectSelectedAnswer(store.getState());
 
  // if the given answer is the one that is already selected, we mark it as unselected
  if (answer === selectedAnswer) {
    store.dispatch(setAnswerSelected(answer, false));
    return;
  }
 
  // if there was an answer already selected, we mark it as unselected
  if (selectedAnswer) {
    store.dispatch(setAnswerSelected(selectedAnswer, false));
  }
 
  // we mark the given answer as selected
  store.dispatch(setAnswerSelected(answer, true));
};
 
const store = createStore(/* ... */);
const answer = /* ... */;
 
toggleAnswer(answer, store);

Le code du store (action creators, reducers, selectors) est tiré de l'article redux 101.

Modifions un peu cette fonction : plutôt que de donner tout le store en second paramètre, on va ne donner que les fonctions getState et dispatch. Pourquoi, me demandez-vous ? Vous verrez bien. Et si ça se trouve, vous voyez déjà où je veux en venir...

const store = /* ... */;
 
type GetState = typeof store.getState;
type Dispatch = typeof store.dispatch;
 
const toggleAnswer = (answer: Answer, getState: GetState, dispatch: Dispatch) => {
  // ...
};
 
toggleAnswer(answer, store.getState, store.dispatch);

Bien bien bien, continuons.

Maintenant, plutôt que de déclarer une fonction à trois paramètres, on va déclarer une fonction qui ne prend que l'answer en paramètre, et qui retourne une nouvelle fonction qui prend getState et dispatch. #curry

const toggleAnswer = (answer: Answer) => {
  return (getState: GetState, dispatch: Dispatch) => {
    // ...
  };
};
 
// wtf?! why u doin dis
toggleAnswer(answer)(store.getState, store.dispatch);

C'est bon, on est là où je voulais en venir. Cette forme, pas très commode, reconnaissons-le, a une particularité intéressante : la fonction interne est une fonction à deux paramètres, le premier permettant d'accéder à la state en lecture (getState), et le second en écriture (dispatch).

Et bien ce type de fonction a un nom, on appelle ça un thunk.

type Thunk = (getState: GetState, dispatch: Dispatch) => unknown;
 
const toggleAnswer = (answer: Answer): Thunk => {
  return (getState, dispatch) => {
    // ...
  };
};

Ce type de fonction est très pratique, car c'est une fonction générique capable de manipuler le store comme bon lui semble.

Tellement pratique d'ailleurs, que la bibliothèque redux-thunk permet d'ajouter au store la possibilité d'appeler dispatch non plus avec des actions uniquement, mais aussi avec des fonctions. Et vous l'aurez sûrement deviné, les fonctions qu'on va pouvoir dispatcher doivent être des thunks.

toggleAnswer(answer)(store.getState, store.dispatch);
 
// can be replaced with
 
store.dispatch(toggleAnswer(answer));

Arrêtons-nous une minute... On est en train de dire que, via redux-thunk, on a la possibilité d'exécuter un use case, c'est-à-dire du code qui manipule le store. Cette fonction peut :

  • lire la state à tout moment
  • muter la state selon des règles métier
  • dispatcher d'autres use cases
  • être asynchrone (retourner une Promise)

Quand j'ai vu redux-thunk pour la première fois, je n'ai pas vraiment réalisé la puissance que ça apporte, alors que le principe n'est vraiment pas compliqué (et l'implémentation non plus d'ailleurs).

Avec redux-thunk, nous sommes donc capables d'orchestrer les changements d'états d'une app de manière asynchrone, une tâche qui peut s'avérer complexe lorsque les besoins métiers sont complexes eux aussi (on parle de complexité essentielle, ou essential complexity).

Un exemple de use case asynchrone ? Bien sûr ! Après avoir sélectionné une réponse, on va implémenter le use case de validation, qui doit trigger un call au backend pour enregistrer la réponse de l'utilisateur.

const validateAnswer = (): Thunk => {
  return async (dispatch, getState) => {
    const question = selectQuestion(getState());
    const selectedAnswer = selectSelectedAnswer(getState());
 
    if (!selectedAnswer) {
      throw new Error('no answer selected');
    }
 
    await fetch(`/question/${question.id}/answer`, {
      method: 'POST',
      body: selectedAnswer.text,
    });
 
    dispatch(setQuestionValidated());
  };
};

Ici, notre use case ne prend pas de paramètre, on pourrait donc se passer de la fonction externe. Mais on va tout de même la garder pour rester consistent : on considérera qu'un use case sera toujours une fonction qui retourne un thunk.

Ah oui au fait, pour pouvoir utiliser redux-thunk, il faut ajouter un brin de config lors de la création du store. Pour ce faire, redux a un système de plugin sous forme de middlewares. C'est très puissant, j'aimerais beaucoup en parler, mais c'est en dehors du scope de cet article.

import thunk from 'redux-thunk';
import { createStore, applyMiddleware } from 'redux';
 
const store = createStore(rootReducer, applyMiddleware(thunk));

Revenons un instant sur le typage de nos thunks. Le type Thunk donné plus haut existe déjà dans redux-thunk, et est défini de manière plus complète que ma version simpliste. C'est un type générique qui s'appelle en réalité ThunkAction, et qui accepte quatre paramètres de type :

  1. le type de retour du thunk
  2. le type de la state
  3. le type d'un truc qu'on verra plus tard
  4. le type de toutes les actions

Pour simplifier la lecture du code, plutôt que de passer tous ces paramètres, on va créer un type spécifique à notre app, avec les types statiques qu'on connaît déjà (la state et les actions).

import { ThunkAction } from 'redux-thunk';
 
type Thunk<ReturnType = void> = ThunkAction<ReturnType, State, unknown, Action>;
 
const toggleAnswer = (answer: Answer): Thunk => {
  return (getState, dispatch) => {
    /* ... */
  };
};
 
const validateAnswer = (): Thunk<Promise<void>> => {
  return async (getState, dispatch) => {
    /* ... */
  };
};

Les types State et Action viennent de l'article sur redux.


Ça-y-est, on a réussi à entièrement découpler notre code domaine de notre infra !

Entièrement ? Non... un petit groupe d'irréductibles lignes de code résistent encore et toujours à notre clean archi ! Vous voyez de qui je parle ? La partie secondaire de l'infra, celle qui est appelée par le domaine !

D'ailleurs, c'est ce qu'il se passe dans le use case validateAnswer : la fonction fetch ne fait certainement pas partie de notre domaine, elle fait partie du framework (ici, de l'environnement donné par le navigateur).

Comment est-il possible d'utiliser cette fonction tout en gardant notre domaine standalone ? Nous allons devoir inverser cette dépendance.

Inverser la dépendance du domaine vers l'infrastructure

L'inversion de dépendences, c'est ce qui permet au code domaine de dépendre de fonctionnalités qui lui sont externes. C'est aussi le "D" de l'acronyme SOLID (Dependency Inversion Principle), et c'est un principe fondamental lorsqu'on parle d'architecture logicielle en règle générale.

La couche domaine n'a pas le droit d'avoir connaissance du reste du monde, ok on l'a déjà dit plein de fois. Mais bon, on aimerait bien que le domaine puisse être capable de contacter une API, afficher une notification, ou même juste écrire un log dans la console.

Voilà ce qui va se passer : le domaine va définir une interface, qui doit être vu comme un contrat. Ce contrat va établir la liste des choses dont le domaine dépend, mais qui n'est pas de son ressort.

Cette interface, on l'appelle aussi une abstraction, car ce sont des fonctionnalités abstraites du point de vue du domaine. Le domaine dicte ce dont il dépend, et impose à celui qui l'utilise de lui fournir une implémentation de ce contrat.

Et hop ! Tour de magie, le domaine ne dépend plus d'une implémentation, mais d'une abstraction qu'il a lui-même définit. C'est maintenant le code qui implémente ce contrat qui dépendra de l'infrastructure, et non plus le domaine.

Voilà ce que ça pourrait donner (mais en vrai, on va faire mieux que ça) :

interface DomainDependencies {
  fetch(method: string, url: string) => Promise<unknown>
  readLocalStorage(key: string) => string
  getCurrentUrl() => string
  // ...
}
 
const domainFunction = async (dependencies: DomainDependencies) => {
  await dependencies.fetch('POST', '/some/url');
  // ...
}

L'avantage de ce tour de magie, c'est qu'il est maintenant possible de donner une implémentation différente du contrat en fonction du contexte dans lequel le code domaine est utilisé. Je pense principalement au contexte de l'app lancée "en vrai" (en prod ou en dev), et à celui des tests unitaires, mais on pourrait en imaginer d'autre (dans storybook par exemple).

Le code qui implémente les interfaces du domaine, ce sera la partie secondaire (de droite) de l'infrastructure. On parle aussi de partie driven, car elle est "drivée", invoquée, par le domaine. Et la partie primaire, celle qui invoque le domaine, est la partie driving de l'infra.


Il existe plusieurs manières d'appliquer l'inversion de dépendances, souvent via une bibliothèque d'injection de dépendances (DI, Dependency Injection), comme inversify, typedi ou brandi, et parfois même built-in dans un framework plus large comme nest ou via l'API context de React.

Il est aussi possible d'inverser une dépendance à la mano cela dit, c'est d'ailleurs ce qu'il se passe quand on passe une fonction en paramètre à une autre fonction. Dans notre cas, il aurait été possible de passer la fonction fetch en paramètre à notre use case.

Les devs qui ont imaginé redux et redux-thunk avaient déjà cette pratique en tête, ils ont donc implémenté un système permettant d'inverser des dépendances dans redux-thunk nativement. Et vous allez voir, c'est tout bête.

Lors de la création du store, on peut définir une valeur qui sera passée en troisième paramètre à tous les thunks, après getState et dispatch. Cette valeur, redux-thunk l'appelle de manière hyper générique extraArgument, mais nous on va l'appeler dependencies.

const dependencies = {
  fetch,
};
 
const store = createStore(rootReducer, applyMiddleware(thunk.withExtraArgument(dependencies)));
 
// or with redux toolkit
const store = configureStore({
  middleware: (getDefaultMiddlewares) =>
    getDefaultMiddlewares({
      thunk: {
        extraArgument: dependencies,
      },
    }),
});

Et vous vous souvenez du troisième paramètre de type de ThunkAction ? Bin en fait, c'était tout simplement le type des dépendances !

const dependencies = {
  fetch,
};
 
type Dependencies = typeof dependencies;
 
type Thunk<ReturnType = void> = ThunkAction<
  ReturnType,
  State,
  Dependencies, // instead of unknown
  Action
>;

Il est maintenant possible de modifier notre use case pour qu'il ne dépende plus directement de fetch, mais indirectement via le troisième paramètre du thunk.

const validateAnswer = (): Thunk => {
  return async (dispatch, getState, dependencies) => {
    // ...
 
    await dependencies.fetch(`/question/${question.id}/answer`, {
      method: 'POST',
      body: selectedAnswer.text,
    });
 
    // ...
  };
};

C'est mieux, mais… c'est pas encore ça. Pour l'instant, notre domaine ne dépend plus directement de fetch, mais il dépend toujours de sa signature, et par extension, de son API.

Plutôt que d'exposer la fonction fetch directement, il serait préférable d'exposer une fonction qui a un sens métier, une fonction qui cacherait l'appel à fetch derrière un nom qui a un sens domaine.

La fonctionnalité dont notre domaine dépend, ce n'est pas vraiment fetch en réalité, mais plutôt la possibilité d'enregistrer la réponse. Ici aussi, on aimerait rester abstrait, et éviter de préciser comment cette réponse est enregistrée.

Pour implémenter cette dépendance, rien de mieux que de tirer parti de la programmation orientée objet. Notre domaine dépend de la fonctionnalité "enregistrer une réponse", il devra donc définir une interface contenant une méthode saveAnswer. Cette interface fait partie de la couche métier, et sera le contrat entre le domaine et le monde extérieur.

interface QuestionPort {
  saveAnswer(question: Question, answer: Answer): Promise<void>;
}

Petit point de vocabulaire, dans le jargon de la clean archi, on appelle ce type d'interface un port, et toute classe de l'infrastructure qui l'implémente, un adapteur. Ces mots devraient vous sembler familiers si vous connaissez l'architecture hexagonale. On parle donc souvent d'adapteur primaire, driving adapter ou adapteur de gauche, et d'adapteur secondaire, driven adapter ou adapteur de droite (mais rien à voir avec la politique).

Une implémentation de notre port (qui sera un adapteur de droite), peut donc se faire ainsi :

class HttpQuestionAdapter implements QuestionPort {
  async saveAnswer(question: Question, answer: Answer): Promise<void> {
    await fetch(`/question/${question.id}/answer`, {
      method: 'POST',
      body: answer.text,
    });
  }
}

Maintenant que la logique de fetch est encapsulée dans un adapteur dont le nom a un sens métier, son utilisation depuis notre use case devient encore plus explicite, simple à lire et à comprendre, et surtout découplé de l'infrastructure : notre code domaine ne dépend plus de la notion d'URL, de méthode POST ou de body.

type Dependencies = {
  questionAdapter: QuestionPort;
};
 
const dependencies: Dependencies = {
  questionAdapter: new HttpQuestionAdapter(),
};
 
const validateAnswer = (): Thunk<Promise<void>> => {
  return async (dispatch, getState, { questionAdapter }) => {
    const question = selectQuestion(getState());
    const selectedAnswer = selectSelectedAnswer(getState());
 
    await questionAdapter.saveAnswer(question, answer);
 
    // ...
  };
};

Mais alors, quel est l'intérêt d'être resté abstrait lors de la définition du contrat dans notre domaine ? Très bonne question, et je vous remercie de l'avoir posée.

Étant donné qu'il n'est pas précisé comment la réponse à la question doit être sauvegardée, il est tout à fait possible d'implémenter d'autres adapteurs de ce même port, ayant un comportement différent. Par exemple, plutôt que de faire un appel au backend, on pourrait imaginer vouloir répondre à notre question en local uniquement, et donc enregistrer la réponse dans le local storage du navigateur.

class LocalStorageQuestionAdapter implements QuestionPort {
  async saveAnswer(question: Question, answer: Answer): Promise<void> {
    localStorage.setItem('answer', JSON.stringify({ question, answer }));
  }
}

Et bim ! C'est possible en quelques lignes, sans avoir eu besoin de changer quoi que ce soit dans le code métier !

Au fait, ça donne quoi dans le contexe des tests unitaires ? Et bien il est tout à fait possible, et même souhaitable, de créer des adapteurs spécialement conçus pour les tests, qui ne feront que manipuler de la donnée stockée dans leurs attributs.

class StubQuestionAdapter implements QuestionPort {
  public savedQuestion?: Question;
  public savedAnswer?: Answer;
 
  async saveAnswer(question: Question, answer: Answer): Promise<void> {
    this.savedQuestion = question;
    this.savedAnswer = answer;
  }
}
 
it('saves the validated answer to a question', () => {
  // ARRANGE
  const questionAdapter = new StubQuestionAdapter();
 
  const deps: Dependencies = {
    questionAdapter,
  };
 
  const store = createStore(rootReducer, thunk.withExtraArgument(deps));
 
  const answer1: Answer = { text: '42', selected: false };
  const answer2: Answer = { text: '51', selected: false };
 
  const question: Question = {
    text: 'What is the answer to life?',
    answers: [answer1, answer2],
  };
 
  store.dispatch(setQuestion(question));
  store.dispatch(setAnswerSelected(answer1));
 
  // ACT
  await store.dispatch(validateAnswer());
 
  // ASSERT
  expect(questionAdapter.savedQestion).toEqual(question);
  expect(questionAdapter.savedAnswer).toEqual(answer1);
});

Le domaine de l'infrastructure

Pour terminer cet article, j'aimerais aborder un point que je trouve particulièrement sexy.

On parle du code domaine, mais en fait on peut voir ça de manière plus générique, comme du code qui a une certaine fonction. Dans le contexte de notre app, la fonction du code domaine est celui de répondre aux besoins métiers.

Réfléchir au domaine d'une app qui affiche un questionnaire, fait de React et des call API la couche infra dans ce contexte.

Mais on peut très bien réfléchir au domaine d'un contexte différent, par exemple celui de notre HttpQuestionAdapter. Son "métier" à lui, c'est de faire un call API pour sauvegarder une réponse à une question. Et dans ce contexte, notre use case devient un adapteur de gauche !

Et si on pousse le bouchon encore plus loin (Maurice), on peut vouloir abstraire la manière dont le call API est fait. Nous, on a utilisé fetch, mais peut-être qu'on aimerait pouvoir utiliser autre chose, comme axios par exemple. Et donc la fonctionnalité "exécuter une requête HTTP" peut être abstraite derrière une interface, qui sera implémentée par un adapteur de droite.

Voyons voir ce à quoi ça peut ressembler. On commence par définir l'interface, le contrat, définissant comment on veut pouvoir éxécuter une requête HTTP.

type HttpResponse = {
  status: number;
  body: any;
};
 
interface HttpPort {
  get(url: string): Promise<HttpResponse>;
  post(url: string, body?: unknown): Promise<HttpResponse>;
}

Notre HttpQuestionAdapter dépend de cette interface, il va donc récupérer un objet qui l'implémente au moment de son instanciation (via son constructeur).

class HttpQuestionAdapter implements QuestionPort {
  constructor(private readonly httpAdapter: HttpPort) {}
 
  async saveAnswer(question: Question, answer: Answer): Promise<void> {
    await this.httpAdapter.post(`/question/${question.id}/answer`, selectedAnswer.text);
  }
}

Vous avez compris le principe, on peut maintenant implémenter une classe qui respecte ce contrat en utilisant fetch, et pourquoi pas une autre qui utiliserait axios.

class FetchHttpAdapter implements HttpPort {
  // note that we allow to provide a different implementation of fetch,
  // which can be used to provide a mocked version in the unit tests
  constructor(private readonly fetch = globalThis.fetch) {}
 
  async get(url: string): Promise<HttpResponse> {
    return this.request('GET', url);
  }
 
  async post(url: string, body?: any): Promise<HttpResponse> {
    return this.request('POST', url, body);
  }
 
  private request(method: string, url: string, body?: unknown): Promise<HttpResponse> {
    const response = await this.fetch(url, {
      method,
      body: JSON.stringify(body),
    });
 
    return {
      status: response.status,
      body: await response.json(),
    };
  }
}

Là dans ce cas, cette abstraction est certainement overkill, mais dans un cas réel avec pas mal de logique commune à tout ce qui est relatif aux calls HTTP, une abstraction de la sorte aurait beaucoup de sens. On pourrait y encapsuler la gestion de l'authentification, de la gestion de cache, ou tout autre détail technique que l'on veut appliquer à l'ensemble de nos calls HTTP.

L'implémentation de HttpQuestionAdapter peut maintenant se faire en passant une instance de FetchHttpAdapter en paramètre de son constructeur.

const httpAdapter = new FetchHttpAdapter();
 
const dependencies: Dependencies = {
  questionAdapter: new HttpQuestionAdapter(httpAdapter),
};

Conclusion

Alors, est-ce que la clean archi m'a permis d'améliorer la conception de features frontend ? Oui bien sûr, j'ai pu appliquer ces principes chez Gojob, ainsi que sur plusieurs projets perso. Et ça a porté ces fruits : il devient très facile de faire une évolution sur du code domaine (potentiellement en TDD), à tel point qu'il m'arrive de développer sans même lancer l'app en local, en n'utilisant que les tests unitaires comme appui et l'environment déployé sur ma branche par la CI pour valider la feature.

L'approche expliquée dans cet article, via redux, n'est pas la seule manière de séparer le code domaine de l'infra. Une alternative intéressante est de passer par l'API context de react, qui est aussi une manière d'inverser des dépendances. Par contre, ça impose d'inclure react dans les tests du domaine, ce qui n'est pas souhaitable si on veut limiter la surface d'adhérence entre le domaine et l'infra.


Nous l'avons vu, la clean architecture apporte la possibilité d'écrire du code de façon plus modulaire : il est possible de concevoir plusieurs implémentations d'un même adapteur, et de choisir celui qui doit être utilisé en fonction du contexte d'utilisation. Il serait même envisageable de concevoir plusieurs implémentations de la partie UI pour le même code domaine, c'est-à-dire pouvoir remplacer React par une autre bibliothèque de rendu.

Mais même si c'est possible en théorie, je ne pense pas que ce soit toujours réaliste, compte tenu de la charge de travail qu'impliquerait plusieurs implémentations des mêmes composants. Tout dépend du projet lui-même finalement, si le code de l'UI est très simple (quelques boutons par exemple) et que la majorité du code fait partie du domaine, il serait possible de changer de framework de rendu. Mais c'est rarement le cas, les apps web ont, le plus souvent, une UI complexe lorsque le domaine est lui aussi complexe.


Mais alors, quels retours critiques peut ont faire à la clean archi ?

Selon moi, appliquer ces principes n'a de sens que pour géré une complexité essentielle, une complexité qui émerge d'un besoin complexe (qui comporte beaucoup de règles métier). Une app qui ne fait qu'afficher des datas sans aucune transformation ou aucune logique ne va pas pouvoir tirer parti des principes expliqués ici.

Car oui, la clean archi complexifie nécessairement le code. Il sera plus difficile à lire et à expliquer à des personnes qui ne connaissent pas encore ces principes. Et il est important de toujours se demander à quel point le domaine est complexe, car plus il l'est, et plus la clean architecture permettra d'éviter le code spaghetti, compliqué à maintenir et à faire évoluer.

Mon expérience m'a appris que mettre de la clean archi partout dès que possible n'est pas une bonne idée, car ça peut créer de la complexité accidentelle. A mon avis, il vaut mieux garder les principes en têtes, et trouver le bon juste milieu d'abstraction, selon la situation. Rien que le fait de passer une fonction en paramêtre à une autre fonction, c'est une manière d'inverser une dépendence !


Voilà qui termine cet article, j'espère que ma vision de la clean archi vous sera utile dans vos projets ! Le code final est toujours dispo sur ce dépôt github.

Pour voir exemple d'application front telle que décrite dans cet article, vous pouvez jeter un coup d'oeil à ce projet sur lequel je bosse (sur le commit que j'ai linké, car j'ai fais le choix à posteriori de revenir sur un modèle plus simple, sans clean archi).

Si vous avez des questions, des remarques, des feedbacks, ou si vous voulez juste discuter, envoyez-moi un petit message sur mastodon ou sur twitter (n'hésitez pas, j'adore parler tech !).

How to write good code