Je t'avais promis cet article il y a un moment.
Dans un article précédent, je racontais un projet Node que je détestais rouvrir. À la fin, j'avais annoncé la suite : pourquoi les routes Express finissent toujours par devenir illisibles. Et puis plus rien. Silence radio pendant des semaines.
Pas d'excuse compliquée. J'ai laissé traîner, comme on laisse traîner un refactor qu'on sait nécessaire mais jamais urgent. Sauf que le sujet n'a pas arrêté de me trotter dans la tête. Alors le voilà, enfin.
Promesse simple : à la fin de cet article, tu sauras pourquoi ton fichier de routes se transforme en cauchemar. Et tu repartiras avec un geste concret pour arrêter l'hémorragie, pas juste un constat déprimant.
1. Ce fichier que tu n'oses plus ouvrir
Tu vois de quel fichier je parle.
Celui que tu as écrit il y a six mois, un vendredi après-midi, pour livrer la fonctionnalité à temps. À l'époque il faisait vingt lignes. Aujourd'hui il en fait deux cents, et personne ne sait vraiment comment.
Chaque nouvelle règle métier s'est ajoutée au même endroit. Une vérification ici. Un appel à la base de données là. Un if de plus, imbriqué dans un if précédent. Puis un email à envoyer, parce que le produit l'a demandé un mardi et qu'il fallait livrer le mercredi.
Maintenant, quand il faut y toucher, tu hésites. Tu relis trois fois avant de changer une ligne. Tu te demandes ce qui va casser ailleurs, dans une route que tu ne regardes jamais.
Alors tu copies-colles la logique dans un nouveau fichier plutôt que de réutiliser celle qui existe déjà. Tu te dis que tu nettoieras plus tard. Le "plus tard" n'arrive jamais.
Si cette scène te parle, tu n'es pas seul. Et ce n'est pas parce que tu écris du mauvais code.
2. Le vrai coupable (spoiler : ce n'est pas Express)
La réaction naturelle, c'est d'accuser Express. Ou la taille du fichier. "Je devrais juste le découper en plus petits morceaux."
Ça ne suffit pas. Découper un mauvais fichier en trois mauvais fichiers ne règle rien : ça déplace juste le problème, avec un import en plus.
Le vrai coupable, c'est ailleurs. C'est que ta logique métier — les règles qui font que ton produit est ton produit — est collée au framework. Elle vit à l'intérieur du handler, mélangée à req et res du début à la fin.
Résultat : impossible de séparer "ce que fait mon application" de "comment Express reçoit une requête HTTP".
Voici le test qui révèle le problème en une seconde. Prends une règle métier de ta route — "le mot de passe doit faire au moins 8 caractères", par exemple. Pose-toi cette question :
Peux-tu tester cette règle sans lancer un serveur HTTP ?
Si la réponse est non — s'il faut monter un serveur, simuler une requête, lire une réponse JSON juste pour vérifier une règle de validation — alors voilà le problème. Ta logique et ton framework sont soudés ensemble. Et tout ce qui est soudé finit par devenir impossible à faire évoluer sans tout casser.
3. Le code, tel qu'il est aujourd'hui
Regarde cette route d'inscription. Validation, hash du mot de passe, écriture en base, envoi d'un email : tout est là, dans le même bloc, req et res mêlés à chaque étape.
const express = require('express')
const bcrypt = require('bcrypt')
const { PrismaClient } = require('@prisma/client')
const { sendWelcomeEmail } = require('./mailer')
const app = express()
app.use(express.json())
const prisma = new PrismaClient()
app.post('/register', async (req, res) => {
const { email, password } = req.body
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Email invalide' })
}
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Mot de passe trop court (8 caractères min)' })
}
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
return res.status(409).json({ error: 'Cet email est déjà utilisé' })
}
const passwordHash = await bcrypt.hash(password, 12)
const user = await prisma.user.create({ data: { email, passwordHash } })
try {
await sendWelcomeEmail(user.email)
} catch (err) {
console.error('Envoi email échoué', err)
}
return res.status(201).json({ id: user.id, email: user.email })
})
module.exports = app
Rien de honteux là-dedans. C'est même du code qui marche, qui a probablement tourné en prod sans souci depuis six mois. Mais essaie d'appliquer le test de la section précédente à la règle "le mot de passe doit faire au moins 8 caractères". Pour la tester, il faut faire tourner tout ce fichier — Express, Prisma, le mailer compris.
4. Le geste : sortir la logique du handler
Voilà le seul geste dont tu as besoin. Pas un pattern à trois lettres, pas une nouvelle couche d'abstraction. Une fonction.
Tu prends toute la logique métier — validation, vérification en base, hash, création, email — et tu la sors dans sa propre fonction, registerUser(data). Cette fonction ne connaît ni req ni res. Elle reçoit des données simples (email, password), elle renvoie un résultat simple, ou elle lève une erreur.
Attention à un malentendu classique : registerUser n'est pas devenue une fonction magiquement isolée du monde. Elle continue de taper la base de données, de hasher un mot de passe, d'envoyer un email. Ses effets de bord n'ont pas disparu, ils sont toujours là. Ce qui a changé, c'est autre chose : elle ne dépend plus d'Express. Tu pourrais l'appeler depuis un script, un test, une tâche planifiée, une autre route — elle s'en fiche. C'est ça, le gain : l'indépendance vis-à-vis du framework, pas une histoire de pureté.
const bcrypt = require('bcrypt')
const { PrismaClient } = require('@prisma/client')
const { sendWelcomeEmail } = require('./mailer')
const prisma = new PrismaClient()
class ValidationError extends Error {}
class ConflictError extends Error {}
async function registerUser({ email, password }) {
if (!email || !email.includes('@')) {
throw new ValidationError('Email invalide')
}
if (!password || password.length < 8) {
throw new ValidationError('Mot de passe trop court (8 caractères min)')
}
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
throw new ConflictError('Cet email est déjà utilisé')
}
const passwordHash = await bcrypt.hash(password, 12)
const user = await prisma.user.create({ data: { email, passwordHash } })
try {
await sendWelcomeEmail(user.email)
} catch (err) {
console.error('Envoi email échoué', err)
}
return { id: user.id, email: user.email }
}
module.exports = { registerUser, ValidationError, ConflictError }
Remarque les erreurs typées, ValidationError et ConflictError. C'est ce qui permet à la logique métier de dire "ceci est invalide" sans jamais mentionner un code HTTP. Elle ne sait même pas que HTTP existe.
Et le handler, dans tout ça ? Il devient minuscule. Son seul travail : traduire une requête HTTP en appel à registerUser, puis traduire le résultat (ou l'erreur) en réponse HTTP. Rien de plus. C'est un traducteur HTTP↔métier, pas un endroit où vit la logique.
const express = require('express')
const { registerUser, ValidationError, ConflictError } = require('./registerUser')
const app = express()
app.use(express.json())
app.post('/register', async (req, res, next) => {
try {
const user = await registerUser(req.body)
return res.status(201).json(user)
} catch (err) {
if (err instanceof ValidationError) return res.status(400).json({ error: err.message })
if (err instanceof ConflictError) return res.status(409).json({ error: err.message })
return next(err)
}
})
module.exports = app
Une poignée de lignes. Tu le lis en entier sans effort, et tu sais exactement ce qu'il fait : il reçoit, il délègue, il répond.
5. Pourquoi c'est mieux
Ce n'est pas juste plus joli à regarder. Trois choses changent concrètement.
D'abord, tu peux tester la règle métier sans lancer un serveur. Plus besoin de monter Express, de simuler une requête HTTP, de parser une réponse JSON pour vérifier qu'un mot de passe trop court est rejeté. Tu appelles registerUser directement, avec un objet, et tu regardes ce qu'elle renvoie ou ce qu'elle lève.
const { test } = require('node:test')
const assert = require('node:assert')
const { registerUser, ValidationError } = require('./registerUser')
test('refuse un mot de passe trop court — sans lancer Express ni DB', async () => {
await assert.rejects(
() => registerUser({ email: 'ada@example.com', password: '123' }),
ValidationError,
)
})
Regarde bien ce test. Aucun app.listen, aucun supertest, aucun port à ouvrir. Je teste la règle métier sans lancer de serveur. Le test s'exécute en quelques millisecondes, et il ne peut échouer que pour une seule raison : la règle elle-même est cassée. Pas parce que le port 3000 était pris, pas parce qu'une requête a mis trop de temps à répondre.
Ensuite, cette fonction devient réutilisable ailleurs que dans une route HTTP. Un script d'import en masse depuis un CSV, une tâche planifiée qui crée des comptes de test, une deuxième route qui inscrit via une invitation — tout ça peut appeler registerUser directement, sans repasser par Express, sans fabriquer une fausse requête HTTP pour appeler du code qui n'a jamais eu besoin de HTTP.
Enfin — et c'est peut-être le bénéfice le plus sous-estimé — ton handler redevient lisible d'un coup d'œil. Une poignée de lignes. Tu sais ce qu'il fait sans avoir à dérouler deux cents lignes de if imbriqués. Le jour où quelqu'un d'autre (ou toi, dans six mois) doit comprendre "que fait cette route", la réponse tient dans le temps de lire le fichier.
Je t'entends déjà venir avec l'objection : "pourquoi pas juste couper en middlewares plus petits ?" Un middleware pour la validation, un pour la vérification en base, un pour l'envoi d'email. Ça se défend, en apparence.
Sauf que ça ne règle rien. Chaque middleware continue de recevoir req et res. La logique métier reste mélangée au framework, juste répartie sur plusieurs fichiers au lieu d'un seul. Tu as déplacé le problème, tu ne l'as pas supprimé. Le test de la section 2 — "peux-tu tester cette règle sans lancer un serveur HTTP ?" — reste tout aussi négatif qu'avant.
6. La suite, et ce qu'il faut retenir
Une dernière remarque, pour ceux qui viennent d'un autre langage ou qui y passeront bientôt : en Node, ce geste reste une discipline à choisir toi-même, rien ne t'y oblige ; en Go, cette même séparation devient presque un réflexe, que la structure du langage encourage.
La série continue.
Si tu ne retiens qu'une phrase de cet article, que ce soit celle-là : la logique métier ne doit pas connaître req/res.
Top comments (0)