DEV Community

Rodolphe D.
Rodolphe D.

Posted on

Handler / service / repository expliqué à un dev Express

En Semaine 1, on a sorti la logique du handler. Un geste, une fonction : registerUser. Ça a tout changé — jusqu'au jour où cette fonction devient le nouveau monolithe.

Aujourd'hui, on coupe ce monolithe en trois rôles que tu as déjà croisés sous d'autres noms : handler, service, repository. Pas pour coller un diagramme sur le frigo. Pour savoir mettre la prochaine règle métier quand le fichier grossit — sans retomber dans le routes.js de 400 lignes.

Promesse : à la fin, tu sauras qui fait quoi, tu auras le découpage sur le register que tu connais, et tu sauras aussi quand ne pas le faire.

1. Le problème après le premier geste

Voici où on en était. La logique est hors du handler — bien. Mais elle fait encore tout :

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 }
}
Enter fullscreen mode Exit fullscreen mode

Trois natures de travail dans la même fonction :

  1. Règles métier — « email valide », « mot de passe ≥ 8 », « email unique ».
  2. PersistancefindUnique, create.
  3. Effet de bord — l'email de bienvenue.

Tant que c'est court, ça va. Le jour où tu ajoutes « vérifier un code d'invitation », « écrire dans une table d'audit », « choisir le provider d'email selon l'env », cette fonction devient le fichier que tu n'oses plus ouvrir — juste un cran plus bas dans la pile.

Le premier geste (sortir du handler) reste nécessaire. Il n'est plus suffisant.

2. Les trois rôles, en français de café

Oublie un instant les buzzwords. Pose-toi trois questions sur chaque bout de code :

Question Rôle En Express, ça ressemble à…
« Comment je parle HTTP ? » Handler la route, le controller
« Qu'est-ce que mon produit autorise / décide ? » Service la logique métier, parfois appelée use case
« Comment je lis / écris les données ? » Repository l'accès Prisma / SQL / ORM
  • Le handler décode req, appelle le service, traduit résultat/erreur en status HTTP. Il ne connaît pas Prisma. Il ne hash pas de mot de passe.
  • Le service applique les règles. Il dit « cet email est déjà pris ». Il ne sait pas si ça vient de Postgres ou d'un mock en mémoire. Il ne connaît pas res.status.
  • Le repository parle à la base. findByEmail, create. Pas de règle « mot de passe trop court » ici — ce n'est pas son job.

Le test mental, le même qu'en Semaine 1, se décline :

  • Peux-tu tester une règle métier sans lancer Express ? → service isolé.
  • Peux-tu changer de base (ou mocker) sans réécrire les règles ? → repository derrière une interface claire.
  • Peux-tu lire la route en 10 secondes ? → handler mince.

3. Le même register, découpé

Repository — la base, rien d'autre

// userRepository.js
class UserRepository {
  constructor(prisma) {
    this.prisma = prisma
  }

  findByEmail(email) {
    return this.prisma.user.findUnique({ where: { email } })
  }

  create({ email, passwordHash }) {
    return this.prisma.user.create({
      data: { email, passwordHash },
    })
  }
}

module.exports = { UserRepository }
Enter fullscreen mode Exit fullscreen mode

Pas de validation. Pas de bcrypt. Des questions à la base, des réponses.

Service — les règles du produit

// registerService.js
const bcrypt = require('bcrypt')
const { sendWelcomeEmail } = require('./mailer')

class ValidationError extends Error {}
class ConflictError extends Error {}

class RegisterService {
  constructor(users) {
    this.users = users // UserRepository
  }

  async register({ 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 this.users.findByEmail(email)
    if (existing) {
      throw new ConflictError('Cet email est déjà utilisé')
    }

    const passwordHash = await bcrypt.hash(password, 12)
    const user = await this.users.create({ email, passwordHash })

    try {
      await sendWelcomeEmail(user.email)
    } catch (err) {
      console.error('Envoi email échoué', err)
    }

    return { id: user.id, email: user.email }
  }
}

module.exports = { RegisterService, ValidationError, ConflictError }
Enter fullscreen mode Exit fullscreen mode

Le service orchestre. Il décide. Il délègue le stockage au repository. Toujours zéro req / res.

Handler — le traducteur HTTP

// handler.js
const express = require('express')
const { PrismaClient } = require('@prisma/client')
const { UserRepository } = require('./userRepository')
const {
  RegisterService,
  ValidationError,
  ConflictError,
} = require('./registerService')

const prisma = new PrismaClient()
const users = new UserRepository(prisma)
const registerService = new RegisterService(users)

const app = express()
app.use(express.json())

app.post('/register', async (req, res, next) => {
  try {
    const user = await registerService.register(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
Enter fullscreen mode Exit fullscreen mode

Même job qu'en Semaine 1 — mais il appelle un service, pas une fonction fourre-tout. Le câblage (prisma → repo → service) se fait une fois, au démarrage. Les routes ne créent plus leurs dépendances dans le handler.

4. Ce que ça change concrètement

Tester sans HTTP ni vraie DB. Tu injectes un faux repository :

const { test } = require('node:test')
const assert = require('node:assert')
const { RegisterService, ValidationError } = require('./registerService')

test('refuse un mot de passe trop court', async () => {
  const fakeUsers = {
    findByEmail: async () => null,
    create: async () => {
      throw new Error('ne doit pas être appelé')
    },
  }
  const service = new RegisterService(fakeUsers)

  await assert.rejects(
    () => service.register({ email: 'ada@example.com', password: '123' }),
    ValidationError,
  )
})
Enter fullscreen mode Exit fullscreen mode

La règle métier est vérifiée. Aucun Express. Aucun Postgres. Le fake ne fait que ce que le service a le droit d'attendre du repository.

Changer d'outil sans réécrire le métier. Demain tu remplaces Prisma par pg brut, ou tu ajoutes un cache devant findByEmail : tu touches le repository. Le service et le handler restent.

Lire le projet par responsabilité. « Où est la règle du mot de passe ? » → service. « Où est la requête SQL / Prisma ? » → repository. « Où est le 409 ? » → handler. Tu ne fouilles plus un seul fichier de 300 lignes pour les trois réponses.

5. Et en Go, c'est la même carte

Si tu as suivi la Semaine 2 et la Semaine 4, tu as déjà vu le handler mince et les erreurs-valeurs. Le découpage se lit pareil :

Express Go (idée)
RegisterService.register RegisterService.Register (méthode sur un struct)
UserRepository.findByEmail UserRepository.FindByEmail
injection via constructor injection via champs du struct + câblage dans main
instanceof ValidationError errors.Is(err, ErrValidation)

Même carte mentale. Autre syntaxe. La semaine prochaine, on pose exactement ce squelette en Go — pas en théorie, en arborescence de projet.

6. Quand ne pas faire ça

Trois couches pour un script de 40 lignes, c'est du théâtre.

Règle simple que j'utilise :

  1. Une route, logique courte → fonction + handler mince (Semaine 1). Stop.
  2. La fonction métier mélange règles et SQL, et ça commence à faire mal → introduis service / repository.
  3. Tu ajoutes une deuxième façon d'entrer (job cron, CLI, autre transport) → le service devient obligatoire ; le handler HTTP n'est plus le seul client.

Ce n'est pas « clean architecture ou rien ». C'est « un cran de découpe quand la douleur apparaît », pas avant. Les projets juniors que je vois partent trop souvent du diagramme à six couches et n'écrivent jamais la première règle métier.

7. Ce qu'il faut retenir

Handler = HTTP. Service = décisions du produit. Repository = données. Le premier geste (sortir du handler) reste la fondation ; ces trois rôles sont ce que tu ajoutes quand registerUser devient le nouveau fichier qu'on évite.

Si tu ne retiens qu'une phrase : chaque couche a une seule question à laquelle répondre — et elle ne répond pas aux deux autres.

La semaine prochaine : on pose le squelette du fil rouge Go — ton premier vrai backend, structuré handler / service / repository, prêt à grandir (parties 2 et 3 plus tard).

La série continue.

Top comments (0)