Retour au cours

backend / nodejs

Architecture en couches et injection de dépendances

Leçon 181 exercice

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.

CoucheRôleCe qu'elle NE doit PAS faire
Routesrouter une requête HTTP vers un contrôleurcontenir de la logique
Contrôleurstraduire HTTP en appel au servicecontenir de la logique métier
Serviceslogique métier pureconnaître Express ou le SQL
Repositoriesseule couche qui parle à la basecontenir 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

text
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
js
// 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];
  }
}
js
// 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;
  }
}
js
// 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);
    }
  };
}
js
// 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);
js
// 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

1 disponible
1

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.

Résoudre l’exercice →