Chaque projet backend Go finit par résoudre les mêmes problèmes : logging structuré, authentification JWT, arrêt gracieux, chargement de configuration depuis des variables d'environnement, clients HTTP sortants avec retry... À un moment donné, soit on copie-colle depuis son dernier projet, soit on construit quelque chose de réutilisable.
Duck
duck est ce quelque chose de réutilisable. C'est un toolkit Go modulaire que j'ai conçu pour couvrir les problématiques récurrentes d'un service HTTP — sans imposer une architecture, sans vendor lock-in, et sans magie cachée.
Le projet en est toujours à ses débuts, et j'espère augmenter progressivement son scope en me basant sur des besoins réels de multiples projets.
👉 github.com/qwackididuck/duck
Pourquoi duck ?
L'écosystème Go offre d'excellentes bibliothèques individuelles. Mais les assembler de façon cohérente et production-ready — un arrêt gracieux qui draine aussi les goroutines de fond, un middleware de logging qui masque les champs sensibles, un middleware JWT avec rotation de clés — ça prend du temps à chaque projet.
duck n'est pas un framework. C'est une collection de packages indépendants qui résolvent chacun une chose précisément, et qui se composent proprement ensemble.
Installation
go get github.com/qwackididuck/duck
Nécessite Go 1.26+. Les sous-packages sont des imports séparés — on ne tire que les dépendances dont on a besoin :
go get github.com/qwackididuck/duck/server # aucune dépendance externe go get github.com/qwackididuck/duck/jwt # importe go-jose/v4 go get github.com/qwackididuck/duck/oauth2 # importe golang.org/x/oauth2 go get github.com/qwackididuck/duck/metrics # importe prometheus/client_golang
Ce qu'il contient
┌──────────────────────────────────────────────────────────────────────────┐ │ votre service │ ├─────────────┬────────────┬────────────┬────────────┬─────────────────────┤ │ server │ log │ config │ jwt │ oauth2 │ │ graceful │ slog + │ env vars │ go-jose v4 │ Auth Code + PKCE │ │ shutdown │ context │ + files │ + JWKS │ session store │ ├─────────────┴────────────┴────────────┴────────────┴─────────────────────┤ │ middleware : logging · metrics · body limit · compress │ ├──────────────────────────────────────────────────────────────────────────┤ │ httpclient : retry · backoff · logging │
Voici un tour des parties les plus intéressantes.
server — L'arrêt gracieux fait correctement
Le package server gère le cycle de vie complet d'un service HTTP : démarrage, supervision des goroutines de fond, health probes Kubernetes, et arrêt propre.
srv, err := server.New( server.WithAddr(":8080"), server.WithHandler(router), server.WithLogger(logger), server.WithShutdownTimeout(30 * time.Second), ) if err != nil { log.Fatal(err) }
À la réception de SIGINT ou SIGTERM, la séquence d'arrêt est ordonnée et déterministe :
- On arrête d'accepter de nouvelles connexions
- Le contexte applicatif est annulé — toutes les goroutines reçoivent le signal
- Les requêtes HTTP en cours sont drainées
- On attend que toutes les goroutines enregistrées aient terminé (jusqu'à
ShutdownTimeout) - Le processus se termine proprement
Les goroutines de fond sont enregistrées via srv.Go() et reçoivent le contexte annulable :
srv.Go(func(ctx context.Context) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: runPeriodicJob(ctx) }
Health probes Kubernetes
srv, err := server.New( server.WithAddr(":8080"), server.WithHandler(router), server.WithHealthChecks("payments-service", server.WithKOStatus(http.StatusServiceUnavailable), ), server.WithDependency(&PostgresChecker{db: db}), server.WithDependency(&RedisChecker{client: rdb}), )
GET /health répond à la liveness probe (toujours OK tant que le process tourne). GET /ready vérifie chaque dépendance enregistrée et retourne 503 si l'une d'elles est en échec — retirant automatiquement le pod du pool du load balancer.
log — slog avec propagation par contexte
Le package log est une fine couche au-dessus du standard log/slog. Il retourne un simple *slog.Logger — aucun type custom à transporter dans le codebase.
La fonctionnalité clé est la propagation d'attributs par contexte : on attache des champs une seule fois, et ils apparaissent automatiquement dans tous les appels de log qui utilisent ce contexte.
ctx := ducklog.ContextWithAttrs(r.Context(), slog.String("request_id", requestID), slog.String("user_id", session.UserID), ) // Dans une couche service — pas besoin de passer request_id manuellement : ducklog.FromContext(ctx, logger).Info("commande créée", slog.String("order_id", "ord_123"), )
Sortie :
{ "level": "INFO", "msg": "commande créée", "request_id": "req-3f8a", "user_id": "usr_42", "order_id": "ord_123" }
Ce pattern peut sembler anodin, mais il élimine une quantité significative de boilerplate dès que le service grossit au-delà de quelques handlers.
config — Configuration type-safe avec les génériques
Le package config charge la configuration depuis des variables d'environnement et/ou des fichiers JSON/YAML grâce aux génériques Go. Le comportement de chaque champ est déclaré dans les struct tags.
type Config struct { Addr string `duck:"env=ADDR,default=:8080"` DatabaseURL string `duck:"env=DATABASE_URL,required"` ShutdownTimeout time.Duration `duck:"env=SHUTDOWN_TIMEOUT,default=30s"` AllowedOrigins []string `duck:"env=ALLOWED_ORIGINS,sep=,"` } cfg, err := config.Load[Config]( config.WithEnv(), config.WithFile("config.yaml"),
Ordre de résolution : variables d'env → fichier → valeurs par défaut du tag. Si un champ required est absent de toutes les sources, Load retourne une erreur wrappant config.ErrMissingMandatory. Cela empêche les mauvaises configurations silencieuses d'atteindre la production.
jwt — Construit sur go-jose v4
La génération et la validation JWT s'appuient sur go-jose v4. Les claims sont génériques — on définit son propre struct en embarquant josejwt.Claims.
type AppClaims struct { josejwt.Claims TenantID string `json:"tenantId"` Role string `json:"role"` } provider, err := jwt.WithHMACKey(jwt.HS256, []byte("votre-secret-32-bytes-minimum!!")) token, err := jwt.Generate(AppClaims{ Claims: josejwt.Claims{
Le middleware est tout aussi concis :
router.Use(jwt.Middleware[AppClaims](provider)) // Dans un handler : claims, ok := jwt.ClaimsFromContext[AppClaims](r.Context())
Rotation de clés sans interruption de service
newProvider, _ := jwt.WithHMACKey(jwt.HS256, newSecret) oldProvider, _ := jwt.WithHMACKey(jwt.HS256, oldSecret) rotatingProvider := jwt.NewMultiKeyProvider(newProvider, oldProvider) // Signe avec la nouvelle clé, vérifie avec les deux.
Providers externes via JWKS
Fonctionne nativement avec Keycloak, Auth0, AWS Cognito, Azure AD :
provider, err := jwt.NewJWKSProvider( "https://keycloak.company.com/realms/myrealm/protocol/openid-connect/certs", jwt.WithJWKSRefreshInterval(30 * time.Minute), jwt.WithJWKSAlgorithms(jwt.RS256), )
Les clés publiques sont récupérées, mises en cache et rafraîchies automatiquement. En cas d'échec du rafraîchissement, les dernières clés connues sont conservées (comportement stale-on-error).
oauth2 — Authorization Code + PKCE
Le package oauth2 gère le cycle OAuth2/OIDC complet — PKCE, vérification CSRF, échange de token, création de session et logout — construit sur golang.org/x/oauth2.
auth, err := oauth2.New( oauth2.WithProvider(providers.Google(clientID, clientSecret)), oauth2.WithProvider(providers.GitHub(clientID, clientSecret)), oauth2.WithRedirectURL("https://myapp.com/auth/{provider}/callback"), oauth2.WithSessionStore(store.NewMemoryStore()), oauth2.WithOnLogin(func(ctx context.Context, id oauth2.Identity) (oauth2.SessionData, error) { user, _ := db.Upsert(id.Provider, id.ProviderID, id.Email, id.Name) return oauth2.SessionData{UserID: user.ID}, nil }), oauth2.WithSuccessRedirect("/dashboard"),
Quatre routes sont montées automatiquement : login, callback, logout, et logout-all. Le hook OnLogin est la seule logique métier à implémenter — tout le reste (code verifier PKCE, cookie de state, échange de token) est géré en interne.
En production, on remplace le store mémoire par Redis :
oauth2.WithSessionStore(store.NewRedisStore(rdb, store.WithKeyPrefix("myapp:sessions:"), store.WithTTL(7 * 24 * time.Hour), ))
middleware — Une chaîne composable
chi est utilisé comme routeur. Les middlewares se composent avec middleware.Chain :
router.Use(middleware.Chain( middleware.Logging(logger, middleware.WithObfuscatedHeaders("Authorization", "Cookie"), middleware.WithObfuscatedBodyFields("password", "token"), ), middleware.BodyLimit(1 * 1024 * 1024), middleware.Compress(), middleware.HTTPMetrics(promProvider, middleware.WithPathCleaner(func(r *http.Request) string { return chi.RouteContext(r.Context()).RoutePattern()
Quelques détails notables :
- Logging middleware génère ou propage
X-Request-Id, et supporte la capture du body avec limitation de taille et obfuscation des champs — utile pour déboguer sans fuiter des credentials dans les logs. - BodyLimit utilise une double protection : vérification de
Content-Lengthen premier, puishttp.MaxBytesReaderpour les clients qui mentent ou omettent ce header. - Compress active le gzip paresseusement — seulement quand le body dépasse un seuil de taille et que le
Content-Typeest compressible. Les instancesgzip.Writersont poolées viasync.Pool. - HTTPMetrics utilise
chi.RouteContextpour normaliser les chemins comme/users/123en/users/{id}, évitant une cardinalité élevée dans les labels Prometheus.
httpclient — Retry, backoff et corrélation de requêtes
client := httpclient.New( httpclient.WithTimeout(30 * time.Second), httpclient.WithRetry( []httpclient.RetryCondition{ httpclient.RetryOnStatusCodes(429, 502, 503, 504), httpclient.RetryOnNetworkErrors(), }, httpclient.WithMaxAttempts(3), httpclient.WithExponentialBackoff(100 * time.Millisecond), ),
Le backoff exponentiel utilise le full jitter (random(0, base × 2ⁿ)) pour éviter le thundering herd problem lorsque plusieurs services redémarrent simultanément.
Le transport de logging se situe en couche externe, de sorte que chaque tentative — y compris les retries — est loggée individuellement avec son propre code de statut et sa durée. Il propage aussi automatiquement X-Request-Id vers les services en aval, permettant une corrélation de requêtes end-to-end sans câblage supplémentaire.
Principes de conception
duck a été construit autour de quelques principes explicites qui ont orienté chaque décision d'API.
Functional options partout. Chaque fonction With* est rétrocompatible. Ajouter une nouvelle option ne casse jamais un appel existant — une propriété importante pour un toolkit partagé.
Génériques pour la type safety. statemachine.Run[D], jwt.Middleware[C], config.Load[T] — les erreurs de type sont détectées à la compilation, sans interface{} ni cast à l'exécution.
Extensibilité par interfaces. server.Stater, middleware.HTTPMetricsProvider, oauth2.Provider, oauth2.SessionStore sont toutes des interfaces. On peut substituer n'importe quel backend (Postgres, MongoDB, un provider d'identité custom) sans toucher aux internals de duck.
Types standards, zéro lock-in. httpclient.New retourne *http.Client. log.New retourne *slog.Logger. On ne transporte jamais un wrapper spécifique à duck dans le codebase. duck reste aux bords.
Pas de magie. Aucune fonction init(), aucune génération de code, aucun état global. Ce qu'on configure est ce qui s'exécute.
Outillage
duck utilise golangci-lint pour l'analyse statique (configuré dans .golangci.yml) et CodeRabbit pour les revues de code assistées par IA sur chaque pull request.
Conclusion
duck ne dicte pas comment structurer son service. Il n'oblige pas à utiliser tous ses packages. C'est une collection d'utilitaires bien délimités et bien typés qui couvrent les problèmes que je résolvais continuellement depuis zéro — et que je n'ai plus à résoudre.
Le code source, les exemples et la documentation complète sont sur GitHub :
👉 github.com/qwackididuck/duck
Des exemples exécutables pour chaque package se trouvent dans examples/. Chacun peut être lancé avec go run ./examples/<n>.