De Node à Go : le même POST /register, côte à côte

작성자

카테고리:

← 피드로
DEV Community · Rodolphe D. · 2026-07-21 개발(SW)

La semaine dernière, on a nettoyé le register en Express. La logique métier est sortie du handler, direction sa propre fonction registerUser : un handler devenu minuscule, un traducteur HTTP↔métier, et une règle métier qu’on peut tester sans lancer de serveur. Si tu ne l’as pas lu, commence par là — tout ce qui suit s’appuie dessus.

Aujourd’hui, on change de langage, pas de sujet. Je prends ce même endpoint — inscription, validation, hash du mot de passe, écriture en base, email de bienvenue — et je le pose à côté de son équivalent en Go. Même geste, même découpage : la logique d’un côté, le handler HTTP de l’autre.

Pas de tour de magie, pas de framework caché derrière. Juste le code, côte à côte. Les contrastes viendront ensuite — aujourd’hui, on regarde.

1. La version Express, pour mémoire

Tu connais déjà ce code, il vient de la semaine dernière : la logique dans registerUser, un handler mince qui ne fait que traduire.

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 }

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

2. Le même endpoint, en Go

Voici le même endpoint, structuré pareil, en Go : la logique d’un côté, le handler de l’autre.

Une différence de couche à assumer tout de suite, pour ne pas te laisser croire à une magie : côté Express, la base passait par Prisma, un ORM. Côté Go, je montre du SQL direct avec database/sql — c’est le défaut idiomatique du langage, on y reviendra en détail Semaine 9.

RegisterUser est le miroir Go de registerUser : la logique métier, découplée, qui ne connaît pas net/http.

package main

import (
    "database/sql"
    "errors"
    "fmt"
    "log"
    "strings"

    "golang.org/x/crypto/bcrypt"
)

// Erreurs métier — l'équivalent Go de ValidationError / ConflictError.
// Elles ne mentionnent aucun code HTTP : la logique ne sait pas que HTTP existe.
var (
    ErrValidation = errors.New("données invalides")
    ErrConflict   = errors.New("cet email est déjà utilisé")
)

// RegisterInput décrit, explicitement, la forme des données attendues.
type RegisterInput struct {
    Email    string
    Password string
}

// RegisteredUser décrit ce que la fonction renvoie.
type RegisteredUser struct {
    ID    string
    Email string
}

// RegisterUser porte la logique métier. Elle ne connaît ni *http.Request ni
// http.ResponseWriter : on pourrait l'appeler depuis un script, un test, un job.
func RegisterUser(db *sql.DB, in RegisterInput) (RegisteredUser, error) {
    if !strings.Contains(in.Email, "@") {
        return RegisteredUser{}, fmt.Errorf("%w: email invalide", ErrValidation)
    }
    if len(in.Password) < 8 {
        return RegisteredUser{}, fmt.Errorf("%w: mot de passe trop court (8 caractères min)", ErrValidation)
    }

    var exists bool
    if err := db.QueryRow(
        "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", in.Email,
    ).Scan(&exists); err != nil {
        return RegisteredUser{}, err
    }
    if exists {
        return RegisteredUser{}, ErrConflict
    }

    hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 12)
    if err != nil {
        return RegisteredUser{}, err
    }

    var id string
    if err := db.QueryRow(
        "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id",
        in.Email, string(hash),
    ).Scan(&id); err != nil {
        return RegisteredUser{}, err
    }

    if err := sendWelcomeEmail(in.Email); err != nil {
        // L'email ne doit pas faire échouer l'inscription.
        log.Printf("envoi email échoué: %v", err)
    }

    return RegisteredUser{ID: id, Email: in.Email}, nil
}

Enter fullscreen mode Exit fullscreen mode

Si tu n’as jamais lu de Go, quatre repères suffisent pour ne pas te perdre dans ce fichier :

  • Une fonction peut renvoyer plusieurs valeurs. Regarde la signature : func RegisterUser(db *sql.DB, in RegisterInput) (RegisteredUser, error). Elle rend deux choses d’un coup — le résultat et une erreur. C’est la convention Go : on renvoie souvent (résultat, erreur), et l’appelant vérifie l’erreur juste après. Rien de plus à comprendre ici, on y revient en détail Semaine 4.
  • nil, c’est l’absence de valeur. L’équivalent mental du null que tu connais. Un db à nil, c’est « pas de connexion ».
  • := déclare et assigne en même temps (hash, err := ...), là où var déclare seul (var id string). Deux façons d’introduire une variable, selon que tu as déjà une valeur à mettre dedans ou non.
  • La majuscule initiale décide de la visibilité. RegisterUser commence par une majuscule : elle est exportée, visible depuis l’extérieur du package. sendWelcomeEmail, minuscule, reste privée au package. Le langage lit la casse comme un mot-clé de portée.

Avec ces quatre repères, le reste se lit comme du pseudo-code.

Le handler, ensuite — mince, qui ne fait que relayer HTTP vers la logique métier, exactement comme côté Express.

package main

import (
    "database/sql"
    "encoding/json"
    "errors"
    "net/http"
)

// registerHandler est mince : décoder, appeler RegisterUser, traduire en HTTP.
// C'est un traducteur HTTP↔métier, pas un endroit où vit la logique.
func registerHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var in RegisterInput
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "corps de requête invalide", http.StatusBadRequest)
            return
        }

        user, err := RegisterUser(db, in)
        switch {
        case err == nil:
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusCreated)
            json.NewEncoder(w).Encode(user)
        case errors.Is(err, ErrValidation):
            http.Error(w, err.Error(), http.StatusBadRequest)
        case errors.Is(err, ErrConflict):
            http.Error(w, err.Error(), http.StatusConflict)
        default:
            http.Error(w, "erreur serveur", http.StatusInternalServerError)
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Et le câblage : un http.ServeMux de la bibliothèque standard, mux.HandleFunc("POST /register", ...). Pas de routeur tiers, pas de framework — juste la stdlib.

package main

import (
    "database/sql"
    "log"
    "net/http"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
    db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/register")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    mux := http.NewServeMux()
    mux.HandleFunc("POST /register", registerHandler(db))

    log.Println("écoute sur :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Enter fullscreen mode Exit fullscreen mode

Un détail au passage : sendWelcomeEmail est un stub ici, comme l’était mailer.js en Semaine 1 — pas la peine de s’y attarder.

3. Ce qui saute aux yeux

Maintenant qu’on a les deux versions côte à côte, quelques contrastes ressortent tout de suite. Pas besoin de creuser, ils sont en surface.

D’abord, les types. En Go, RegisterInput et RegisteredUser sont déclarés, noir sur blanc, en haut du fichier. La forme des données attendues n’est pas une convention tacite ni un commentaire — c’est écrit, et le compilateur la fait respecter. Côté Express, req.body est un objet dynamique : sa forme existe dans ta tête, dans un commentaire peut-être, mais nulle part le langage ne la vérifie pour toi. Voilà ce que j’aime là-dedans : je n’ai pas besoin de deviner ce qu’attend RegisterUser, la signature me le dit.

Ensuite, les erreurs : if err != nil partout, une erreur qui se renvoie au lieu de se lancer. Ça me plaît de voir chaque échec possible écrit là, dans le flux — mais c’est un gros morceau, on y consacrera la Semaine 4 en entier.

Autre chose qui manque : un framework. Pas d’Express, pas d’équivalent. net/http vient de la bibliothèque standard, et le routing POST /register que tu as vu dans main.go est natif depuis les dernières versions de Go — zéro dépendance HTTP à installer. Ça change la nature du projet : le routeur n’est l’affaire de personne d’autre que la stdlib. Ça a un revers que j’apprécie : pas de version de framework à traquer, pas de breaking change à anticiper à chaque mise à jour.

Dernier point, le SQL direct avec database/sql : les requêtes sont écrites en toutes lettres dans RegisterUser, pas cachées derrière un ORM. J’avoue aimer voir exactement ce qui part vers la base, sans couche à deviner — mais c’est un autre gros sujet, Semaine 9 : SQL sans se tirer une balle dans le pied.

4. La preuve que ça tourne

Tu te souviens du test de la Semaine 1 : peux-tu tester cette règle sans lancer un serveur HTTP ? Côté Express, la réponse avait fini par être oui, une fois la logique sortie du handler. Voyons ce que ça donne côté Go.

package main

import (
    "errors"
    "testing"
)

// La validation lève avant tout accès à la base : pas besoin de DB ni de serveur.
func TestRegisterUser_RefuseMotDePasseTropCourt(t *testing.T) {
    _, err := RegisterUser(nil, RegisterInput{Email: "[email protected]", Password: "123"})
    if !errors.Is(err, ErrValidation) {
        t.Fatalf("attendu ErrValidation, obtenu: %v", err)
    }
}

Enter fullscreen mode Exit fullscreen mode

Regarde bien ce qui ne se passe pas. go test, et c’est tout : aucun serveur qui démarre, aucune base de données à monter. Le premier argument passé à RegisterUser est carrément nil — pas de connexion, pas de mock, rien. Et ça fonctionne, parce que la validation de l’email et du mot de passe lève avant même que le code touche db. La règle métier est vérifiée en quelques millisecondes, et elle ne peut échouer que pour une seule raison : elle-même.

C’est la même démonstration qu’en Semaine 1, avec node:test sur registerUser. Changement de langage, même geste : découpler la logique du framework, et le test devient trivial à écrire.

5. Pour la suite

Un fil est resté volontairement tendu dans cet article : if err != nil, partout, à chaque appel qui peut échouer. Plaisir ou corvée ? C’est tout le sujet du prochain plongeon,

Si je devais résumer la sensation de cette comparaison en une phrase : côté Go, tout est écrit, rien n’est caché. Les types, les erreurs, les requêtes SQL, le routing — tout est là, sous tes yeux, dans le fichier. Au début, ça déroute un peu, il y a plus de lignes à lire. Après un moment, je trouve ça reposant : je n’ai jamais à me demander ce qui se passe derrière un décorateur ou un ORM, la réponse est toujours dans le fichier ouvert devant moi.

La série continue.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다