Aller au contenu

Structurer une API Express

Pour une API qui comporte peu de routes, regrouper le code dans un seul fichier peut suffire. À mesure que l’application grandit, les mêmes fonctions finissent toutefois par gérer plusieurs responsabilités : traiter les requêtes, valider les données, appliquer les règles métier, accéder aux données et construire les réponses HTTP. Les modifications deviennent plus délicates et les tests plus difficiles à réaliser séparément.

Cette page présente une façon d’organiser une API Express en répartissant ces responsabilités. La fiche suivante montrera comment tester ses différentes composantes.

Voici une API de gestion de boîtes regroupée dans un seul fichier index.js. Elle permet de consulter la liste des boîtes, d’en créer, d’en consulter une et d’en supprimer une. Explorez cet exemple avant de poursuivre : chaque couleur met en évidence une responsabilité distincte, donc un aspect du code susceptible d’évoluer indépendamment des autres.

index.js, ligne par ligne
Choisissez une responsabilité pour isoler ses lignes.
7 responsabilités, 28 lignes
Chaque couleur correspond à une raison différente de modifier ce fichier.
import express from "express"; const app = express();app.use(express.json()); const boites = [];let prochainId = 1; app.get("/boites", (req, res) => {  res.json(boites);}); app.post("/boites", (req, res) => {  const { nom, capacite } = req.body ?? {};  if (typeof nom !== "string" || nom.trim() === "") {    return res.status(400).json({ erreur: "nom requis" });  }  if (!Number.isInteger(capacite) || capacite <= 0) {    return res.status(400).json({ erreur: "entier positif attendu" });  }  if (boites.some((b) => b.nom === nom)) {    return res.status(409).json({ erreur: "nom déjà utilisé" });  }  const boite = { id: prochainId++, nom, capacite };  boites.push(boite);  res.status(201).json(boite);}); app.get("/boites/:id", (req, res) => {  const boite = boites.find((b) => b.id === Number(req.params.id));  if (!boite) return res.status(404).json({ erreur: "boite introuvable" });  res.json(boite);}); app.delete("/boites/:id", (req, res) => {  const index = boites.findIndex((b) => b.id === Number(req.params.id));  if (index === -1) return res.status(404).json({ erreur: "boite introuvable" });  boites.splice(index, 1);  res.status(204).end();}); app.listen(3000);
Ce fichier fonctionne. Mais il a 7 raisons différentes de changer, toutes mêlées dans les mêmes routes, et une partie de son code est répétée d'une route à l'autre. Modifier l'une de ces responsabilités oblige à relire toutes les autres pour ne rien casser.

Ces quelques lignes combinent sept responsabilités. Une nouvelle règle de validation, un changement de format de réponse ou le remplacement du stockage peuvent alors nécessiter des modifications dans le même bloc de code. Il devient plus difficile d’en mesurer les conséquences. Deux autres problèmes apparaissent en explorant le filtre Code répété :

  1. La répétition du code. La recherche d’une boîte et le traitement du cas où elle n’existe pas sont reproduits dans deux routes, avec find dans l’une et findIndex dans l’autre. Modifier un seul de ces traitements risque de créer des comportements incohérents.
  2. La difficulté de tester les règles séparément. La règle qui interdit deux boîtes portant le même nom est intégrée à la route. Elle ne peut donc pas être appelée directement depuis un test sans extraire ou réorganiser le code.

Une API Express peut s’inspirer du MVC, mais elle ne possède généralement pas de vue HTML dans le serveur : elle produit notamment des réponses JSON, que d’autres applications peuvent afficher. Nous utiliserons ici une organisation en couches, parmi plusieurs approches possibles, avec des routes, des contrôleurs, des services et des modèles, ainsi que des intergiciels (middlewares) :

Couche Dossier Responsabilité Connait HTTP ?
Routes routes/ Associer une méthode HTTP et un chemin à des intergiciels et à un contrôleur oui
Contrôleurs controleurs/ Lire la requête, appeler le service, formuler la réponse oui
Services services/ Appliquer les règles métier non
Modèles modeles/ Gérer l’accès aux données non
Intergiciels intergiciels/ Traiter les requêtes en amont des contrôleurs ou gérer les erreurs oui

L’arborescence correspondante :

src/
├── app.js               assemblage de l'application
├── serveur.js           démarrage (app.listen)
├── routes/
│   └── boites.js
├── controleurs/
│   └── boites.js
├── services/
│   └── boites.js
├── modeles/
│   └── boite.js
├── regles/
│   ├── verifications.js petites fonctions de vérification réutilisables
│   └── boite.js         règles de validation d'une boite
└── intergiciels/
    ├── boites.js        verifierId, chargerBoite
    ├── validation.js    validerCorps
    ├── mesure.js        mesurerDuree
    └── erreurs.js       gererErreurs
Dans cette organisation, les contrôleurs passent par les services pour accéder aux données, et les services passent par les modèles. Les routes associent les traitements HTTP aux contrôleurs et aux intergiciels.

Nous adoptons ici une règle de dépendance : les contrôleurs ne consultent pas directement les modèles, et les services ne manipulent pas les objets HTTP req et res. Cette séparation permet de tester les responsabilités plus indépendamment.

Reprenons l’exemple des boîtes en répartissant le code dans les différents modules, en commençant par l’accès aux données.

Dans cet exemple, le modèle regroupe les opérations d’accès aux boîtes. Il utilise un tableau, mais les services n’ont pas besoin de connaître cette représentation : ils appellent les méthodes exposées par Boite.

modeles/boite.js
let boites = [];
let prochainId = 1;
export const Boite = {
  async lister() {
    return [...boites];
  },
  async trouverParId(id) {
    return boites.find((b) => b.id === Number(id)) ?? null;
  },
  async existeNom(nom) {
    return boites.some((b) => b.nom === nom);
  },
  async creer({ nom, capacite }) {
    const boite = { id: prochainId++, nom, capacite };
    boites.push(boite);
    return boite;
  },
  async supprimer(id) {
    const avant = boites.length;
    boites = boites.filter((b) => b.id !== Number(id));
    return boites.length < avant;
  },
  vider() {                       // utile pour les tests
    boites = [];
    prochainId = 1;
  },
};

Le tableau et le compteur ne sont pas exportés. Les autres modules ne peuvent donc pas accéder directement à ces variables : ils passent par les méthodes de Boite.

Le service applique ici une règle métier : deux boîtes ne peuvent pas avoir le même nom. Il ne reçoit ni req ni res. Lorsqu’un nom est déjà utilisé, il lève une erreur que l’application traduira ensuite en réponse HTTP.

services/boites.js
import { Boite } from "../modeles/boite.js";
export function listerBoites() {
  return Boite.lister();
}
export function trouverBoite(id) {
  return Boite.trouverParId(id);
}
export async function creerBoite({ nom, capacite }) {
  if (await Boite.existeNom(nom)) {
    const err = new Error("nom déjà utilisé");
    err.status = 409;
    throw err;
  }
  return Boite.creer({ nom, capacite });
}
export function supprimerBoite(id) {
  return Boite.supprimer(id);
}

En Express 5, une erreur levée dans un gestionnaire de route asynchrone, ou dans une promesse qu’il retourne, est automatiquement transmise au gestionnaire d’erreurs. Celui-ci utilise ici err.status pour produire une réponse HTTP 409.

La vérification de l’unicité du nom est désormais centralisée dans le service. On peut la tester sans démarrer le serveur. Avec une base de données, une contrainte d’unicité reste aussi souhaitable pour éviter les conflits entre requêtes simultanées.

Le contrôleur reçoit les informations de la requête, appelle les services nécessaires et construit la réponse HTTP. Ses fonctions restent volontairement courtes :

controleurs/boites.js
import * as service from "../services/boites.js";
export async function lister(req, res) {
  res.json(await service.listerBoites());
}
export async function creer(req, res) {
  const boite = await service.creerBoite(req.body);
  res.status(201).json(boite);
}
export function lire(req, res) {
  res.json(req.boite);          // ajoutée à req par chargerBoite
}
export async function supprimer(req, res) {
  await service.supprimerBoite(req.boite.id);
  res.status(204).end();
}

lire et supprimer ne vérifient pas si la boîte existe : quand ils s’exécutent, chargerBoite l’a déjà fait. La vérification écrite deux fois dans index.js n’est plus écrite qu’une fois.

Dans index.js, les conditions de validation étaient intégrées aux routes. Nous les séparons en deux étapes pour faciliter leur lecture et leur réutilisation.

D’abord, de petites fonctions de vérification, qui répondent par vrai ou faux à une question précise. Leur nom indique le résultat attendu, ce qui rend les conditions plus faciles à comprendre :

regles/verifications.js
export const estTexteNonVide = (v) => typeof v === "string" && v.trim() !== "";
export const estEntierPositif = (v) => Number.isInteger(v) && v > 0;
export const regle = (condition, message) => (v) => (condition(v) ? null : message);

regle est une fonction de fabrication (factory), comme validerCorps et exigerRole dans la fiche précédente : elle reçoit une condition et un message, et retourne une règle prête à l’emploi. Cette règle renvoie null si la valeur convient, et le message sinon.

Ensuite, les règles d’une boîte se lisent presque comme une phrase :

regles/boite.js
import { estTexteNonVide, estEntierPositif, regle } from "./verifications.js";
export const reglesBoite = {
  nom: regle(estTexteNonVide, "nom requis"),
  capacite: regle(estEntierPositif, "entier positif attendu"),
};

Ces fonctions ne dépendent ni d’Express, ni du modèle : ce sont des fonctions pures, qui reçoivent une valeur et retournent un résultat sans rien modifier autour d’elles. Les vérifications sont réutilisables pour n’importe quelle ressource, et la fiche sur les tests en fera ses tout premiers sujets.

Chaque route associe une méthode et un chemin HTTP à une suite de traitements : les intergiciels nécessaires, puis le contrôleur. Router permet de regrouper les routes d’une même ressource.

routes/boites.js
import { Router } from "express";
import * as controleur from "../controleurs/boites.js";
import { verifierId, chargerBoite } from "../intergiciels/boites.js";
import { validerCorps } from "../intergiciels/validation.js";
import { reglesBoite } from "../regles/boite.js";
const routes = Router();
routes.get("/", controleur.lister);
routes.post("/", validerCorps(reglesBoite), controleur.creer);
routes.get("/:id", verifierId, chargerBoite, controleur.lire);
routes.delete("/:id", verifierId, chargerBoite, controleur.supprimer);
export default routes;

Ces quatre déclarations résument les points d’accès de l’API. Les routes qui ciblent une boîte par son identifiant réutilisent les mêmes intergiciels, plutôt que de reproduire leur logique.

app.js
import express from "express";
import routesBoites from "./routes/boites.js";
import { mesurerDuree } from "./intergiciels/mesure.js";
import { gererErreurs } from "./intergiciels/erreurs.js";
export const app = express();
app.use(mesurerDuree);
app.use(express.json());
app.use("/boites", routesBoites);
app.use(gererErreurs);          // en dernier, comme tout middleware d'erreur
serveur.js
import { app } from "./app.js";
app.listen(3000, () => console.log("API sur le port 3000"));

Cette séparation permet d’importer l’application dans un test sans appeler app.listen(). Certains tests pourront donc exercer ses composants sans démarrer un serveur réseau.

VérificationUn collègue ajoute un appel à Boite.lister() directement dans un contrôleur, « pour aller plus vite ». Quelle règle est enfreinte, et qu'est-ce que cela change ?

La règle de dépendance : un contrôleur ne doit parler qu’au service, jamais au modèle. Concrètement, la requête échappe à toute règle du domaine que le service appliquerait, et le contrôleur dépend désormais du modèle : si le modèle change, le contrôleur devra peut-être changer aussi. Tester ce contrôleur demandera aussi de remplacer le modèle, et plus seulement une fonction du service.

Dans notre exemple, les intergiciels peuvent être regroupés selon leur portée :

1. Les intergiciels globaux s’appliquent à l’ensemble de l’application : mesurerDuree, express.json(), gererErreurs. Ils se montent dans app.js.

2. Les intergiciels propres à certaines routes réalisent des vérifications particulières : verifierId, chargerBoite, validerCorps(reglesBoite). Ils sont déclarés dans le fichier de routes correspondant.

Le middleware d’erreur, vu dans la fiche précédente, trouve ainsi sa place dans son propre fichier. C’est lui qui transforme en réponse l’erreur 409 levée par le service :

intergiciels/erreurs.js
export function gererErreurs(err, req, res, next) {
  console.error(err);
  const statut = err.status ?? 500;
  res.status(statut).json({ erreur: statut === 500 ? "erreur interne du serveur" : err.message });
}

Un gestionnaire d’erreurs Express doit conserver la signature à quatre paramètres (err, req, res, next), même si next n’est pas utilisé dans cet exemple.

Pour respecter la règle de dépendance, chargerBoite passe désormais par le service plutôt que d’interroger le modèle. Et verifierId peut réutiliser une des petites fonctions de vérification :

intergiciels/boites.js
import * as service from "../services/boites.js";
import { estEntierPositif } from "../regles/verifications.js";
export function verifierId(req, res, next) {
  if (!estEntierPositif(Number(req.params.id))) {
    return res.status(400).json({ erreur: "identifiant invalide" });
  }
  next();
}
export async function chargerBoite(req, res, next) {
  const boite = await service.trouverBoite(req.params.id);
  if (!boite) return res.status(404).json({ erreur: "boite introuvable" });
  req.boite = boite;
  next();
}

req.params.id est toujours une chaîne de caractères, même quand l’URL contient un nombre : d’où le Number(...) avant la vérification.

Le passage par le service tient en une ligne, mais il compte : la fiche sur les tests montrera qu’il rend ce middleware beaucoup plus simple à tester.

5. Remplacer le stockage en mémoire par une base de données

Section intitulée « 5. Remplacer le stockage en mémoire par une base de données »

Les données conservées en mémoire disparaissent lorsque le serveur redémarre. Voyons comment remplacer cette implémentation sans réécrire toute l’API.

Les services utilisent les méthodes lister, trouverParId, existeNom, creer et supprimer. Une nouvelle implémentation devra conserver ces opérations, mais aussi leur comportement : valeurs retournées, gestion des identifiants et traitement des erreurs. Les exemples suivants illustrent le principe ; ils supposent que la connexion à la base, le schéma et les dépendances nécessaires ont déjà été configurés.

Avec PostgreSQL et la bibliothèque pg, chaque méthode devient une requête SQL :

// modeles/boite.js (version PostgreSQL)
import { pool } from "../bd.js";
export const Boite = {
  async lister() {
    const { rows } = await pool.query("SELECT id, nom, capacite FROM boites ORDER BY id");
    return rows;
  },
  async trouverParId(id) {
    const { rows } = await pool.query("SELECT id, nom, capacite FROM boites WHERE id = $1", [id]);
    return rows[0] ?? null;
  },
  async existeNom(nom) {
    const { rows } = await pool.query("SELECT 1 FROM boites WHERE nom = $1", [nom]);
    return rows.length > 0;
  },
  async creer({ nom, capacite }) {
    const { rows } = await pool.query(
      "INSERT INTO boites (nom, capacite) VALUES ($1, $2) RETURNING id, nom, capacite",
      [nom, capacite],
    );
    return rows[0];
  },
  async supprimer(id) {
    const { rowCount } = await pool.query("DELETE FROM boites WHERE id = $1", [id]);
    return rowCount > 0;
  },
};

Les identifiants restent des entiers générés par la base : verifierId, qui vérifie un entier positif, n’a pas à changer.

Avec Mongoose, le modèle enveloppe un modèle Mongoose derrière la même interface :

// modeles/boite.js (version MongoDB)
import mongoose from "mongoose";
const schema = new mongoose.Schema({
  nom:      { type: String, required: true, unique: true },
  capacite: { type: Number, required: true, min: 1 },
});
const BoiteMongo = mongoose.model("Boite", schema);
export const Boite = {
  lister: () => BoiteMongo.find(),
  trouverParId: (id) => BoiteMongo.findById(id),
  existeNom: async (nom) => (await BoiteMongo.exists({ nom })) !== null,
  creer: (donnees) => BoiteMongo.create(donnees),
  supprimer: async (id) => (await BoiteMongo.findByIdAndDelete(id)) !== null,
};

Cette version MongoDB montre une limite importante : elle ne peut pas remplacer telle quelle le modèle en mémoire. MongoDB utilise par défaut des identifiants ObjectId, différents des entiers employés précédemment. Il faudrait adapter la vérification de req.params.id (par exemple avec mongoose.isValidObjectId) et s’assurer que les identifiants restent cohérents dans toute l’application.

De plus, les résultats Mongoose ne correspondent pas nécessairement au format { id, nom, capacite } attendu par l’API. Une couche de conversion dans le modèle peut préserver ce format public. Les opérations liées aux erreurs et les contraintes d’unicité doivent également être prises en compte.

Avec PostgreSQL, les services, les contrôleurs et les routes peuvent rester inchangés si le schéma de la base conserve des identifiants entiers et si le nouveau modèle retourne les mêmes données. Il faut néanmoins configurer la connexion et créer les tables nécessaires.

Avec MongoDB, le remplacement demande davantage de précautions : validation des identifiants, conversion éventuelle des documents et gestion des erreurs de base de données. On peut limiter les modifications aux modules d’accès aux données et aux vérifications concernées, mais ce résultat doit être conçu explicitement.

La séparation des responsabilités ne supprime pas toutes les modifications lors d’un changement de stockage. Elle permet surtout de les localiser et d’en réduire la portée.
VérificationOn remplace le modèle en mémoire par PostgreSQL, puis par MongoDB. Quelles adaptations faut-il envisager dans chaque cas ?

Avec PostgreSQL, si l’on conserve les identifiants entiers et les mêmes formats de résultats, les principaux changements concernent le modèle, la connexion et la création des tables. Avec MongoDB, il faut également traiter le format des identifiants, la validation des paramètres et la conversion éventuelle des documents retournés. L’objectif est de préserver autant que possible l’interface utilisée par les services.

Une API ainsi organisée distingue les responsabilités et facilite leur vérification : les fonctions de validation reçoivent des valeurs, les services manipulent les données du domaine et les intergiciels traitent les requêtes HTTP. La fiche suivante, Tester une API Express, montre comment écrire des tests sur ces composants.