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é :
slogutilisé partout- Routing stdlib Go 1.22 — pas de gorilla/mux
modernc.org/sqlitezero-CGO- Interface-based design sur les stores
errors.Ispour les sentinels- Context propagé sur toutes les opérations I/O
Points à améliorer :
nil, nilpour "not found" dans le store → devrait êtrenil, ErrJobNotFound- Tests non parallèles —
t.Parallel()absent partout govulncheckabsent du CI/Makefilecontext.WithoutCancelpour 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.