Conception en couches de l’API Node.js : des contrôleurs volumineux à l’architecture propre
Apprenez à refactorer une API Node.js en couches de contrôleurs, de services et d’accès aux données afin de résoudre les logiques métier complexes, les erreurs incohérentes et les difficultés de mise à l’échelle.
Sources: I Built a Node.js API. Then I Realized I Was Doing It Wrong.
Leçons pratiques sur la manière de structurer, d’échaler et de maintenir correctement un backend Node.js, tirées de la création d’API en production.
Lorsque vous commencez à écrire des API Node.js, le travail de backend peut sembler étonnamment simple.
Définir une route.
La relier à la base de données.
Envoyer du JSON en retour.
C’est tout.
Pendant un certain temps, cette approche fonctionne réellement bien.
Puis l’application commence à grandir.
Soudain, vous faites face à :
- Des règles métier intégrées directement dans les contrôleurs
- Des appels à la base de données répétés dans chaque fichier de route
- Une logique de validation redondante
- Un traitement des erreurs qui varie d’un point de terminaison à l’autre
- Des fichiers surchargés
- Un code qui résiste aux tests automatisés
- Une logique d’authentification mêlée à la logique métier
- De petites modifications qui cassent des fonctionnalités qui semblaient sans rapport
L’API ne échoue pas techniquement.
Quelque chose de plus subtil ne fonctionne pas correctement :
Apporter des modifications devient véritablement difficile.
C’est à ce moment-là qu’on se rend compte qu’il existe une réelle différence entre une API qui fonctionne simplement et une autre capable de résister à un développement continu dans des conditions réelles.
1. Ma première architecture Node.js
const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users"); res.json(users);
});app.post("/users", async (req, res) => {
const { name, email } = req.body; const user = await db.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
[name, email]
); res.json(user);
});app.listen(3000);
Aucun de ces codes n’est incorrect en soi.
Pour un prototype rapide, c’est un point de départ tout à fait raisonnable.
Les problèmes commencent dès que le projet commence à se développer.
Imaginez l’ajout progressif de fonctionnalités telles que :
- Authentification
- Autorisation
- Vérification des entrées
- Vérification des adresses e-mail
- Traitement des paiements
- Gestion des coupons
- Gestion des commandes
- Suivi des stocks
Imaginez maintenant que vous deviez intégrer directement chacun de ces éléments dans vos gestionnaires de routes.
À ce stade, vous n’avez plus une véritable architecture.
Vous avez plutôt un amas de code qui s’exécute simplement.
2. Le problème n’était pas Node.js
C’est une erreur facile à commettre au début.
L’instinct est de penser :
"Ma base de code devient chaotique simplement parce que l’application grandit."
Ce n’est qu’une partie de l’histoire.
La véritable cause était un manque de séparation des responsabilités.
Un seul contrôleur gérait beaucoup trop de choses.
Il était responsable de :
- Le parsing de la requête entrante
- La validation des données saisies
- Vérification de l’authentification
- Exécution de la logique métier
Cela représente six ou sept tâches distinctes regroupées en un seul endroit.
Chaque fois qu’une exigence changeait, il fallait rééditer ce même fichier.
Repetitivement.
3. L’architecture que j’utilise aujourd’hui
Une stratégie plus propre consiste à diviser l’application en couches distinctes.
Par exemple :
src/
│
├── controllers/
│ └── user.controller.ts
│
├── services/
│ └── user.service.ts
│
├── repositories/
│ └── user.repository.ts
│
├── dto/
│ └── create-user.dto.ts
│
├── middleware/
│ └── auth.middleware.ts
│
├── routes/
│ └── user.routes.ts
│
├── utils/
│ └── error-handler.ts
│
└── app.ts
Le principe de base est simple :
Chaque couche doit avoir une responsabilité claire.
4. Les contrôleurs ne doivent pas contenir de logique métier
La tâche d’un contrôleur doit se limiter aux aspects liés à HTTP.
Quelque chose comme ceci :
export class UserController {
constructor(
private readonly userService: UserService
) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.userService.createUser(req.body); return res.status(201).json({
success: true,
data: user,
});
} catch (error) {
return res.status(500).json({
success: false,
message: error.message,
});
}
}
}
Le contrôleur n’a pas besoin de savoir :
- Où ou comment le enregistrement de l’utilisateur est stocké
- Quelle technologie de base de données est utilisée
Rien de tout cela ne relève de ses responsabilités.
Sa seule tâche est de gérer la requête entrante et de former la réponse à envoyer.
5. Placer la logique métier dans des services
C’est à ce niveau que réside la véritable logique de l’application.
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
async createUser(data: CreateUserDto) {
const existingUser =
await this.userRepository.findByEmail(data.email); if (existingUser) {
throw new Error("User already exists");
} const hashedPassword = await hashPassword(
data.password
); return this.userRepository.create({
...data,
password: hashedPassword,
});
}
}
Cela permet à la logique d’être réutilisée.
Une autre partie du système, par exemple, pourrait simplement appeler :
userService.createUser(data);
sans avoir besoin de simuler une requête HTTP du tout.
Cette réutilisabilité est très importante tant pour les tests que pour la maintenance à long terme.
6. L’accès à la base de données mérite son propre niveau
La logique métier ne devrait pas être encombrée de commandes SQL brutes ou d’appels ORM dispersés un peu partout.
C’est précisément le problème que résolvent les repositories.
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
} async create(data: CreateUserInput) {
return prisma.user.create({
data,
});
}
}
Avec cela en place, la séparation des préoccupations devient évidente.
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Chaque couche ne connaît que ce dont elle a strictement besoin, et rien de plus.
7. Pourquoi les DTO sont plus importants que vous ne l’imaginez
Une erreur fréquente au début est de passer directement les corps de requête bruts dans l’application.
Quelque chose comme ceci :
const user = await userService.createUser(req.body);
C’est risqué.
Qu’y a-t-il réellement à l’intérieur de req.body ?
Pouvez-vous en être sûr ?
Un DTO établit un contrat clair et défini pour ce qui est attendu.
class CreateUserDto {
name: string;
email: string;
password: string;
}
À partir de là, les données entrantes sont validées avant d’être autorisées à approcher la logique de l’application.
Par exemple, un en-tête de données comme :
{
"name": "Mit",
"email": "mit@example.com",
"password": "secret123"
}
peut être vérifié contre des règles telles que :
- Champs obligatoires
- Format d’e-mail
- Longueur du mot de passe
- Types de données
Le principe fondamental ici est simple :
N’ayez jamais confiance dans les données provenant du client.
L’interface utilisateur ne constitue pas une barrière de sécurité.
8. La cohérence dans la gestion des erreurs est importante
Un problème lié aux API en phase initiale est l’incohérence dans la structure des réponses.
Une route peut renvoyer :
{
"error": "User not found"
}
Une autre répond par :
{
"message": "Not found"
}
Tandis qu’une autre encore a l’aspect suivant :
{
"success": false,
"data": null
}
Pour quiconque développe une interface utilisateur utilisant cette API, cette incohérence est épuisante.
L’adoption d’une approche centralisée de gestion des erreurs rend l’API bien plus facile à utiliser.
Par exemple :
{
"success": false,
"message": "User not found",
"code": "USER_NOT_FOUND"
}
La forme spécifique choisie n’a pas grande importance.
Ce qui compte, c’est qu’elle soit toujours la même.
9. L’authentification n’est pas l’autorisation
C’est une distinction qui pose problème à de nombreux développeurs dès le début.
L’authentification répond à une question :
Qui êtes-vous ?
L’autorisation répond à une autre question :
Quelles actions êtes-vous autorisé à effectuer ?
D’un point de vue logique, cela se présente à peu près comme ceci :
Login
↓
Authentication
↓
JWT / Session
↓
User identified
↓
Authorization
↓
Can this user delete the product?
Le fait qu’un utilisateur soit authentifié ne signifie pas pour autant qu’il ait le droit d’effectuer n’importe quelle action souhaitée.
C’est précisément pourquoi les vérifications de rôle et de permissions doivent être traitées séparément de la logique de connexion/authentification de base.
10. Il faudra finalement penser à l’échelle
Lorsque le code est suffisamment bien organisé, une nouvelle question apparaît :
Que se passe-t-il lorsque le trafic augmente ?
C’est à ce stade que des outils tels que Redis, les files d’attente de tâches et les index de base de données deviennent utiles.
Imaginez un point d’entrée qui exécute constamment une requête de base de données coûteuse à chaque demande.
Au lieu de :
Request
↓
Database
↓
Response
le système pourrait finalement passer à quelque chose comme :
Request
↓
Redis Cache
↓
Cache Hit → Response
│
└── Cache Miss
↓
Database
↓
Redis
↓
Response
Et pour tout ce qui ne devrait pas bloquer la réponse :
API
↓
Queue
↓
Worker
↓
Email / Notification / Processing
Cela empêche les tâches lentes et non critiques de retarder la demande réelle.
Mais voici l’avertissement qu’il convient de rappeler :
Ne recourez pas à Redis, aux files d’attente, à Kafka, aux microservices ou à Kubernetes simplement parce qu’ils ont l’air impressionnants.
L’architecture existe pour résoudre un problème réel.
Elle ne devrait pas en créer de nouveaux.
11. La leçon la plus importante ici
Le véritable changement de mentalité se résume à ceci :
Cessez d’optimiser pour la vitesse à laquelle le code est écrit.
Optimisez plutôt pour la manière dont il peut être modifié en toute sécurité par la suite.
Lorsqu’on commence un premier projet, il semble efficace de tout mettre dans un seul fichier.
Au début, c’est effectivement le cas.
Cependant, rédiger la version initiale ne représente qu’une petite partie du travail total.
La plupart des modifications apportées à une base de code interviennent par la suite.
Au fil du temps, un projet devra :
- Ajouter de nouvelles fonctionnalités
- Réparer les bugs
- S’adapter à des exigences changeantes
- Remplacer des bibliothèques
- Mettre à jour les schémas de base de données
- Intégrer un système d’authentification
- Mettre en place des systèmes de permissions
- Raccourcir les requêtes lentes
- Se connecter à des services tiers
Si l’architecture rend chacune de ces tâches difficile, la vitesse initiale obtenue n’était en réalité pas un avantage réel.
12. Un meilleur point de départ
Un backend Node.js moderne pourrait raisonnablement être structuré de la manière suivante :
src/
│
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.dto.ts
│ │ └── user.routes.ts
│ │
│ ├── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ └── product.dto.ts
│
├── middleware/
├── config/
├── database/
├── utils/
└── app.ts
Dans les applications plus volumineuses, organiser le code en fonction de modules ou fonctionnalités s’avère généralement plus efficace que de regrouper tout le contenu dans d’immenses dossiers partagés remplis de contrôleurs et de services non liés entre eux.
La structure exacte des dossiers n’est pas l’essentiel.
Ce qui compte, c’est que la responsabilité de chaque composant reste claire.
13. Sept règles à suivre
En résumé, tout se résume à sept règles :
1. Gardez les contrôleurs légers
Les contrôleurs servent uniquement à gérer les requêtes HTTP, rien de plus.
2. Placez la logique métier dans les services
C’est dans les services que doit résider le comportement réel de l’application.
3. Isolez les opérations sur la base de données
Évitez que les requêtes SQL se retrouvent partout dans le code.
4. Validez les entrées externes
N’assumez pas que req.body contient bien ce qui est attendu.
5. Standardisez les erreurs
Rendre les réponses en cas d’échec prévisibles et uniformes.
6. Ajouter des infrastructures uniquement lorsque c’est nécessaire
Redis, les files d’attente et les microservices sont des outils utilisés lorsqu’ils sont justifiés, et non des signes de seniorité.
7. Concevoir pour l’évolution
Quel que soit celui qui maintient ce code, il le modifiera bien plus souvent qu’il ne l’écrira à partir de zéro.
Conclusion
Mettre en place une API Node.js n’est pas difficile.
La garder compréhensible des mois après le début du développement de nouvelles fonctionnalités est un défi bien plus grand.
Il est facile d’associer un bon développement backend au fait de savoir comment connecter :
GET
POST
PUT
DELETE
Ce n’est en réalité que le point de départ.
La véritable compétence réside dans la capacité à savoir où chaque élément logique doit se trouver, comment les différentes parties du système communiquent entre elles, et comment l’évoluer sans casser tout le reste dans le processus.
Tout projet n’a pas besoin d’une architecture complexe.
Il a plutôt besoin d’une architecture proportionnelle à la complexité du problème réel.
Pourquoi ? Parce que l’objectif n’a jamais été :
« Le faire fonctionner. »
L’objectif réel est :
« Faciliter son adaptation lorsque les exigences évoluent inévitablement. »