Introduction aux principes de la clean architecture dans une API NodeJs (Express)
Quatrième article de notre série sur la clean architecture (les précédents dans la liste en bas de page), et rentrons plus dans la technique avec une introduction à la mise en œuvre des principes dans une API Node.JS avec Express.
Voyons la structure global d’un projet et les détails des grands concepts (entities, adapter, ports…).
Organisation des répertoires principaux
Commençons par jeter un coup d’œil à la structure générale des répertoires. Comme souvent, tout le code source de notre API est regroupé dans un répertoire “/src”. Ensuite, nous allons tout de suite voir la distinction Core vs Infrastructure :
Le cœur de l’application dans “/core”
C’est ici que résident les cas d’utilisation, les entités métier et les règles de gestion. Cette partie ne dépend d’aucun framework ou librairie externe, elle représente le strict minimum pour faire fonctionner notre application : C’est le cœur fonctionnel de notre application. Il est lui-même divisé en plusieurs sous-répertoires.
Les cas d’utilisations
Dans “/core/use-cases”, on retrouve les cas d’utilisation métier. Chaque cas d’utilisation possède son propre fichier typescript avec si besoin ses types d’entrée et de sortie (ce qui rentre dans le use case et ce qui en sort). On a deux écoles :
- Un sous dossier avec regroupement des cas d’utilisation. Exemple : Un sous dossier user dans lequel je regroupe tous les cas des users
- Tous les cas d’utilisation à la racine de use-cases, sans arborescence particulière
Je préfère personnellement la deuxième solution, tout en “flat”, pour la simple raison qu’on ne se pose jamais la question “où je mets / où a été mis ce use case ?”. Un exemple simple, l’adresse d’un utilisateur, on la met dans “user” ou “address” ? Autant de réponses que de développeurs, donc autant ne pas mettre de sous dossier !
Un exemple de cas d’utilisation qui permet d’obtenir un livre par son id :
class GetBook {
private bookRepository: BookRepository;
private logger: Logger;
constructor() {
this.bookRepository = container.resolve<BookRepository>('BookRepository');
this.logger = container.resolve<Logger>('Logger');
}
async execute(id: string): Promise<Book | 'BOOK_NOT_FOUND'> {
this.logger.debug('[Get-book usecase] Start');
const data = await this.bookRepository.findById(id);
return data ?? 'BOOK_NOT_FOUND';
}
}
export default GetBook;
Les entities
Le répertoire “/core/entities” rassemble nos modèles de données métier comme par exemple “User” ou “Product”. Ce sont de simples objets qui représentent les concepts de notre domaine, ils rassemblent les règles métiers de ceux-ci.
Prenons l’exemple de notre “User” justement, et plus précisément d’un utilisateur qui n’est pas connecté, donc pas connu de l’API :
export class NotExistingUser extends User {
constructor() {
super();
}
public hashPassword(notHashedPassword: string) {
const hmac = createHmac('sha512', this.config.salt);
hmac.update(notHashedPassword);
return hmac.digest('hex');
}
}
On a ici une classe qui étend User et récupère donc ses propriétés, et une méthode qui lui appartient, c’est “sa” règle métier : On hash un mot de passe (pour créer son compte ou le connecter).
Les ports
Ce sont ici des interfaces toutes simples, qui vont faire le lien entre la partie core et la partie infrastructure. Elles servent de contrat sans implémentation concrète. On peut y retrouver des ports pour des dépendances techniques, comme un logger par exemple, ou pour des repositories (bases de données…). Un exemple pour un repository de User :
interface UserRepository {
findByEmail(email: string): Promise<User | null>
create(user: User): Promise<void>
}
On retrouve dans ce contrat deux méthodes avec chacune des paramètres d’entrées et de sorties. La partie code sait donc ce qu’elle va recevoir d’infrastructure et inversement.
Les détails techniques dans “/infrastructure”
Au même niveau que core on retrouve donc le dossier infrastructure qui lui gère tous les détails techniques comme la persistence des données ou les appels à des services externes. C’est ici qu’on implémente concrètement les interfaces définies dans le core.
L’API
Commençons par l’API, dossier dans lequel nous allons mettre la config d’Express, ainsi que les controllers et autres middlewares.
Dans le détail pour les controllers, le dossier “/infrastructure/api/controllers” rassemble les contrôleurs par ressource ou cas d’utilisation.
Le rôle ici est de récupérer les données de la requête HTTP, de les valider et les mapper vers les entrées attendues par le cas d’utilisation, d’exécuter ce cas d’utilisation et enfin de formater la réponse. On va donc retrouver des sous-dossier par ressource, avec un fichier typescript pour le controller lui même, les DTO d’entrée et de sortie, et l’encodeur/décodeur des ces entrées/sorties.
Les adapters
Comme son nom l’indique, on retrouve dans ce répertoire l’adaptation technique des ports que l’on a définis côté core. Très important au niveau de l’arborescence d’identifier la dépendance liée à l’implémentation. Reprenons notre exemple du repository User :
// /core/ports/user-repository.port.ts
interface UserRepository {
findByEmail(email: string): Promise<User | null>
save(user: User): Promise<void>
}
// /infrastructure/adapters/mongo/user.repository.ts
import { UserRepository } from '../../core/ports/user-repository.port.ts'
export class MongoUserRepository implements UserRepository {
async findByEmail(email: string): Promise<User | null> {
// logique d'accès MongoDB
}
async save(user: User): Promise<void> {
// logique d'accès MongoDB
}
}
Au niveau de l’arborescence, on retrouve “adapters/mongo”, ce qui permet de comprendre que dans ce sous répertoire, nous avons toute l’implémentation technique d’accès à mongo DB. Comme on peut le voir, l’adapter reprend le contrat précisé dans le port. Si demain je souhaite changer de dépendance pour SQLite par exemple, il suffira de créer un second adapter, changer l’injection de dépendance, et il n’y aura aucun impact dans toute la partie core.
Conclusion
Cette structure répartit clairement les différentes responsabilités d’une API Node.js. Le core n’a aucune dépendance externe, les ports sont découplées de la logique métier et les détails d’implémentation sont délégués à la couche d’infrastructure.
En suivant ces principes de la Clean Architecture, votre code gagne en maintenabilité, testabilité et capacité d’évolution. Bien que l’exemple soit donné avec du code TypeScript et Express, cette organisation peut aisément s’adapter à d’autres frameworks Node.js ou même tout autre langage.
C’est finalement tout l’intérêt de la clean architecture : Pas de langage ou framework, mais un retour aux sources, au bon sens, et contrairement à une pensée erronée, retour à la simplicité !
FAQ:
- Quels sont les avantages de suivre les principes de la Clean Architecture dans un projet Express ? L’inconvénient d’Express (ou son avantage selon le point de vue !), c’est que c’est une coquille vide. On peut donc faire absolument ce qu’on veut, et aussi n’importe quoi. La clean architecture permet d’amener un cadre à toute l’équipe.
- Je fais de la clean architecture, dois-je absolument suivre cette structure à la lettre ? Non, cette structure n’est qu’un exemple parmi d’autres. L’essentiel est de respecter les principes fondamentaux de la Clean Architecture : séparation des préoccupations, découplage des détails techniques, dépendances vers l’extérieur, etc.
- Comment structurer les tests dans cette architecture ? On ne l’a pas mis dans cet article, mais les tests unitaires des cas d’utilisations peuvent être mis au même niveau que chacun, dans /core/use-cases. Personnellement, je préfère avoir un dossier tests à la racine qui est une convention qu’on voit souvent.
Un article de Nicolas Lapointe.
Cet article est une introduction à la Clean Architecture, et fait partie d’une série dédiée sur ce sujet. Rendez-vous sur les prochains !
Articles sur la Clean Architecture :