4
minutes
Mis à jour le
16/4/2024


Share this post

Explorez la conception d'une API REST avec Spring Boot selon l'Architecture Hexagonale, mettant en lumière les principes du Domain Driven Design.

#
HexagonalArchitecture
#
Spring Boot
#
API
#
Java
Dorian Frances
Software Engineer

Présentation de l’API

Comment partir sur des bases solides lorsque l’on se lance dans la création d’une API REST en utilisant une architecture hexagonale ? C’est ce que nous allons essayer de construire tout au long de cet article !

Le but est de se constituer une base de données de Pokémons. Une sorte de pokédex si vous voulez. Pour cela, nous allons passer par l’API PokéAPI. Pour celles et ceux qui ne voient pas ce qu’est PokéAPI, il s’agit simplement d’une API publique mettant à disposition de nombreuses informations concernant les Pokémons comme leur nom, leurs capacités, leur espèce…

Nous partirons d’un projet vide pour arriver à une API fonctionnelle et correctement testée en passant par l’explication des grands principes de l’architecture hexagonale. Cela dit, l’API que nous allons construire dans ce tutoriel ne justifie pas forcément la mise en place d’une telle architecture. Elle sert simplement de support afin d’expliquer les concepts et outils présents dans cet article.Nous le verrons par la suite, mais il n’est pas toujours judicieux de choisir une architecture complexe comme l’architecture hexagonale pour l’ensemble de vos projets !

IMPORTANT

Pour simplifier la lecture, vous trouverez en cliquant sur 🔗 ce lien, l’API servant de support à cet article. Vous pourrez ainsi suivre au fil de l’eau l’ensemble des concepts et des outils présentés ici.

Bonne lecture !

Générer le projet

Comme beaucoup de projets écrits avec Spring Boot, nous pouvons utiliser le Spring Initializr pour générer notre projet. Voici donc les dépendances que nous allons ajouter.

En ouvrant le projet ainsi généré, vous pouvez lancer votre application via votre IDE ou en utilisant la commande suivante à la source du projet : ./mvnw spring-boot:run.

Création de la base de données locale via Docker

Pour pouvoir réaliser nos développements en local, nous allons avoir besoin de nous générer une base de données. Nous allons donc utiliser Docker.

À la source de notre projet, nous allons créer un fichier docker-compose.yml ayant pour contenu ce qui suit :

Vous pouvez ensuite lancer la commande docker-compose up à la source du projet afin de générer l’image PostgreSQL de votre base de données qui va accueillir vos Pokémons.

Connecter notre application à notre base de données PostgreSQL et lancer les migrations

Notre application est générée et prête à être lancée. Nous avons une base de données tournant en local. Il nous faut maintenant connecter ces deux briques, mais également faire en sorte qu’au démarrage de notre API, les migrations de notre base de données se lancent afin que cette dernière soit prête à être utilisée. Et pour cela, nous allons utiliser l’outil Liquibase.

Connection avec la base de données

Créez ou modifiez votre fichier de configuration d’application pour indiquer à Spring Boot qu’il faut se connecter à votre base de données.

Nous avons ici créé un fichier de configuration application-local.yml car à terme, vous aurez sans doute différents environnements de développement possédant chacun leur propre base de données.

Vous pouvez relancer votre application, elle se connectera correctement à votre base de données PostgreSQL.

Lancer les migrations

Dans le dossier src/main/resources nous allons créer ces deux fichiers :

Le premier nous permet d’indiquer les différents fichiers de migrations que nous possédons ainsi que l’ordre dans lequel ils doivent être exécutés. Le second est, lui, notre premier fichier de migration ! Nous créons ici notre table pokemons ainsi que ses différentes colonnes.

Enfin, la dernière étape consiste à indiquer à Spring Boot où trouver les fichiers de migrations que nous voulons lancer au démarrage. Notre fichier de configuration doit donc se transformer comme cela :

Relancez votre application. Elle se connectera à votre base de données et vous pourrez vérifier en utilisant l’outil de votre IDE ou par exemple DBeaver, que vos migrations ont bien été lancées.

Les notions de domain, application, infrastructure et bootstrap

Ça y est, notre API est prête, nous pouvons commencer à développer. Mais d’abord explication un peu théorique de ce que nous allons faire pour pouvoir mettre en évidence la notion d’architecture hexagonale.

Tout d’abord, pourquoi mettre en place une telle architecture ? L’intérêt de l’architecture hexagonale apparait dans le cas d’une logique métier complexe car elle isole naturellement le code que l’on veut tester en omettant toute dépendance technique. Ansi les tests sont faciles à écrire et le code complexe gagne en robustesse. De plus, si durant la vie du projet, l’envie de changer une implémentation technique, comme notre base de données par exemple, se fait ressentir, le cost of change de cette décision sera par conséquent réduit.

Notre application va se composer en 4 grands packages :

  • Domain : nous devons mettre dans ce package l’ensemble de notre code métier. Autrement dit, le code avec de la valeur ajoutée. Nous y retrouverons nos objets métiers, les méthodes de ces objets, etc. Ce package doit être indépendant de toutes implémentations techniques (nous y reviendrons).
  • Application : il représente toutes les implémentations techniques qui se base sur notre logique métier. On pourra retrouver la définition de notre API REST avec nos controllers, nos DTOs
  • Infrastructure : il représente toutes les implémentations techniques sur laquelle est basée notre logique métier. On y retrouvera les logiques de connection à nos bases de données, nos entités…
  • Bootstrap : c’est là que l’on retrouve toutes les choses relatives à l’initialisation de notre application.

Voici donc l’arborescence de notre application.

Arborescence de l’API

L’implémentation de notre premier endpoint

Nous allons donc définir notre controller permettant de récupérer la liste des ID de pokémons que l’on ne possède pas déjà comme suit :

Notre PokemonController  (présent dans notre package application) prend en dépendance notre service CapturablePokemonUseCase qui lui, fait parti de notre domain. Jusque-là, rien d’anormale.

  • On demande à notre service UseCase de nous donner, parmi le top 20 des pokémons, ceux que l’on peut capturer.
  • Nous mappons ensuite cette liste d’objets vers notre DTO que l’on renvoie ensuite à notre API.

Maintenant, regardons ce qu’il se passe du côté de notre service contenu dans le domain. Nous pouvons voir ceci :

Nous comprenons globalement bien ce que fait ce service :

  • Il récupère la liste des IDs de Pokémons que l’on possède déjà.
  • Il récupère, via une API, le top 20 des Pokémons disponibles.
  • Il renvoie au controller, les Pokémons que l’on ne possède pas encore.

Vous pouvez voir ici notre objet métier (remarquez qu’aucune annotation Spring n'est présente dans notre domain).

Pourquoi est-ce que l’on injecte des interfaces dans notre service ?

L’inversion de dépendance

Vous vous souvenez quand je vous ai dit qu’il fallait que notre domain soit complètement indépendant de toutes implémentations techniques ? On y revient ici !

Lorsque nous avons regardé tout à l’heure l’implémentation de notre controller, je n’ai pas fait mention de quelconque interface lorsque j’ai dit que nous lui injections notre service. En effet, pour le cas du controller ce n’était pas nécessaire. C’était notre controller qui était dépendant de notre service du domain.

Dans le cas présent, ce n’est pas le cas, c’est notre domain (CapturablePokemonUseCase) qui dépend de PokemonApiFetcher et PokemonRepositoryFetcher et ces objets ne peuvent pas être des implémentations techniques (règle de l’architecture hexagonale). Alors que sont ces objets ?

La notion de ports et d’adapters

Vous avez sûrement déjà entendu la notion d’Architecture Ports & Adapters. C’est l’autre nom souvent donné à l’Architecture Hexagonale. Elle vient en réalité d’une métaphore toute simple de la construction de notre architecture.

Comme notre domain ne peut pas être dépendant d’implémentations techniques qui lui sont extérieures, il n’interagira qu’avec des interfaces (faisant partie intégrante du domain) que l’on appelle des ports. Ces interfaces seront ensuite implémentées par des classes faisant partie du package infrastructure. On appellera ces classes, des adapters.

Schéma de représentation de notre architecture ports & adapters

Les interfaces ne contiennent aucune logique, elles ne définissent que la signature des méthodes que l’on veut utiliser. De plus, ces méthodes ont un nom “métier”, sans prendre en compte l’implémentation technique choisie (par exemple capturePokemon).

Ainsi, nous avons inversé le sens de dépendance de notre domain par rapport aux différentes implémentations techniques. Ainsi, ce sont maintenant les adapters qui dépendent du domain.

Le mapping des objets manipulés

Certains d’entre vous l’auront sûrement déjà remarqué mais, nous utilisons des Mapper dans l’implémentation de notre controller, ainsi que dans nos adapters. À quoi peuvent-ils bien servir ?

Il s’agit là encore d’une contrainte de l’architecture hexagonale. Lorsque l’on dit que notre domain doit être indépendant de toutes classes résidant en dehors de son périmètre, cela vaut également pour les objets qu’il manipule. Ainsi, notre domain ne peut pas manipuler des objets tels que des DTOs de réponse de notre API, des entités de notre base de données…

Nous devons donc créer des objets spécifiques à notre domain et chaque passage de ces objets entre les packages application, infrastructure et domain doit se faire via des mappers. Vous trouverez un exemple ci-dessous :

L’injection de dépendances

Vous vous demandez sûrement depuis tout à l’heure pourquoi est-ce que nous n’avons pas encore parlé du package bootstrap de notre application et vous auriez raison. En effet, notre domain est fonctionnellement indépendant du reste de notre application grâce à l’utilisation des différents mappers et les interfaces que nous lui fournissons en dépendance. Mais alors comment ces interfaces sont-elles injectées ?

Pour les parties externes à notre domain, nous utilisons les décorateurs Spring @RestController et @Component. Mais alors, pourquoi n’utilisons-nous pas le décorateur @Service sur les classes de notre domain ? Eh bien… pour ne pas rendre notre domain dépendant à Spring Boot ! Je sais, c’est discutable, mais c’est tout de même intéressant de vous montrer comment faire et ça se passe dans le package bootstrap.

Nous réalisons donc l’ensemble des injections de dépendances de notre domain “à la main”.

Remarque : nous pourrions pousser cette notion et réaliser l’entièreté des injections de dépendances de l’application “à la main” afin de rendre encore davantage notre application, agnostique de Spring Boot. Cela dit, posez-vous toujours la question “est-ce que c’est nécessaire ?”.

Nous voyons que cette injection peut être contraignante, surtout si l’on doit le faire pour chacun des services de notre domain. Je vous mets donc ici un article permettant d’utiliser des annotations custom pour simplifier cette génération de @Bean tout en respectant les principes de l’architecture hexagonale. L’exemple utilisant le language Kotlin, cet article vous aidera à le transposer en Java.

Et voilà, de cette manière, nous avons rendu notre domain (notre logique métier) complètement indépendant du reste de notre application ! 👏

Tester notre application

Je ne vous apprends rien en vous disant qu’une bonne application est une application testée correctement. Dans ce cas, en quoi l’architecture hexagonale nous est utile ?

Séparation des responsabilités

L’architecture hexagonale nous force à définir des limites claires entre les différentes parties de notre application selon leur rôle. Ce qui rend les tests unitaires et les tests d’intégrations plus faciles à écrire et à maintenir.

L’injection de dépendances

L’architecture hexagonale favorise l’utilisation de l’injection de dépendances pour fournir uniquement les ressources nécessaires aux différentes parties de notre application, les rendant  plus modulaires et indépendantes. Cela facilite la substitution des dépendances par des versions fictives ou simulés lors des tests.

Un exemple de test unitaires
Un exemple de test d’intégration

Le but d’un test d’intégration est de vérifier que l’ensemble des parties de notre application fonctionnent correctement ensemble.

Afin de simuler ce que nous renvoie l’API PokéAPI, nous allons utiliser Wiremock. Vous trouverez toute la configuration nécessaire sur le repo du projet, que je ne vais pas détailler ici.

Nous pouvons donc écrire notre test d’intégration de la sorte :

Archunit et les règles architecturales

Je tenais à vous présenter rapidement cette librairie assez utile qu’est Archunit. Il s’agit d’une librairie Java open-source permettant d'écrire des tests unitaires pour vérifier la conformité de votre architecture. Vous pouvez écrire des règles de nommage pour certaines classes, mais dans notre cas, le plus intéressant reste tout de même les règles d’architecture de code.

L’écriture de ce type de tests parle d’elle-même ! Avec le décorateur @AnalyzeClasses, nous renseignons les classes sur lesquelles nous voulons vérifier nos différentes règles. C’est en utilisant le décorateur @ArchTest que nous explicitons à Junit que nous définissons une méthode comme une règle Archunit qui sera ensuite analysée lorsque nous lancerons nos tests.

Pour résumer

Je n’ai pas explicité l’entièreté du développement des différentes fonctionnalités de notre application, mais comme vous pouvez le voir, il y avait tout même déjà beaucoup de choses à dire.

Je tiens quand même à vous rappeler qu’il est important de toujours se demander “est-ce que j’ai réellement besoin de mettre en place une architecture hexagonale ?”. En effet, on se rend très vite compte que l’architecture hexagonale peut être très, parfois trop, contraignante. On créé de nombreuses classes afin de rester dans les clous, on mappe énormément d’objets entre les différentes parties de notre application… et cela peut occasionnellement demander quelques efforts d’abstractions.

Comme mentionné à plusieurs reprises, nous faisons énormément d’effort pour d’isoler la logique métier des autres parties de notre application. Ce genre d’architecture se prête donc généralement à des applications avec énormément de logique métier.

Vous allez me dire que je n’ai pas nécessairement pris comme exemple, l’application la plus propice à ce genre d’architecture et vous auriez raison. J'espère que grâce à cet article et à cette API simple, vous avez pu apprendre et comprendre les bases de l’architecture hexagonale.

Ici, nous nous sommes concentré sur l’implémentation concrète d’une architecture hexagonale. Cependant, si vous voulez d’avantage creuser les erreurs types et les différentes pièges dans lesquels il ne faut pas tomber lorsque l’on met en place ce type d’architecture, je vous invite à lire cet article qui couvre d’avantage ces points.