Accueil / Articles / Structurer les services Node.js avec des modules orientés domaine et des couches propres

Structurer les services Node.js avec des modules orientés domaine et des couches propres

Apprenez à organiser une base de code Node.js en composants basés sur des domaines, à appliquer une architecture stricte en 3 couches, et à exposer des utilitaires partagés via des API publiques claires.

1300 mots

Lorsque vous lancez un nouveau service Node.js, l’élan de développement s’accélère rapidement. Des frameworks tels qu’Express, Fastify et NestJS vous permettent d’avoir un point de terminaison REST ou GraphQL en fonctionnement en quelques minutes. Cependant, à mesure que les équipes s’agrandissent et que la logique métier se complexifie, un problème bien connu apparaît souvent dans ces bases de code : elles deviennent des enchevêtrements difficiles à suivre, souvent surnommés le « backend spaghetti ».

Cet article présente une série en quatre parties sur les bonnes pratiques Node.js de niveau entreprise, axée sur l’établissement d’une base architecturale capable de faire face à une équipe en croissance. Vous découvrirez comment organiser le code autour des domaines métier, maintenir une séparation claire entre les différentes couches, et créer des modules partagés qui ne deviennent pas un fardeau.

1. Structurer par composants métier (organisation pilotée par les domaines)

L’une des erreurs fréquentes dans les projets Node.js consiste à organiser le code uniquement en fonction des rôles techniques au niveau supérieur du dossier src.

L’anti-pattern : la stratification technique à la racine

❌ AVOID: Layered by technical type
src/
├── controllers/
│   ├── userController.js
│   ├── orderController.js
│   └── paymentController.js
├── models/
│   ├── userModel.js
│   ├── orderModel.js
│   └── paymentModel.js
├── services/
│   ├── userService.js
│   ├── orderService.js
│   └── paymentService.js
└── routes/
    ├── userRoutes.js
    ├── orderRoutes.js
    └── paymentRoutes.js

Pourquoi la stratification technique échoue à grande échelle

  1. Mauvaise localisation : La création d’une seule fonctionnalité — par exemple, un flux de travail de « remboursement de commande » — oblige à naviguer entre quatre ou plus de dossiers non liés.
  2. Bordures floues : Comme tout le matériel technique est regroupé, les ingénieurs finissent par interagir directement avec des services et des modèles différents, ce qui génère des dépendances circulaires et un couplage trop fort.
  3. Charge cognitive élevée : Une fois que le nombre de modèles dépasse 50, un répertoire monolithique controllers/ ou models/ devient vraiment difficile à naviguer.

La solution : des composants de domaine modulaires

Une approche plus efficace consiste à s’organiser autour des capacités métier — ce que le Domain-Driven Design appelle des Contextes Délimités. Chaque composant devient alors un domaine autonome, regroupant ses propres contrôleurs, services, repositories et modèles de domaine.

✅ PREFER: Component-based architecture
src/
├── components/
│   ├── users/
│   │   ├── users.controller.js
│   │   ├── users.service.js
│   │   ├── users.repository.js
│   │   └── users.routes.js
│   ├── orders/
│   │   ├── orders.controller.js
│   │   ├── orders.service.js
│   │   ├── orders.repository.js
│   │   └── orders.routes.js
│   └── payments/
│       ├── payments.controller.js
│       ├── payments.service.js
│       └── payments.repository.js
└── shared/
    ├── logger/
    └── database/

Avantages :

  • Autonomie : Tout ce qui concerne les orders reste dans un seul dossier.
  • Prêt pour les microservices : Si le composant payments devient trop volumineux ou complexe, il est beaucoup plus simple de le transformer en microservice indépendant, puisque ses dépendances sont déjà isolées du reste de l’application.

2. Imposer une architecture en 3 couches stricte au sein des composants

Dans chaque composant métier, maintenez une séparation claire entre trois couches :

┌─────────────────────────────────────────────────────────┐
│ 1. Entry Point / Transport Layer                        │
│    (Controllers, Event Subscribers, Route Handlers)    │
└───────────────────────────┬─────────────────────────────┘
                            │ Calls with plain DTOs
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 2. Domain / Business Layer                              │
│    (Services, Business Logic, Validation Rules)         │
└───────────────────────────┬─────────────────────────────┘
                            │ Calls repository methods
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 3. Data Access Layer                                    │
│    (Repositories, ORMs, Database Queries)              │
└─────────────────────────────────────────────────────────┘

La règle d’or : tenir les objets Web à l’écart de la logique de domaine

Les services métier et les repositories ne doivent jamais recevoir d’objets spécifiques au framework — pas de req ou res d’Express, pas non plus d’instance de requête Fastify.

La mauvaise méthode : fuite d’objets de transport HTTP

// orders.service.js
async function createOrderService(req, res) {
  // BAD: Service knows about HTTP headers, status codes, and req.body
  const userId = req.headers['x-user-id'];
  const orderData = req.body;

  if (!orderData.items || orderData.items.length === 0) {
    return res.status(400).json({ error: "Cart cannot be empty" });
  }

  const newOrder = await db.orders.insert({ userId, ...orderData });
  return res.status(201).json(newOrder);
}

Pourquoi cela pose problème :

  • createOrderService devient inutilisable en dehors d’un contexte HTTP — on ne peut pas l’appeler depuis un script CLI, un consommateur Kafka ou RabbitMQ, ou une tâche cron, car aucun de ces outils ne fournit de req ou res.
  • Les tests doivent désormais simuler des objets de requête et de réponse HTTP au lieu d’utiliser des valeurs JavaScript ordinaires.

La bonne méthode : couche de services découplée

// orders.controller.js (Transport Layer)
const orderService = require('./orders.service');

async function handleCreateOrder(req, res, next) {
  try {
    // 1. Extract values from web context
    const userId = req.headers['x-user-id'];
    const { items, shippingAddress } = req.body;

    // 2. Call domain service with pure primitives/DTOs
    const order = await orderService.createOrder({
      userId,
      items,
      shippingAddress
    });

    // 3. Format HTTP response
    return res.status(201).json({ status: 'success', data: order });
  } catch (err) {
    next(err); // Defer error handling to central middleware
  }
}

// orders.service.js (Domain Layer)
const orderRepo = require('./orders.repository');

async function createOrder({ userId, items, shippingAddress }) {
  // 1. Pure business logic validation
  if (!items || items.length === 0) {
    throw new ValidationError('Order must contain at least one item.');
  }

  // 2. Business calculation
  const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  // 3. Persist via repository
  const createdOrder = await orderRepo.saveOrder({
    userId,
    items,
    shippingAddress,
    totalAmount,
    status: 'PENDING'
  });

  return createdOrder;
}

3. Modulariser les utilitaires et imposer des API publiques propres

Au fur et à mesure que l’application se développe, des fonctionnalités partagées telles que le journalisation, l’authentification, les outils de connexion à la base de données et les clients pour des API externes deviennent nécessaires pour de nombreux composants différents. Plutôt que de permettre à chaque consommateur d’accéder directement à des chemins de fichiers internes complexes, il convient de regrouper ces utilitaires partagés en modules internes disposant d’un point d’entrée public clairement défini.

Le danger des importations profondes

// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");

Si l’équipe responsable du journaliseur décide plus tard de réorganiser la structure de ses dossiers internes, tout consommateur ayant importé depuis ce chemin profond cesse immédiatement de fonctionner.

Solution A : Exporter via index.js (CommonJS)

Mettre en place un seul fichier d’entrée qui ne réexporte que les éléments destinés à une utilisation externe, en cachant tout le reste.

// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');

// Expose ONLY public functions
module.exports = {
  logger,
  auditLog
};

Les consommateurs peuvent alors importer via cette interface simplifiée au lieu d’accéder aux parties internes :

// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');

Solution B : Exportation de paquets avec ESM (Node.js Workspaces / ESM moderne)

Si vous travaillez avec du code ESM moderne de Node.js ou des paquets au sein d’un monorepo, utilisez le champ exports dans package.json pour restreindre explicitement les fichiers qui peuvent être importés depuis l’extérieur du paquet.

// shared/logger/package.json
{
  "name": "@my-app/logger",
  "version": "1.0.0",
  "main": "./src/index.js",
  "exports": {
    ".": "./src/index.js"
  }
}

Avec cette configuration en place, toute tentative d’importer un chemin privé tel que @my-app/logger/src/internal/formatter.js échoue avec une erreur en temps de exécution, ce qui crée une limite claire empêchant tout couplage accidentel avec les détails d’implémentation.

Liste de contrôle pour l’architecture – Partie 1

  • Organisation du domaine : Le code est-il structuré en fonction des composants fonctionnels (orders, users) plutôt qu’en dossiers techniques génériques (controllers, models) ?
  • Services purement métier : Les fonctions de service sont-elles dépourvues d’arguments liés à la couche de transport tels que req, res ou next ?
  • Interfaces explicites : Les modules partagés présentent-ils une interface publique délibérément restreinte, que ce soit via un fichier index.js ou des exportations de package.json ?
  • Flux de données correct : Les données se déplacent-elles strictement vers le bas, du contrôleur au service puis au repository, sans que les couches inférieures ne puissent importer des éléments provenant des couches supérieures ?

Lectures complémentaires

  • Pourquoi décoder les chunks du buffer comme du texte endommage le téléchargement des fichiers — Explique comment traiter les données binaires du buffer comme du texte UTF-8 endommage silencieusement les fichiers téléchargés, et montre la manière correcte de gérer ces données au niveau des octets pour y remédier.