Architecture propre en Node.js : guide pratique des repositories et use cases
La complexité croissante des applications web pousse les équipes à structurer leur code plus rigoureusement. Lorsqu'un projet Node.js démarre, il est tentant d'écrire des routes Express qui concentrent tout : validation, accès à la base, envoi d'emails, calculs métier. Ce modèle fonctionne au début, puis devient un casse-tête.
L'architecture propre, popularisée par Robert C. Martin, propose une réponse structurée. Elle vise à découpler la logique métier des détails techniques : frameworks, bases de données, services tiers. L'objectif est de rendre le métier indépendant de l'infrastructure, ce qui facilite la maintenance, l'évolution et les tests.
Au cœur de cette approche se trouvent les repositories, qui encapsulent l'accès aux données, et les use cases, qui orchestrent les opérations du domaine. Combinés à une inversion de dépendances rigoureuse, ces deux piliers permettent de construire des applications évolutives.
Ce guide explore la mise en œuvre concrète de ces concepts en Node.js. Vous y trouverez des principes, des exemples et des recommandations pour structurer un backend selon la clean architecture, sans tomber dans la sur-ingénierie.
Principes d'une architecture en couches
L'architecture propre repose sur une séparation stricte des couches logicielles. Au centre se trouvent les entités métier, qui modélisent les règles du domaine. Autour gravitent les use cases, qui définissent les actions de l'application. À la périphérie se situent les adaptateurs, qui connectent l'application au monde extérieur.
Le principe d'inversion de dépendance est central : les couches internes ne doivent jamais dépendre des couches externes. Un use case ne connaît pas la technologie de persistance, il manipule uniquement des abstractions. Cette règle offre une liberté énorme pour substituer une implémentation par une autre sans toucher au cœur métier.
Cette organisation favorise une testabilité élevée. Comme la logique métier ne dépend de rien de concret, on peut la tester avec des implémentations en mémoire. Les dépendances externes sont validées par des tests d'intégration ciblés, ce qui réduit les régressions.
Modéliser le domaine avec des entités riches
Les entités représentent les objets qui possèdent une identité unique. Un utilisateur, une commande ou un produit portent un identifiant et conservent leur continuité dans le temps. En TypeScript, on les définit avec des classes ou des interfaces enrichies de méthodes métier. Une méthode order.cancel() encapsule les règles d'annulation, plutôt que de laisser le use case décider seul.
Les value objects sont immuables et définis uniquement par leurs valeurs. Une adresse email, un montant en euros ou une plage horaire sont de bons candidats. Ils évitent la prolifération de primitives maladroites et renforcent l'expressivité des modèles.
Cette modélisation simplifie les use cases. Le développeur travaille avec des objets métier riches qui portent leur propre logique, ce qui rend le code plus lisible et réduit les bugs d'interprétation.
Les repositories comme frontière de persistance
Le rôle d'un repository est de fournir une interface de collection pour accéder aux entités du domaine. Il expose des méthodes comme findById, save ou delete, sans révéler la technologie sous-jacente. Le use case dépend d'une interface abstraite ; une implémentation concrète traduit ces appels en requêtes SQL, MongoDB ou autre système.
Cette abstraction débloque plusieurs cas d'usage. Vous pouvez basculer d'une base relationnelle à une base NoSQL en réécrivant uniquement l'implémentation du repository. Vous pouvez aussi fournir une implémentation en mémoire pour les tests automatisés. Les choix technologiques deviennent réversibles.
Le choix du système de persistance reste stratégique. Pour approfondir ce sujet, l'article comparer les bases de données NoSQL propose un panorama détaillé de MongoDB, Cassandra et Couchbase, avec leurs compromis en termes de cohérence, scalabilité et modèle de données.
Dans une architecture multi-services, le repository peut aussi appeler une API distante. L'interface reste la même, seule l'implémentation change, illustrant la puissance de l'inversion de dépendance.
| Approche DI | Idéal pour | Forces principales | Limites connues |
|---|---|---|---|
| Constructeur manuel | Microservices légers | Aucune dépendance, lecture claire | Verbeux à grande échelle |
| tsyringe | Projets Node.js moyens | API simple, décorateurs natifs | Fonctionnalités limitées |
| inversify | Applications complexes | Mature, riche en fonctionnalités | Configuration verbeuse |
| NestJS intégré | Plateformes d'entreprise | Écosystème riche, opinion claire | Cadre plus rigide à quitter |
Les use cases au cœur de l'application
Un use case représente une action métier complète. Créer une commande, valider un paiement ou générer un rapport sont autant de use cases distincts. Chacun reçoit ses entrées via un DTO, exécute la logique métier en s'appuyant sur les entités et les repositories, puis retourne un résultat structuré. Cette découpe unifie la lecture du code.
L'implémentation privilégie des fonctions pures ou des classes sans état. Les dépendances sont injectées via le constructeur, ce qui facilite la substitution lors des tests. Un use case ne déclenche jamais d'effet de bord externe directement ; il délègue cette tâche à des services dédiés.
La granularité mérite réflexion. Trop fins, les use cases éclatent la logique en une multitude de petites actions difficiles à suivre. Trop grossiers, ils accumulent des responsabilités hétérogènes. Une bonne pratique consiste à les aligner sur les actions métier identifiées avec le product owner.
L'injection de dépendances pour découpler
L'injection de dépendances permet aux use cases de recevoir leurs collaborateurs sans les instancier eux-mêmes. En Node.js, plusieurs approches coexistent : constructeurs manuels pour les petits projets, conteneurs légers comme tsyringe ou inversify pour les applications plus larges, ou des frameworks opinionnés comme NestJS qui intègrent la DI nativement.
Le choix du mécanisme dépend du contexte. Pour un microservice léger, un simple factory suffit largement. Pour une plateforme complexe, un conteneur apporte traçabilité et configuration centralisée. L'important est de garder une frontière nette entre la composition des objets et leur utilisation.
Cette discipline ouvre la porte aux microservices et à la communication inter-services. Dans une architecture distribuée, les services communiquent via API ou files de messages, ce qui pose des enjeux de résilience et d'observabilité. Le recours à un service mesh devient pertinent pour les plateformes de grande taille. Un dossier complet est disponible dans comparer les solutions de service mesh, où Istio, Linkerd et Consul sont passés au crible.
Connecter les adaptateurs au monde extérieur
Les adaptateurs constituent la couche la plus externe de l'architecture. Ils traduisent les requêtes HTTP en DTO d'entrée pour les use cases et formattent les réponses pour les clients. Dans une application Express, un contrôleur reçoit la requête, instancie le use case avec ses dépendances, puis renvoie le résultat. Aucun code de persistance ou de logique métier ne devrait apparaître dans ce contrôleur.
Cette séparation permet de changer de framework sans toucher au domaine. Migrer d'Express vers Fastify devient un exercice purement technique. Le cœur métier survit aux modes technologiques, ce qui est l'un des apports majeurs de la clean architecture.
Les services externes suivent la même logique. Pour stocker des fichiers, le use case manipule une interface StoragePort ; l'implémentation concrète peut s'appuyer sur un service cloud spécifique. L'analyse comparer les solutions de stockage cloud aide à y voir plus clair entre AWS S3, Google Cloud Storage et Azure Blob, trois options complémentaires.
Tester une architecture en couches
La séparation des couches simplifie radicalement la stratégie de tests. Les entités et les use cases se prêtent aux tests unitaires purs, exécutés en millisecondes sans dépendance externe. Les repositories et adaptateurs sont couverts par des tests d'intégration qui valident l'interaction avec la vraie technologie. Les tests end-to-end restent utiles mais moins critiques grâce à la fiabilité des couches internes.
Les mocks et les stubs jouent un rôle clé dans cette stratégie. On remplace les repositories par des implémentations en mémoire qui répondent à des scénarios déterministes, ce qui évite les flakiness liées aux bases de données. Cette discipline impose aussi de mieux concevoir les interfaces.
La couverture de code n'est qu'un indicateur parmi d'autres. Une architecture propre permet d'écrire des tests qui expriment l'intention métier, ce qui élève le niveau de qualité et renforce la confiance lors des refactorings.
Recommandations pour réussir votre mise en œuvre
- Commencez petit en isolant un seul use case existant avant de réécrire toute l'application.
- Privilégiez TypeScript pour bénéficier des interfaces et du typage strict entre les couches.
- Documentez les frontières entre les couches avec un diagramme partagé par toute l'équipe.
- Mettez en place des tests d'interface pour chaque port, indépendamment de l'implémentation.
- Limitez le nombre de dépendances injectées pour garder des use cases lisibles et focalisés.
- Nommez vos fichiers et dossiers selon le métier, jamais selon la technique employée.
- Revoyez régulièrement la structure du projet avec l'équipe pour éviter la dérive technique.
Plongez dans la pratique dès aujourd'hui : choisissez un use case de votre application actuelle et isolez-le derrière une interface claire. Vous mesurerez rapidement les bénéfices en termes de testabilité, de clarté et de sérénité au quotidien. Pour aller plus loin, explorez les ressources de DéveloppeurWeb.Com et appliquez ces principes à vos prochains projets Node.js.