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, stdlibnet/http, SQL viadatabase/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)
Pourquoi comme ça :
-
cmd/api— le binaire. Un seulmain. Plus tard tu pourras avoircmd/migratesans 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-toutservices/global dès le jour 1. -
httpapi/— tout ce qui parle HTTP. Le domaine ne connaît pasResponseWriter.
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
}
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
}
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"})
}
// 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})
}
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
}
// internal/mailer/mailer.go
package mailer
type Stub struct{}
func (Stub) SendWelcome(email string) error {
return nil // volontairement muet pour le squelette
}
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
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)