Architecture hexagonale en front-end

Publié le 18 novembre 2022
Dimitri Dumont avatar
Dimitri Dumont
Développeur front-end

Dans cet article, nous allons découvrir l’architecture hexagonale et comprendre pourquoi il est intéressant de l’utiliser en front-end. Cet article s’appuie sur un projet que j’ai créé en React & Next.js afin de vous fournir un exemple concret. Le projet est disponible sur Github, le lien se trouve à la fin de l'article. Une version de cet article avec Redux sera bientôt disponible.

L'architecture hexagonale se base sur le concept d'inversion de dépendances. Si vous ne connaissez pas ce concept, je vous invite à lire l'article que j'ai écrit à ce sujet avant de lire celui-ci : voir l'article sur l'inversion de dépendances.

Schéma de l'architecture hexagonale

Qu’est-ce que l’architecture hexagonale ?

1. Définition

C’est une architecture de code basée sur l’utilisation de ports, d’adaptateurs et de l'inversion de dépendances. Le but est d’avoir une application faiblement couplée aux dépendances externes comme une base de données, une API, un framework ou encore une librairie.

Pour simplifier ces termes, le but est d’avoir une interface, c’est-à-dire un contrat, qui définit comment une action va envoyer et recevoir des informations à des services externes comme une base de données, une API, une librairie ou autre.

2. Les différentes parties

a. Use case

Un use case définit une action de l’utilisateur. L’objectif est de ne pas utiliser de librairies ou de frameworks ici, car le but est de ne pas coupler la logique à ces outils.

En front-end, un use case peut être une fonction ou une classe. Plus spécifiquement dans un projet React, redux peut être une exception et être considéré comme les uses cases. Dans ce cas, les actions sont les use cases, le state est le modèle et les sélecteurs sont utilisés pour mapper les données entre différents modèles.

b. Port primaire

Ce sont les contrats situés entre les use cases et les adaptateurs primaires. Ils peuvent être représentés par des interfaces. En général, le use case est aussi considéré comme port primaire, ainsi il n’y a pas besoin de créer d’interface pour cela.

c. Adaptateur primaire

L’adaptateur primaire est l’élément qui dialogue avec le domaine. Son rôle est de déclencher des use-cases. Par exemple en front, les adaptateurs peuvent être des composants React qui déclenchent des actions redux ou de simples fonctions du domaine.

d. Port secondaire

Le port secondaire est l’interface qui définit le contrat entre les uses cases et les adaptateurs secondaires. Cette interface est utilisée par les use-cases pour faire appel à des services externes implémentés par les adaptateurs secondaires. C’est ici que nous retrouvons surtout le principe d’inversion de dépendances.

Pour gagner en flexibilité, vous pouvez également utiliser de l’injection de dépendances afin d’éviter de devoir déclarer votre implémentation de ce contrat à chaque utilisation de use-cases. Pour cela, en front, les middlewares comme redux-thunk ou redux-observable permettent de le faire via des arguments passés à la configuration de votre store redux. Si vous n’utilisez pas redux, vous pouvez utiliser InversifyJS.

e. Adaptateur secondaire

L’adaptateur secondaire est l’implémentation du port secondaire. Ils sont appelés indirectement par les use-cases. Par exemple, en front-end, ils peuvent être représentés par des classes ou des fonctions qui effectuent des requêtes HTTP, qui accèdent à une base de données côté client, etc.

Pourquoi utiliser l’architecture hexagonale en front-end ?

1. Ne plus être dépendant des librairies et frameworks

Grâce à l'architecture hexagonale, la logique de votre projet front-end n'est plus dépendante des librairies et frameworks comme React, Vue ou encore Next.js.

Ce qui implique que si l'un de ces outils n'est plus maintenu ou ne vous convient plus, vous pouvez changer de librairie ou de framework sans avoir à développer de nouveau tout le projet. Il faudra uniquement modifier la partie visuelle du projet.

2. Pouvoir tester facilement et efficacement un projet front-end

Grâce à l'inversion de dépendances et au fait que votre domaine doit être indépendant, vous n'avez plus besoin de rendre vos composants, de "mocker" vos requêtes HTTP, etc.

Vous avez uniquement besoin d'utiliser une fausse implémentation de votre service externe comme l'API dans vos tests. Ainsi, vos tests se découpent en trois grandes catégories :

  • Les tests unitaires : ils servent à tester votre domaine.
  • Les tests d'intégrations : ils testent vos implémentations externes.
  • Les tests de bout en bout : ils sont utiles pour tester l'intégralité de votre application en même temps.

3. Avoir le même core pour une app mobile et une app web

Ce point rejoint le premier point expliqué précédemment. Étant donné que votre logique est indépendante des frameworks et librairies utilisés, vous pouvez avoir le même code pour la logique de vos applications.

4. Développer le front-end sans le back-end

Grâce à l'inversion de dépendances représenté par le port secondaire, vous pouvez développer le front-end d'une application sans le back-end.

Pour cela, il suffit de procéder de la même manière que pour les tests en utilisant une fausse implémentation.

Cette méthode est pratique dans le cas où vous souhaitez développer une fonctionnalité pour la faire tester à quelques utilisateurs. Elle est également utile dans le cas où les développeurs back-end n'ont pas encore pu développer l'API.

5. Développer le front-end sans réseau

Ce point rejoint le point précédent, grâce à l'inversion de dépendances, vous pouvez utiliser une fausse implémentation qui simule le back-end ou l'accès à des services externes. Ainsi, vous n'avez pas besoin d'accès internet pour développer le front-end.

Exemple d'un projet front-end avec une architecture hexagonale en React & Next.js

Au début de cet article, je vous ai présenté un diagramme pour visualiser l'architecture hexagonale. Je vous en propose un nouveau mais avec des termes liés au front-end.

Schéma de l'architecture hexagonale en front-end

Maintenant que nous avons vu ce qu'est l'architecture hexagonale et quels sont les intérêts de l'utiliser, nous allons voir comment s'en servir dans un projet front-end, en React et Next.js.

Pour cela, j'ai choisi un sujet simple et connu : une todo list..

Consulter le code
Le code de cet article est disponible, vous pouvez le consulter sur GitHub.

Dans cet article, nous allons nous focaliser sur une fonctionnalité du projet : la récupération de la liste des tâches. Nous allons commencer par développer l'un des adaptateurs primaires, qui est un composant React.

1. Création de l'adaptateur primaire

src/modules/applications/todo-list/todo-list.container.tsx
1import React, { useEffect, useState } from "react"
2import { Todo as TodoDomain } from "@/modules/todos/domain/todo"
3import { TodoListView } from "@/modules/todos/application/todo-list/todo-list.view"
4import { getTodos } from "@/modules/todos/domain/todos.actions"
5import { outputs } from "@/config/outputs"
6import { Todo } from "@/modules/todos/application/todo"
7import { mapToApplicationModel } from "@/modules/todos/application/todos.mapper"
8
9export const TodoListContainer = () => {
10 const [todos, setTodos] = useState<Todo[]>([])
11 const [errorToGetTodos, setErrorToGetTodos] = useState<string>("")
12
13 useEffect(() => {
14 _getTodos()
15 }, [])
16
17 const _getTodos = async () => {
18 try {
19 const todosDomain: TodoDomain[] = await getTodos({
20 todosOutput: outputs.todosOutput,
21 })
22
23 const todos: Todo[] = mapToApplicationModel(todosDomain)
24
25 setTodos(todos)
26 setErrorToGetTodos("")
27 } catch (error: any) {
28 setErrorToGetTodos(error.message)
29 }
30 }
31
32 return (
33 <TodoListView
34 todos={todos}
35 errorToGetTodos={errorToGetTodos}
36 setTodos={setTodos}
37 />
38 )
39}
40
Hexa web
Besoin d’une équipe de développeurs front-end ?

2. Création du use case

src/modules/todos/domain/todos.actions.ts
1import { Todo } from "@/modules/todos/domain/todo"
2import { TodosOutput } from "@/modules/todos/domain/todos.output"
3
4export const getTodos = async ({
5 todosOutput,
6}: {
7 todosOutput: TodosOutput
8}): Promise<Todo[]> => {
9 try {
10 return await todosOutput.getTodos()
11 } catch (error: any) {
12 throw new Error(error)
13 }
14}

Le use case utilisé pour récupérer la liste des tâches est représenté par une fonction. Elle prend en paramètre le port secondaire TodosOutput afin de l'utiliser pour récupérer la liste de tâches. Comme vous le voyez, notre use case (domaine) n'a aucune connaissance de la manière dont il récupère les données. Il utilise simplement le port secondaire, qui est une interface, pour récupérer ces données.

3. Création du port secondaire

src/modules/todos/domain/todos.output.ts
1import { Todo } from "@/modules/todos/domain/todo"
2
3export interface TodosOutput {
4 getTodos(): Promise<Todo[]>
5}

Ici nous créons notre port secondaire utilisé par le use case. C'est une interface Typescript qui sert à établir un contrat.

4. Création de l'adaptateur secondaire

src/modules/todos/infrastructure/todos-local-storage.ts
1import { TodosOutput } from "@/modules/todos/domain/todos.output"
2import { Todo } from "@/modules/todos/domain/todo"
3
4export class TodosLocalStorage implements TodosOutput {
5 getLocalTodos(): Todo[] {
6 const localTodos: string | null = localStorage.getItem("todos")
7
8 return localTodos ? JSON.parse(localTodos) : []
9 }
10 }
11

Comme vous pouvez le voir, la classe TodosLocalStorage représente l'adaptateur secondaire, qui implémente l'interfaceTodosOutput, le port secondaire. Ici nous utilisons le localStorage pour stocker et récupérer les données, nous aurions aussi pu utiliser des requêtes HTTP pour accéder à une API ou une base de données côté client.

Tout l'intérêt de l'architecture hexagonale est justement d'avoir plusieurs implémentations ou de les changer selon les services utilisés sans avoir besoin de modifier notre core.

5. Exemple de deux tests unitaires

src/modules/todos/domain/todos.test.ts
1import { Todo } from "@/modules/todos/domain/todo"
2import { Todo as TodoInfraModel } from "@/modules/todos/infrastructure/todo"
3import { getTodos } from "@/modules/todos/domain/todos.actions"
4import { TodosInMemory } from "@/modules/todos/infrastructure/todos.in-memory"
5import { todosInfrastructureFakes } from "@/modules/todos/infrastructure/todos.fakes"
6
7describe("[todos] unit tests", () => {
8 const todosOutput = new TodosInMemory()
9
10 beforeEach(() => {
11 todosOutput.setTodos([])
12 })
13
14 describe("when the user wants to get his todos", () => {
15 it("should get them without error", async () => {
16 todosOutput.setTodos(todosInfrastructureFakes)
17
18 const todos: Todo[] = await getTodos({
19 todosOutput,
20 })
21
22 const expectedTodos: Todo[] = todosInfrastructureFakes.map(
23 (infraModel: TodoInfraModel) => ({
24 title: infraModel.title,
25 isDone: infraModel.isOk,
26 })
27 )
28
29 expect(todos).toEqual(expectedTodos)
30 })
31
32 it("shouldn't get them and should throw error", async () => {
33 todosOutput.setTodos(undefined)
34
35 await expect(
36 getTodos({
37 todosOutput,
38 })
39 ).rejects.toThrowError()
40 })
41 })
42 })
43

Voici un exemple de quelques tests unitaires. Comme je l'ai expliqué au début de cet article, les tests unitaires ont pour objectif de tester le core de l'application, la logique.

Ainsi, notre test utilise le use case getTodos en lui passant cette fois-ci un adaptateur secondaire différent : TodosInMemory. Cet adaptateur est une autre implémentation du port secondaire TodosOutput. C'est une classe qui implémente l'interface TodosOutput.

Les tests sont assez simples, ils exécutent et récupèrent les données renvoyées par le use case getTodos et les comparent à de fausses données attendues.

6. Création d'un autre adaptateur secondaire pour les tests unitaires

src/modules/todos/infrastructure/todos.in-memory.ts
1import { TodosOutput } from "@/modules/todos/domain/todos.output"
2import { Todo } from "@/modules/todos/domain/todo"
3import { Todo as TodoInfra } from "@/modules/todos/infrastructure/todo"
4
5export class TodosInMemory implements TodosOutput {
6 private todos: TodoInfra[] | undefined = []
7
8 setTodos(todos: TodoInfra[] | undefined): void {
9 this.todos = todos ? [...todos] : undefined
10 }
11
12 mapToDomainModel(infraModel: TodoInfra[]): Todo[] {
13 return infraModel.map((infraModel: TodoInfra) => ({
14 title: infraModel.title,
15 isDone: infraModel.isOk,
16 }))
17 }
18
19 getTodos(): Promise<Todo[]> {
20 if (!this.todos) {
21 throw new Error("Please create a todo")
22 }
23
24 const todos: Todo[] = this.mapToDomainModel(this.todos)
25
26 return Promise.resolve(todos)
27 }
28}

Voici l'adaptateur secondaire utilisé pour les tests unitaires précédents.

Conclusion

Pour conclure, l'architecture hexagonale est un outil permettant d'organiser son code de manière à ce que la logique de votre projet soit indépendante des outils externes.

Elle est tout aussi importante en front-end afin de pouvoir être indépendant des librairies et frameworks qui évoluent régulièrement et rapidement. Elle vous permet de tester correctement et facilement votre application, en plus de pouvoir faire évoluer les services externes utilisés rapidement. Il faut voir cela comme des plugins qui doivent respecter des règles pour être utilisés par notre application.

Si cet article vous a aidé pour mieux comprendre l'architecture hexagonale et à savoir comment l'utiliser en front-end, n'hésitez pas à le partager à vos collègues.

Consulter le code
Le code de cet article est disponible, vous pouvez le consulter sur GitHub.