Skip to content
All posts

Architecturer les Frontends SaaS : Concevoir pour la Longévité et l'Échelle

Dans l'immense écosystème JavaScript du développement frontend, où de nouveaux frameworks et bibliothèques semblent apparaître chaque jour, les développeurs se retrouvent souvent dans un paradoxe de choix. Bien que de nombreux développeurs soient rapides à se lancer dans les guerres de frameworks, la vérité est que la construction d'applications maintenables et évolutives va plus loin, car elle exige une bonne compréhension des concepts d'architecture logicielle.

Ce n'est pas seulement une question de framework

Chez Junifia, bien que nous reconnaissions l'importance de choisir un framework, nous pensons que d'autres aspects sont encore plus importants. Nous avons utilisé d'excellents frameworks comme React, Vue, Angular, etc., pour créer des SaaS pour nos clients. Mais au final, ces frameworks ne sont que des outils. Ils ont tous le même objectif et permettent tous d'accomplir la même chose : créer des applications incroyables. Ainsi, n'importe quel framework peut être le bon choix s'il correspond aux exigences de votre projet. La décision peut simplement reposer sur l'outil que vous aimez le plus, avec lequel vous êtes à l'aise ou avec lequel vous avez de l'expérience.

La performance ne devrait pas être le seul facteur décisif. En général, un framework n'offre pas un avantage drastique par rapport à un autre en termes de performance. Dans les applications réelles, les causes des ralentissements de performance proviennent des interactions réseau et de la gestion des données. Il est plus bénéfique de se concentrer sur l'optimisation de ces domaines plutôt que de se laisser prendre par de petites différences de performance entre les frameworks. En essence, une conception d'application réfléchie devrait primer sur la sélection du framework.

Les concepts avant la technologie

Ainsi, les applications réussies se concentrent sur une base solide de principes architecturaux. Mais quels sont ces concepts ? Chez Junifia, nous avons quelques concepts clés à garder à l'esprit lorsqu'il s'agit de créer des applications frontend maintenables et évolutives :

  • Réduire le coût du changement

    Le principal défi du développement logiciel est de s'adapter au changement. Que ce soit pour ajouter de nouvelles fonctionnalités, corriger des bugs ou répondre aux retours des utilisateurs, les changements sont inévitables. La manière dont nous structurons nos applications joue un rôle important dans la façon dont nous pouvons accueillir efficacement ces changements. Pour nous aider à y parvenir, nous adhérons aux principes SOLID.

  • Adopter une bonne architecture

    Les architectures hexagonale ou propre, souvent associées aux applications backend, sont tout aussi applicables aux applications frontend. Elles encouragent la séparation des préoccupations et l'isolement des dépendances externes, rendant les applications plus maintenables et évolutives. Une idée clé derrière ces architectures est de protéger notre logique métier des changements externes.

  • Composants de présentation vs. composants conteneurs

    Bien que ce concept ait évolué au fil du temps, notamment avec la montée en puissance des hooks dans React, l'essence reste la même : séparer les composants d'interface utilisateur (ce à quoi les choses ressemblent) des composants logiques (comment les choses fonctionnent).

Démonstration

Illustrons maintenant la mise en œuvre de ces concepts avec un exemple simple. Nous allons créer une application classique de liste de tâches (To-do list application). Elle nous permettra d'ajouter, de basculer et de supprimer des tâches. L'exemple sera en React, mais les concepts sont applicables à n'importe quel framework.

Le composant TodoItem

Le premier composant que nous allons créer est le composant TodoItem. Ce composant sera responsable de l'affichage d'une seule todo item. Il sera un composant de présentation. Cela signifie qu'il sera uniquement responsable de l'affichage de la tâche, mais pas de la gestion de son état. Une règle générale est que les composants ne doivent pas être conscients de l'endroit ou du contexte dans lequel ils sont utilisés. Voyons à quoi cela ressemble dans le code :

interface TodoItemProps {
  title: string;
  completed: boolean;
  onToggle: () => void;
  onDelete: () => void;
}

function TodoItem({ title, completed, onToggle, onDelete }: TodoItemProps) {
  return (
    <div className="todo-container">
      <div className="title-container">
        <Checkbox checked={completed} onChange={onToggle} />
        <span className={completed ? "title-completed" : "title-not-completed"}>
          {title}
        </span>
      </div>
      <button onClick={onDelete} className="delete-button">
        Delete
      </button>
    </div>
  );
}

export default TodoItem;

Ce composant est conçu pour agir comme une interface claire, dictant à travers ses props comment il doit être utilisé. Il nécessite des entrées spécifiques — un titre et un statut indiquant si l'élément est terminé. De plus, il est prêt à répondre à certaines actions, notamment onToggle et onDelete. Mais il reste agnostique quant à la logique derrière ces opérations, il ne sait pas comment les effectuer. Ce comportement lui est donné, ce qui le rend hautement réutilisable. C'est l'essence d'un composant de présentation. Toute modification visuelle nécessaire pour un élément de tâche est isolée à cet endroit, sauvegardant la fonctionnalité tout en modifiant uniquement son apparence.

Règle n°1 : Les composants ne doivent pas être conscients de l'endroit ou du contexte dans lequel ils sont utilisés.

 

Le composant TodoList

Ensuite, nous allons créer le composant TodoList. Ce sera également un simple composant de présentation uniquement responsable de l'affichage d'une liste de tâches, et non de la gestion de leur état :

interface TodoListProps {
  todos: Todo[];
  onToggle: (todo: Todo) => void;
  onDelete: (id: string) => void;
}

function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <TodoItem
            title={todo.title}
            completed={todo.completed}
            onToggle={() => onToggle(todo)}
            onDelete={() => onDelete(todo.id)}
          />
        </li>
      ))}
    </ul>
  );
}

export default TodoList;

The TodoPage component

Ensuite, nous aurons le composant TodoPage, qui est un composant de niveau supérieur. En raison de cela, les pages sont naturellement des composants conteneurs. Elles sont responsables de fournir les données et la logique aux composants de présentation qu'elles intègrent, sans pour autant définir la logique elles-mêmes. Voyons à quoi cela ressemble dans le code :

function TodoPage() {
  const { todos, isLoading, error, addTodo, toggleTodo, deleteTodo } =
    useTodos();

  if (isLoading) return <div>Loading...</div>;

  if (error) return <div>Something went wrong</div>;

  return (
    <div className="container mx-auto p-12">
      <AddTodoForm onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
    </div>
  );
}

export default TodoPage;

Comme vous pouvez le voir, un nouvel élément est introduit ici. La page récupère les données et la logique en invoquant la fonction useTodos, qu'elle transmet ensuite à ses enfants. Cependant, la page ne contient pas la logique elle-même. Au lieu de cela, la logique réside dans les fonctions renvoyées par useTodos. En React, ces fonctions sont définies comme des hooks. Nous verrons ce qu'est ce hook dans la section suivante. Pour le moment, comprenez que la page source l'état et les fonctions pour gérer cet état, mais ne définit pas la logique elle-même. C'est le rôle d'un composant conteneur.

Règle à retenir lors de la création de composants conteneurs : ils ne doivent pas exécuter la logique eux-mêmes ; ils doivent la déléguer. En React, nous pouvons utiliser des hooks pour réaliser cette séparation.

Règle n°2 : Les composants conteneurs (Container) doivent obtenir l'état et la logique, mais ne doivent pas exécuter la logique eux-mêmes ; ils doivent la déléguer.

 

Hooks personnalisés

Les hooks personnalisés sont un excellent moyen de mettre en œuvre la gestion de l'état dans React. Ce ne sont que des fonctions et ils devraient être utilisés pour encapsuler la logique avec état et les effets secondaires afin de garder votre code propre et découplé. Voici donc une règle spécifique à React : penchez-vous fortement sur les hooks personnalisés pour la gestion de l'état.

Voyons maintenant le hook useTodos, que nous avons vu dans la section précédente :

function useTodos() {
  const { todoRepository } = useTodoRepository();
  const { todos, isLoading, error } = useGetTodos(todoRepository);
  const { addTodo } = useAddTodo(todoRepository);
  const { toggleTodo } = useToggleTodo(todoRepository);
  const { deleteTodo } = useDeleteTodo(todoRepository);
  return { todos, isLoading, error, addTodo, toggleTodo, deleteTodo };
}

export default useTodos;
```

Ce qui ressort ici, c'est que useTodos est un hook composite, tirant parti de plusieurs autres hooks pour consolider sa logique. Une telle décomposition non seulement assure que les responsabilités restent distinctes, mais améliore également la lisibilité du code. Une brève note sur useTodoRepository : ne vous y attardez pas trop pour le moment, nous y reviendrons plus tard (mais si vous êtes curieux, il s'agit simplement de retourner une abstraction simple sur la source de données).

Avant d'aller plus loin, mentionnons simplement que nous utiliserons React Query pour la récupération des données (Data fetching). React Query est une excellente bibliothèque qui simplifie la récupération et la mise en cache des données. Elle est disponible dans d'autres frameworks également. C'est une bibliothèque que nous recommandons d'utiliser.

Voici maintenant le hook useGetTodos :

function useGetTodos(todoRepository: TodoRepository) {
  const {
    data: todos = [],
    isLoading,
    error,
  } = useQuery({ queryKey: ["todos"], queryFn: () => todoRepository.getAll() });

  return { todos, isLoading, error };
}

export default useGetTodos;

Il n'est pas important de comprendre parfaitement son fonctionnement, mais nous utilisons essentiellement le hook useQuery de React Query pour récupérer les tâches (fetch) à partir d'une source de données (comme une REST API) et les renvoyer. Le paramètre queryFn est simplement une fonction implémentant cette récupération de données. Dans les applications React, il est courant de trouver des fichiers de service contenant de telles fonctions dédiées à la récupération des données de diverses sources, comme ceci :

export const getTodos = async () => {
  const endpoint = "https://jsonplaceholder.typicode.com/todos";
  const response = await fetch(endpoint);
  const todos = await response.json();
  return todos;
};

Ainsi, vous verrez getTodos utilisé comme second argument du hook useQuery : useQuery({ queryKey: ["todos"], queryFn: getTodos}). C'est une bonne pratique et cela fonctionne pour des applications simples. Mais nous n'utiliserons pas cette approche ici.

Rule #3 (React Specific): lean heavily into custom hooks for state management.

 

Le modèle de répertoire

Pour cette démonstration, nous avons poussé les choses un peu plus loin en implémentant le modèle de répertoire (repository pattern). Sachez simplement que cela sert à expliquer les concepts architecturaux, et que c'est une surconception pour une application aussi simple que celle-ci car cela ajoute de la complexité. Cependant, c'est une bonne pratique pour les applications plus complexes. Voici à quoi ressemble l'interface TodoRepository :

export interface TodoRepository {
  getAll(): Promise<Todo[]>;
  create(title: string): Promise<Todo>;
  update(todo: Todo): Promise<Todo>;
  delete(id: string): Promise<void>;
}

Il s'agit simplement d'une abstraction d'une source de données pour les todos. L'idée ici est que nous voulons protéger notre application de la source de données. En d'autres termes, nous voulons pouvoir changer facilement la source de données sans impact sur le reste de l'application. Par exemple, imaginez que vous avez implémenté toute la récupération de données à partir d'une REST API, et qu'à un moment donné, vous souhaitez passer à une GraphQL API. Ou imaginez que vos exigences changent et que votre application, ou certaines parties de votre application, doivent fonctionner hors ligne ? Vous devrez alors modifier du code qui peut causer des effets de bord si vous n'avez pas conçu votre application correctement.

Ainsi, dans cet exemple, nous avons écrit deux implémentations de cette interface : une pour le stockage local et une pour une simple REST API. Pour l'instant, notre application stocke et récupère des todos à partir du stockage local du navigateur. Mais, si nous voulons utiliser une REST API à la place, nous n'avons qu'à changer une seule ligne de code dans le hook useTodoRepository qui renvoie un TodoRepository :

function useTodoRepository() {
  const todoRepository: TodoRepository = new LocalStorageTodoRepository();
  return { todoRepository };
}

export default useTodoRepository;

Ainsi, nous appliquons ici deux principes SOLID : le Principe d'Inversion de Dépendance, car nous pouvons injecter nos différentes implémentations de l'abstraction dans nos hooks (en tant que paramètres), et le Principe d'Ouverture/Fermeture, car nous pouvons étendre notre application en ajoutant une nouvelle source de données sans modifier le code existant.

Le modèle de répertoire peut également être utile si le backend ou certains de ses endpoints ne sont pas encore prêts. Vous pouvez commencer à implémenter le frontend avec une implémentation de stockage local, puis passer à l'implémentation de REST API lorsqu'elle est prête. Il peut également être utile à des fins de test, car vous pouvez facilement simuler la source de données.

Conclusion

Pour résumer, nous avons vu comment concevoir des applications frontend maintenables et évolutives en appliquant certains concepts architecturaux tels que l'inversion de dépendance et le principe de responsabilité unique. Nous avons également vu comment réduire le coût du changement en séparant la logique et la gestion de l'état des préoccupations d'interface utilisateur ( UI concerns ) en utilisant des hooks personnalisés et en séparant les composants de présentation des composants conteneurs.

Restons en contact

Nous espérons que vous avez apprécié cet article et qu'il vous aidera dans vos futurs projets. N'hésitez pas à prendre rendez-vous avec nous si vous avez besoin de conseils pour votre projet SaaS. Nous serions heureux d'en discuter avec vous ! 

Code

Si vous souhaitez voir le code complet de l'exemple, vous pouvez le trouver ici sur Github.

Références

Cet article s'inspire d'une conférence de Félix-Antoine Bourbonnais et Ian Létourneau à l'Agile Tour de Québec 2019. Regardez la présentation complète sur Youtube.