backend / nodejs
Architecture en couches et injection de dépendances
Explication
Ce que vous allez apprendre
- Séparer une application en couches (routes, contrôleurs, services, repositories) avec un rôle strict par couche
- Comprendre pourquoi la logique métier ne doit jamais connaître Express ni le SQL directement
- Tester un service complètement sans base de données réelle, grâce à un faux repository
- Pratiquer l'injection de dépendances : recevoir ses dépendances via le constructeur plutôt que les importer
- Reconnaître quand cette architecture est justifiée, et quand elle serait excessive
Dans quel contexte ?
Une application de gestion d'utilisateurs a grossi organiquement : les fonctions de route contiennent directement les requêtes SQL, la validation métier et l'envoi d'emails de bienvenue, le tout mélangé dans un seul fichier de 400 lignes. Écrire un test pour "un email déjà utilisé doit être rejeté" oblige à démarrer une vraie base de données et un vrai service d'email à chaque exécution. Cette leçon montre comment réorganiser ce code pour que la logique métier soit testable en isolation complète.
Le problème d'une application qui grossit
Sur un petit projet, mettre toute la logique directement dans les fonctions de route fonctionne très bien. C'est ce qu'on a fait dans les premières leçons Express.
Mais à mesure qu'une application grandit, ce code devient difficile à faire évoluer. Plus de règles métier, plus de types de données, plus de dépendances externes s'entremêlent au même endroit.
Le vrai problème apparaît surtout au moment de TESTER ce code. La logique HTTP, la logique métier et l'accès aux données sont mélangés, impossibles à isoler les uns des autres.
L'idée de cette architecture est de séparer les responsabilités par couche, chacune avec un rôle strict. Voyons-les une par une, de l'extérieur vers l'intérieur.
| Couche | Rôle | Ce qu'elle NE doit PAS faire |
|---|---|---|
| Routes | router une requête HTTP vers un contrôleur | contenir de la logique |
| Contrôleurs | traduire HTTP en appel au service | contenir de la logique métier |
| Services | logique métier pure | connaître Express ou le SQL |
| Repositories | seule couche qui parle à la base | contenir de la logique métier |
Les routes ne font que router une requête HTTP vers un contrôleur. Elles ne prennent aucune décision, elles orientent seulement.
Les contrôleurs traduisent HTTP en appel au service. Ils ne contiennent AUCUNE logique métier, juste de l'aiguillage.
Les services contiennent la vraie logique métier, sans jamais connaître Express ni le SQL. C'est le cœur de l'application, la partie la plus précieuse à protéger et à tester.
Les repositories, enfin, sont la SEULE couche qui parle réellement à la base de données. Toute requête SQL ou appel à un ORM passe uniquement par eux.
Une fois ces couches en place, l'avantage devient concret : un service peut être testé COMPLÈTEMENT sans base de données réelle. Il suffit de lui donner un faux repository, comme le montre l'exemple qui vérifie "email déjà utilisé" sans jamais toucher une vraie base.
Mais comment un service reçoit-il ce faux repository en test, et le vrai en production ? C'est le rôle de l'injection de dépendances : chaque classe REÇOIT ses dépendances via son constructeur plutôt que de les importer directement.
Ce choix rend le remplacement par des mocks trivial en test. Un seul endroit assemble les vraies dépendances entre elles : le "composition root" (container.js).
Le piège à connaître avant d'adopter cette architecture partout : elle ajoute de la verbosité, plus de fichiers, plus d'indirection. Sur un petit script ou un prototype, elle serait excessive ; elle se justifie surtout sur une application qui grossit, avec plusieurs développeurs.
Prérequis
Cette architecture n'est pas gratuite : elle ajoute des fichiers et de l'indirection. Réservez-la à une application dont la logique métier est amenée à grossir et à être testée sérieusement — pas à un script ponctuel ou un prototype jetable.
Commandes & code
Architecture en couches et injection de dépendances
src/
├── routes/ # Couche HTTP — parsing requête, formatage réponse
│ └── users.routes.js
├── controllers/ # Orchestration — appelle les services, ne contient PAS de logique métier
│ └── users.controller.js
├── services/ # Logique métier pure, indépendante d'Express
│ └── users.service.js
├── repositories/ # Accès aux données, seule couche qui connaît le SQL/l'ORM
│ └── users.repository.js
└── container.js # Câblage des dépendances// repositories/users.repository.js — SEULE couche qui parle à la base de données
export class UsersRepository {
constructor(pool) {
this.pool = pool;
}
async findById(id) {
const { rows } = await this.pool.query("SELECT * FROM users WHERE id = $1", [id]);
return rows[0] ?? null;
}
async create(data) {
const { rows } = await this.pool.query(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
[data.name, data.email]
);
return rows[0];
}
}// services/users.service.js — logique métier, ne connaît PAS Express ni SQL
export class UsersService {
constructor(usersRepository, emailService) {
this.usersRepository = usersRepository;
this.emailService = emailService;
}
async registerUser({ name, email }) {
const existing = await this.usersRepository.findByEmail(email);
if (existing) {
throw new ValidationError({ email: "Déjà utilisé" });
}
const user = await this.usersRepository.create({ name, email });
await this.emailService.sendWelcomeEmail(user.email); // testable indépendamment via un mock
return user;
}
}// controllers/users.controller.js — traduit HTTP <-> appels au service
export class UsersController {
constructor(usersService) {
this.usersService = usersService;
}
register = async (req, res, next) => {
try {
const user = await this.usersService.registerUser(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
};
}// container.js — composition root : le SEUL endroit où tout est câblé ensemble
import { pool } from "./db.js";
import { UsersRepository } from "./repositories/users.repository.js";
import { EmailService } from "./services/email.service.js";
import { UsersService } from "./services/users.service.js";
import { UsersController } from "./controllers/users.controller.js";
const usersRepository = new UsersRepository(pool);
const emailService = new EmailService(process.env.SMTP_URL);
const usersService = new UsersService(usersRepository, emailService);
export const usersController = new UsersController(usersService);// tests/users.service.test.js — le bénéfice concret : tester la logique métier SANS DB réelle
import { describe, it, expect, vi } from "vitest";
import { UsersService } from "../src/services/users.service.js";
describe("UsersService.registerUser", () => {
it("rejette un email déjà utilisé", async () => {
const fakeRepo = {
findByEmail: vi.fn().mockResolvedValue({ id: 1, email: "a@a.com" }),
create: vi.fn(),
};
const fakeEmailService = { sendWelcomeEmail: vi.fn() };
const service = new UsersService(fakeRepo, fakeEmailService);
await expect(service.registerUser({ email: "a@a.com" })).rejects.toThrow();
expect(fakeRepo.create).not.toHaveBeenCalled();
});
});Résumé
- Séparer routes/controllers/services/repositories isole la logique métier des détails techniques (HTTP, SQL).
- L'injection de dépendances par constructeur permet de remplacer une dépendance réelle par un mock en test.
- Un seul "composition root" (
container.js) câble toutes les dépendances concrètes ensemble. - Cette architecture a un coût de verbosité : justifiée sur une app qui grossit, excessive sur un petit script.
Exercices pratiques
Mission : rendre testable une logique métier enfouie dans un fichier de 400 lignes
Objectif : Extraire la logique métier d'une route Express monolithique vers une architecture en couches injectable, et écrire un test unitaire du service sans base de données réelle.
Contexte
La route POST /users d'un fichier routes.js de 400 lignes fait tout d'un coup : elle lit req.body, exécute directement pool.query('SELECT * FROM users WHERE email = $1', [email]) pour vérifier les doublons, insère le nouvel utilisateur avec une autre requête SQL inline, puis appelle sendgrid.send(...) directement pour l'email de bienvenue. Écrire un test pour "un email déjà utilisé doit être rejeté" oblige actuellement à démarrer une vraie base PostgreSQL et un vrai compte SendGrid de test.