DEV Community

Rodolphe D.
Rodolphe D.

Posted on

Ton premier vrai backend Go : fil rouge part 1 — le squelette

Jusqu'ici, le Go tenait dans un package main : un fichier pour la logique, un pour le handler, un main qui câble. Parfait pour comparer Express et Go. Insuffisant pour un backend que tu vas faire grandir.

La semaine dernière — Handler / service / repository expliqué à un dev Express — on a nommé les trois rôles côté Express. Aujourd'hui, partie 1 du fil rouge : on pose le squelette Go de ce découpage. Pas toute l'API. Pas la validation exhaustive. Un projet qui démarre, avec POST /register et une place claire pour la suite (login, /me, de meilleures erreurs).

Promesse : à la fin, tu as une arborescence que tu comprends, un go run qui écoute, et une carte mentale pour savoir où coller le prochain fichier.

Les parties 2 et 3 arriveront plus tard : erreurs & validation (S8), puis structurer quand ça grossit (S10). Aujourd'hui = fondations seulement.

1. Ce qu'on construit (et ce qu'on laisse pour plus tard)

Dans cette partie :

  • layout cmd/ + internal/
  • couches handler → service → repository
  • POST /register (validation minimale, comme avant)
  • GET /health (sanity check)
  • câblage dans main, stdlib net/http, SQL via database/sql

Pas encore :

  • auth / JWT / sessions
  • POST /login, GET /me
  • wrapping d'erreurs sophistiqué, validation dédiée
  • migrations, Docker, CI

Un squelette, pas un produit fini. Si tu sens l'envie d'ajouter Gin, GORM et un dossier utils/, respire : on reste volontairement simple.

2. L'arborescence

filrouge/
├── go.mod
├── cmd/
│   └── api/
│       └── main.go          # câblage + ListenAndServe
└── internal/
    ├── httpapi/
    │   ├── handler.go       # HTTP ↔ service
    │   └── respond.go       # petits helpers JSON / erreurs HTTP
    ├── user/
    │   ├── service.go       # règles métier
    │   └── repository.go    # SQL
    └── mailer/
        └── mailer.go        # stub email (comme depuis la S1)
Enter fullscreen mode Exit fullscreen mode

Pourquoi comme ça :

  • cmd/api — le binaire. Un seul main. Plus tard tu pourras avoir cmd/migrate sans mélanger.
  • internal/ — code privé à ce module. Go refuse qu'un autre module l'importe. C'est le « ne touchez pas depuis l'extérieur » du langage.
  • user/ — le domaine « utilisateur » (service + repo). Pas un fourre-tout services/ global dès le jour 1.
  • httpapi/ — tout ce qui parle HTTP. Le domaine ne connaît pas ResponseWriter.

Si tu viens d'Express, l'équivalent mental :

Express (après S5) Go (fil rouge)
handler.js / routes internal/httpapi
registerService.js internal/user/service.go
userRepository.js internal/user/repository.go
server.js / app.listen cmd/api/main.go

3. Le repository — SQL, point

// internal/user/repository.go
package user

import (
    "context"
    "database/sql"
)

type User struct {
    ID           string
    Email        string
    PasswordHash string
}

type Repository struct {
    db *sql.DB
}

func NewRepository(db *sql.DB) *Repository {
    return &Repository{db: db}
}

func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, email, password_hash FROM users WHERE email = $1`, email,
    ).Scan(&u.ID, &u.Email, &u.PasswordHash)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return &u, nil
}

func (r *Repository) Create(ctx context.Context, email, passwordHash string) (User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `INSERT INTO users (email, password_hash) VALUES ($1, $2)
         RETURNING id, email, password_hash`,
        email, passwordHash,
    ).Scan(&u.ID, &u.Email, &u.PasswordHash)
    return u, err
}
Enter fullscreen mode Exit fullscreen mode

Remarque : FindByEmail renvoie (nil, nil) si l'utilisateur n'existe pas. « Pas trouvé » n'est pas une erreur infra — c'est une information pour le service. Les vraies erreurs SQL remontent telles quelles.

Le context.Context en premier argument : idiome Go. Ça permettra d'annuler une requête si le client HTTP raccroche. On ne creuse pas plus aujourd'hui ; on le met parce que c'est la signature attendue.

4. Le service — les décisions

// internal/user/service.go
package user

import (
    "context"
    "errors"
    "fmt"
    "log"
    "strings"

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

var (
    ErrValidation = errors.New("données invalides")
    ErrConflict   = errors.New("cet email est déjà utilisé")
)

type RegisterInput struct {
    Email    string
    Password string
}

type RegisteredUser struct {
    ID    string `json:"id"`
    Email string `json:"email"`
}

// Mailer : assez petit pour rester une interface ici.
type Mailer interface {
    SendWelcome(email string) error
}

type Service struct {
    repo   *Repository
    mailer Mailer
}

func NewService(repo *Repository, mailer Mailer) *Service {
    return &Service{repo: repo, mailer: mailer}
}

func (s *Service) Register(ctx context.Context, 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)
    }

    existing, err := s.repo.FindByEmail(ctx, in.Email)
    if err != nil {
        return RegisteredUser{}, err
    }
    if existing != nil {
        return RegisteredUser{}, ErrConflict
    }

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

    u, err := s.repo.Create(ctx, in.Email, string(hash))
    if err != nil {
        return RegisteredUser{}, err
    }

    if err := s.mailer.SendWelcome(u.Email); err != nil {
        log.Printf("envoi email échoué: %v", err)
    }

    return RegisteredUser{ID: u.ID, Email: u.Email}, nil
}
Enter fullscreen mode Exit fullscreen mode

Même règles qu'avant. Même choix « l'email ne fait pas échouer l'inscription ». Mais plus de *sql.DB dans la signature publique du métier : le service parle au repository. Et le mailer est une interface — en test, tu branches un faux en deux lignes.

5. Le handler — HTTP only

// internal/httpapi/handler.go
package httpapi

import (
    "encoding/json"
    "errors"
    "net/http"

    "filrouge/internal/user"
)

type Handler struct {
    users *user.Service
}

func NewHandler(users *user.Service) *Handler {
    return &Handler{users: users}
}

func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
    var in user.RegisterInput
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeError(w, http.StatusBadRequest, "corps de requête invalide")
        return
    }

    out, err := h.users.Register(r.Context(), in)
    switch {
    case err == nil:
        writeJSON(w, http.StatusCreated, out)
    case errors.Is(err, user.ErrValidation):
        writeError(w, http.StatusBadRequest, err.Error())
    case errors.Is(err, user.ErrConflict):
        writeError(w, http.StatusConflict, err.Error())
    default:
        writeError(w, http.StatusInternalServerError, "erreur serveur")
    }
}

func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
Enter fullscreen mode Exit fullscreen mode
// internal/httpapi/respond.go
package httpapi

import (
    "encoding/json"
    "net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}
Enter fullscreen mode Exit fullscreen mode

Le handler ne sait pas hasher. Il ne sait pas parler SQL. Il traduit — exactement le rôle de la Semaine 5, en Go, avec les errors.Is de la Semaine 4.

6. Le câblage — main lit comme une recette

// cmd/api/main.go
package main

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

    "filrouge/internal/httpapi"
    "filrouge/internal/mailer"
    "filrouge/internal/user"

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

func main() {
    dsn := envOr("DATABASE_URL", "postgres://user:pass@localhost:5432/filrouge")

    db, err := sql.Open("pgx", dsn)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    repo := user.NewRepository(db)
    svc := user.NewService(repo, mailer.Stub{})
    h := httpapi.NewHandler(svc)

    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", h.Health)
    mux.HandleFunc("POST /register", h.Register)
    // Plus tard : POST /login, GET /me — même mux, nouveaux handlers.

    addr := envOr("ADDR", ":8080")
    log.Printf("écoute sur %s", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
}

func envOr(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}
Enter fullscreen mode Exit fullscreen mode
// internal/mailer/mailer.go
package mailer

type Stub struct{}

func (Stub) SendWelcome(email string) error {
    return nil // volontairement muet pour le squelette
}
Enter fullscreen mode Exit fullscreen mode

Lis main de haut en bas : ouvrir la DB → repo → service → handler → routes → listen. Aucune magie de framework. Si tu te perds dans six mois, tu rouvres ce fichier et tu retrouves le graphe de dépendances.

Initialiser le module :

mkdir -p filrouge && cd filrouge
go mod init filrouge
# coller les fichiers ci-dessus, puis :
go get github.com/jackc/pgx/v5/stdlib
go get golang.org/x/crypto/bcrypt
go run ./cmd/api
Enter fullscreen mode Exit fullscreen mode

GET /health doit répondre {"status":"ok"}. POST /register attend une table users (id, email, password_hash) — même schéma minimal qu'en Semaine 2.

7. Où tu branches la suite (sans la coder aujourd'hui)

Garde cette carte sous la main :

Besoin Où ça vit Quelle partie du fil rouge
Meilleure validation, erreurs riches user/service.go + helpers d'erreur Partie 2 (S8)
POST /login, hash verify, token user/service.go + httpapi Partie 2 / suite
GET /me protégé middleware dans httpapi plus tard
Trop de fichiers dans user/ découper / packages — sans paniquer Partie 3 (S10)

Le squelette est fait pour recevoir ces ajouts. Tu n'auras pas à tout jeter pour « enfin structurer ».

8. Ce qu'il faut retenir

Un backend Go « réel » ne commence pas par un framework. Il commence par : un main qui câble, un handler qui traduit HTTP, un service qui décide, un repository qui parle SQL. Le reste — auth, validation poussée, découpage fin — s'accroche sur cette ossature.

Si tu ne retiens qu'une phrase : le squelette, c'est le graphe de dépendances rendu évident dans main et dans les dossiers — pas le nombre de bibliothèques installées.

Prochaine station du fil rouge (S8) : on durcit erreurs et validation, Node vs Go, sur ce projet. Entre-temps, si tu veux juste relire les couches côté Express : l'article handler / service / repository est le mode d'emploi.

La série continue.

Top comments (0)