Conventions Go 2025-2026 — Ce que font les meilleurs projets

Go 1.21, 1.22, 1.23, 1.24 — quatre versions en deux ans, chacune ajoutant quelque chose qui change réellement la façon d'écrire du Go. Pas des features syntaxiques spectaculaires, mais des outils et patterns qui éliminent des problèmes récurrents. Le résultat : les codebases sérieuses de 2025 ressemblent nettement moins à celles de 2022.

Ce que fait cet article : passer en revue ce qui s'est stabilisé comme standard, avec des exemples concrets tirés de projets réels (Caddy, Tailscale, pgx v6). Et ce que les bons projets ont arrêté d'utiliser.

slog — le logger que tu n'as pas besoin d'installer

Depuis Go 1.21, slog est dans la stdlib. Logrus est mort, zap est en sursis, et les projets qui continuent à importer github.com/sirupsen/logrus en 2025 ont juste pas encore fait le ménage.

Le pattern qui s'est imposé dans les codebases sérieuses : un logger enrichi par opération, pas un logger global. Ça donne des logs structurés sans avoir à répéter les mêmes champs à chaque appel :

// Pattern reconnu : logger enrichi par contexte d'opération
func processJob(ctx context.Context, jobID, model string) {
    log := slog.With("job_id", jobID, "model", model)
    log.Info("starting")
    // ... traitement
    log.Error("failed", "error", err)
}

// Dans les librairies : ne jamais appeler slog.SetDefault()
// C'est le rôle de main.go — une lib qui appelle SetDefault() écrase
// la config de l'application qui l'utilise

Tailscale a migré vers slog. Caddy l'utilise pour ses plugins. C'est le signal que l'écosystème a tranché.

errors — wrapping systématique et sentinels

fmt.Errorf avec %w est le standard depuis Go 1.13 (2019). En 2025, si tu vois encore du code qui retourne une erreur nue sans contexte, c'est une dette technique. errors.Join (Go 1.20) a réglé le problème des multi-erreurs sans librairie externe.

// fmt.Errorf avec %w — wrapping systématique, sans exception
return fmt.Errorf("create job: %w", err)

// errors.Join pour la validation multi-champs
errs := []error{}
errs = append(errs, validatePrompt(r.Prompt))
errs = append(errs, validateModel(r.Model))
return errors.Join(errs...) // nil si tous nil, combiné sinon

Les sentinel errors ont leur importance pour les cas "not found" — mais encore faut-il les utiliser :

var ErrJobNotFound = errors.New("job not found")

// ❌ Anti-pattern encore trop répandu en 2024
func (s *Store) Get(id string) (*Job, error) {
    // ...
    return nil, nil // job absent = nil, nil ← ambigu, impossible à tester proprement
}

// ✅ 2025 — explicite et testable avec errors.Is
func (s *Store) Get(id string) (*Job, error) {
    // ...
    return nil, ErrJobNotFound
}

// Côté appelant — propre et sans comparaison de strings
if errors.Is(err, ErrJobNotFound) {
    return http.StatusNotFound, nil
}

ClaudeGate a encore quelques cas nil, nil qui devraient être nil, ErrJobNotFound. C'est exactement le genre de dette qui rend les tests fragiles.

context — la discipline des 10 secondes

Le context est non-négociable depuis longtemps. Ce qui a changé avec Go 1.21 : context.WithoutCancel. Un ajout qui règle un problème réel qu'on contournait avec context.Background() — en perdant toutes les valeurs du contexte parent.

// context.WithoutCancel (Go 1.21) — pour les opérations fire-and-forget
// Cas d'usage exact : webhooks, notifications, opérations de fond
go webhook.Send(context.WithoutCancel(ctx), url, payload)
// Sans ça : si le contexte HTTP expire, le webhook est annulé avant d'être envoyé
// Avant Go 1.21 : context.Background() — perd les valeurs du contexte (trace ID, user ID, etc.)

// Timeout sur toutes les opérations externes — sans exception
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

Dans ClaudeGate, les webhooks utilisent encore le contexte de la requête HTTP d'origine. Si la requête expire à 30 secondes et que le webhook met 35 secondes à répondre, le webhook est silencieusement annulé. context.WithoutCancel corrige ça.

iter.Seq2 — itérateurs natifs, enfin

Go 1.23 a introduit le package iter et le "range over functions". C'est probablement le changement le plus sous-estimé des versions récentes. pgx v6 et sqlc l'ont déjà adopté pour les curseurs de base de données.

// Avant Go 1.23 — verbeux et error-prone
rows, err := s.db.QueryContext(ctx, `SELECT id, status, prompt FROM jobs`)
if err != nil {
    return fmt.Errorf("query jobs: %w", err)
}
defer rows.Close()
for rows.Next() {
    var j Job
    if err := rows.Scan(&j.ID, &j.Status, &j.Prompt); err != nil {
        return fmt.Errorf("scan job: %w", err)
    }
    jobs = append(jobs, j)
}

// Go 1.23+ — pattern propre, lazy, composable
func (s *Store) Jobs(ctx context.Context) iter.Seq2[*Job, error] {
    return func(yield func(*Job, error) bool) {
        rows, err := s.db.QueryContext(ctx, `SELECT id, status, prompt FROM jobs`)
        if err != nil {
            yield(nil, err)
            return
        }
        defer rows.Close()
        for rows.Next() {
            j := &Job{}
            if err := rows.Scan(&j.ID, &j.Status, &j.Prompt); err != nil {
                yield(nil, err)
                return
            }
            if !yield(j, nil) {
                return // l'appelant a fait break — stop propre
            }
        }
    }
}

// Usage — lisible, gestion d'erreur explicite
for j, err := range store.Jobs(ctx) {
    if err != nil {
        return fmt.Errorf("list jobs: %w", err)
    }
    // traiter j
}

L'intérêt ne se limite pas aux requêtes SQL. Tout ce qui était un slice chargé en mémoire d'un coup peut devenir un itérateur lazy. Pour les grandes collections, c'est la différence entre 2 Mo et 2 Ko en RAM.

testing — synctest et la fin des time.Sleep

Go 1.24 a ajouté testing/synctest. Si tu as déjà écrit un test avec time.Sleep(100 * time.Millisecond) pour "laisser le temps à la goroutine", tu sais que c'est du cargo-culting — ça rend les tests flaky sur les machines lentes et trop lents sur les rapides.

// ❌ Avant — sleep hacky, flaky par nature
go worker.processJob(ctx, "job-1")
time.Sleep(100 * time.Millisecond)
assert.Equal(t, "completed", store.GetStatus("job-1"))

// ✅ Go 1.24 — synctest contrôle le temps virtuel
synctest.Run(func() {
    go worker.processJob(ctx, "job-1")
    synctest.Wait() // attend que tous les goroutines soient bloqués ou terminés
    // les assertions sont maintenant déterministes
    assert.Equal(t, "completed", store.GetStatus("job-1"))
})

Les deux autres patterns qui se sont imposés en 2025 :

// Sous-tests parallèles — systématique sur les table-driven tests
func TestValidate(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name    string
        input   string
        wantErr bool
    }{
        {"valid", "claude-3-5-sonnet", false},
        {"empty", "", true},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            err := validateModel(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("got %v, want err=%v", err, tt.wantErr)
            }
        })
    }
}

// t.Cleanup systématique — plus propre que defer dans TestXxx
store := newTestStore(t)
t.Cleanup(func() { store.Close() })

ClaudeGate n'a pas encore t.Parallel() sur ses tests unitaires. C'est rapide à ajouter et ça révèle souvent des races conditions qu'on n'aurait pas vus autrement.

go:generate — mocks sans bikeshedding

La guerre des librairies de mock est terminée en pratique. mockery v3 avec //go:generate est devenu le standard de facto pour les projets qui ont besoin de mocks. Le débat testify vs stdlib testing est moins tranché — les nouveaux projets partent souvent sur la stdlib.

//go:generate mockery --name=JobStore --output=mocks --outpkg=mocks

type JobStore interface {
    Get(ctx context.Context, id string) (*Job, error)
    Save(ctx context.Context, job *Job) error
}
go generate ./...

Le vrai intérêt de //go:generate : les mocks sont versionés avec le code. Pas de script externe à maintenir, pas de "j'ai oublié de régénérer". Le go generate dans la CI vérifie que les mocks sont à jour.

go tool — dépendances de tooling dans go.mod

Go 1.24 a officialisé quelque chose qu'on bricolait depuis des années avec des fichiers tools.go vides. La directive tool dans go.mod versionne les outils de dev avec le projet :

// go.mod
tool (
    github.com/golangci/golangci-lint/cmd/golangci-lint v1.62.0
    golang.org/x/vuln/cmd/govulncheck v1.1.3
)
// Plus de curl | sh dans les Makefiles
go tool golangci-lint run
go tool govulncheck ./...

Le bénéfice concret : tout le monde utilise la même version de golangci-lint. Plus de "ça passe chez moi" parce que quelqu'un a la v1.55 et la CI a la v1.62.

Ce que les meilleurs n'utilisent plus

Le signe d'une codebase Go sérieuse en 2025 : le nombre de dépendances directes dans go.mod. Les bons projets ont 3 à 5 dépendances directes, pas 30.

Abandonné / évité Remplacé par
github.com/pkg/errors stdlib errors + fmt.Errorf("%w")
testify/assert dans les nouveaux projets stdlib testing
logrus, zap slog
gorilla/mux net/http routing stdlib (Go 1.22)
cobra pour les petits CLIs flag + sous-commandes manuelles
Docker multi-stage complexes ko pour Go ou docker buildx

La tendance de fond : Go a rattrapé son retard sur le routing HTTP (Go 1.22), le logging (Go 1.21), les itérateurs (Go 1.23). Ce qui était une excuse légitime pour les dépendances tierces il y a 3 ans ne l'est plus.

Bilan ClaudeGate

À titre d'exemple concret, voici où en est ClaudeGate par rapport à ces conventions :

Bien aligné :

  • slog utilisé partout
  • Routing stdlib Go 1.22 — pas de gorilla/mux
  • modernc.org/sqlite zero-CGO
  • Interface-based design sur les stores
  • errors.Is pour les sentinels
  • Context propagé sur toutes les opérations I/O

Points à améliorer :

  • nil, nil pour "not found" dans le store → devrait être nil, ErrJobNotFound
  • Tests non parallèles — t.Parallel() absent partout
  • govulncheck absent du CI/Makefile
  • context.WithoutCancel pour les webhooks fire-and-forget
  • Tooling golangci-lint non versionné via go tool

Conclusion

La vraie tendance 2025 : les meilleurs projets Go ont un go.mod avec 3 à 5 dépendances directes maximum. La philosophie stdlib-first est revenue en force depuis que Go 1.22 a fixé le routing HTTP, Go 1.21 a donné slog, et Go 1.23 a donné les itérateurs. Ce n'est pas un hasard — c'est le signal que la stdlib Go est enfin mature pour la plupart des cas d'usage.

Ce qui est frappant dans des projets comme Tailscale ou Caddy, c'est l'absence de dépendances "utilitaires". Pas de lodash-Go, pas de librairie de retry, pas de framework de validation. Juste du Go idiomatique avec la stdlib — et les cas complexes gérés directement, sans abstraction intermédiaire.

Pour les projets existants : pas besoin de tout migrer d'un coup. Commencer par slog (une journée de travail sur une codebase moyenne), ajouter govulncheck en CI (30 minutes), et introduire t.Parallel() sur les nouveaux tests. Le reste suit naturellement.

Commentaires (0)