Quand on écrit du Go pour une plateforme financière sous agrément AMF, il y a une règle tacite que personne ne formule mais que tout le monde comprend au premier incident : le code qui tourne vendredi soir doit encore tourner lundi matin, exactement pareil. Pas « à peu près pareil ». Pas « après un restart ». Exactement pareil.
Ça change la façon dont on écrit du Go. On ne cherche plus la solution la plus élégante, on cherche celle qui ne casse pas à 3h du matin un jour férié. Les patterns que je décris ici ne sont pas issus de tutoriels ou de conférences. Ce sont ceux qui ont survécu à des mois de production, de post-mortems et de revues de code avec des gens qui ont très peu de patience pour le code qui « devrait marcher ».
Graceful shutdown, le pattern non-négociable
Premier pattern, et de loin le plus critique. Si votre service ne sait pas s'arrêter proprement, tout le reste est de la décoration.
Le scénario : un déploiement en cours. Kubernetes envoie SIGTERM. Votre service a 30 secondes pour finir ce qu'il fait. Si vous êtes au milieu d'une transaction financière, un transfert de fonds, une réconciliation, une écriture comptable, vous ne pouvez pas juste couper. Vous ne pouvez pas non plus prendre 5 minutes.
func run(ctx context.Context) error {
srv := &http.Server{
Addr: ":8080",
Handler: newRouter(),
}
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
return fmt.Errorf("server stopped: %w", err)
case <-ctx.Done():
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return srv.Shutdown(shutCtx)
}
}
Trois détails qui comptent :
context.Background()pour le shutdown, pas le ctx parent, le parent est déjà annulé, c'est pour ça qu'on est ici- 25 secondes, pas 30, on se garde 5 secondes de marge avant que Kubernetes kill -9
- Le channel d'erreur est bufférisé, si le shutdown arrive avant que
ListenAndServeretourne, le goroutine ne fuit pas
La vraie difficulté n'est pas le serveur HTTP, c'est tout le reste. Les consumers Kafka, les workers de background, les connexions gRPC, les tickers. Chaque composant qui a un cycle de vie doit s'arrêter dans le bon ordre. En pratique, on utilise errgroup avec un contexte partagé : le premier composant qui meurt annule les autres.
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return httpServer.Run(ctx) })
g.Go(func() error { return grpcServer.Run(ctx) })
g.Go(func() error { return kafkaConsumer.Run(ctx) })
g.Go(func() error { return metricsServer.Run(ctx) })
return g.Wait()
Simple. Testable. Chaque composant implémente Run(ctx context.Context) error. Quand le context est annulé, tout se referme dans l'ordre inverse du démarrage. C'est ennuyeux, c'est verbeux, et ça marche depuis deux ans sans surprise.
Middleware HTTP, la stack de production
Toute requête HTTP passe par la même chaîne de middleware. L'ordre n'est pas négociable :
func newRouter() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
mux.HandleFunc("POST /api/v1/transfers", handleTransfer)
var h http.Handler = mux
h = withAuth(h)
h = withRequestID(h)
h = withRecovery(h)
h = withLogging(h)
h = withMetrics(h)
return h
}
L'ordre se lit de bas en haut (le dernier wrappé est le premier exécuté) :
- Metrics : Prometheus histogramme, avant tout le reste pour capter la durée totale
- Logging : structured log de la requête avec request ID, status, durée
- Recovery : attrape les panics, log le stack trace, retourne 500 au lieu de tuer le process
- Request ID : UUID dans le context, propagé dans tous les logs et les appels downstream
- Auth : vérification du token, injection de l'identité dans le context
Le middleware de recovery est le plus sous-estimé. En dev, un panic crashe le programme et on voit la stack trace dans le terminal. En production, un panic dans un handler HTTP tue le goroutine mais pas le process, sauf que la connexion est coupée proprement par le runtime, sans log. Le client reçoit un EOF. Vous ne voyez rien. Le recovery middleware transforme ça en 500 + stack trace dans les logs.
func withRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"error", rec,
"stack", string(debug.Stack()),
"path", r.URL.Path,
)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Circuit breaker, quand le downstream est mort
En fintech, vos services parlent à des partenaires bancaires, des APIs de KYC, des systèmes de paiement. Ils tombent. Pas souvent, mais quand ils tombent, c'est rarement pendant 5 secondes, c'est pendant 45 minutes, un samedi, sans préavis.
Sans circuit breaker, votre service empile les requêtes en attente, les goroutines se multiplient, la mémoire monte, les timeouts cascadent, et votre propre healthcheck tombe. Le circuit breaker coupe la connexion vers le service mort avant que ça ne contamine le reste.
type CircuitBreaker struct {
mu sync.Mutex
failures int
lastFailure time.Time
threshold int
resetTimeout time.Duration
state string // "closed", "open", "half-open"
}
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case "open":
if time.Since(cb.lastFailure) > cb.resetTimeout {
cb.state = "half-open"
return true
}
return false
default:
return true
}
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failures = 0
cb.state = "closed"
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= cb.threshold {
cb.state = "open"
}
}
Implémentation naïve, volontairement. En production on utilise souvent sony/gobreaker, mais comprendre le mécanisme aide à configurer les seuils. Les questions qui comptent : combien de failures avant d'ouvrir le circuit ? Combien de temps avant de retenter ? Est-ce que « failure » inclut les timeouts ou seulement les 5xx ?
La réponse dépend du service downstream. Un partenaire bancaire qui répond en 800ms en temps normal et 30s quand il rame ? Timeout à 5s, circuit ouvert après 3 failures, reset après 60s. Un service interne qui répond en 2ms ? Timeout à 500ms, circuit après 5 failures, reset après 10s.
Structured logging, slog en production
On est passé de log.Printf à slog (standard library depuis Go 1.21) il y a un an et demi. Le gain n'est pas esthétique, c'est opérationnel. Quand un incident arrive à 2h du matin, la question n'est jamais « qu'est-ce qui s'est passé ? » mais « qu'est-ce qui s'est passé pour ce request ID, ce user, ce montant ? ».
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("transfer processed",
"request_id", reqID,
"user_id", userID,
"amount_cents", amount,
"duration_ms", time.Since(start).Milliseconds(),
"partner", "bank_xyz",
)
En JSON, chaque champ est indexable par votre stack de logs (ELK, Loki, Datadog). Vous pouvez filtrer tous les transferts d'un user, tous les appels à un partenaire, tous les traitements de plus de 500ms, sans parser du texte libre.
Deux règles qu'on s'impose :
- Jamais de données personnelles dans les logs : pas d'email, pas de nom, pas d'IBAN. User ID oui, le reste non. C'est un réflexe RGPD, mais surtout c'est la loi quand vous manipulez des fonds.
- Le request ID traverse tout : du middleware HTTP jusqu'au dernier appel gRPC downstream. On le passe dans le context, chaque log l'inclut. Quand un client appelle pour dire « ma transaction est bloquée », le support donne le request ID, et en 30 secondes on a toute la trace.
Ce que le code ne montre pas
Les patterns ci-dessus sont les briques techniques. Ce qui fait la différence en production financière, c'est tout ce qui n'est pas du code :
- Les post-mortems sans blâme. Chaque incident documenté, chaque action corrective suivie. Le code qui a causé le problème est corrigé et le process qui l'a laissé passer est corrigé aussi.
- Les tests de shutdown. On teste le graceful shutdown aussi sérieusement que les features. Un déploiement qui drop des requêtes est un bug P0.
- Le « boring code ». Le code le plus fiable est celui qu'on n'a pas besoin de relire. Pas de generics partout, pas de channels quand un mutex suffit, pas d'abstraction pour le plaisir. Le code ennuyeux est le code qui tourne.
Conclusion
Après plusieurs années de Go en production financière, les patterns qui survivent ne sont jamais les plus sophistiqués. Ce sont les plus ennuyeux. Graceful shutdown, middleware dans le bon ordre, circuit breaker, structured logging. Rien de spectaculaire. Mais quand le partenaire bancaire tombe à 23h un vendredi, c'est ce code ennuyeux qui fait la différence entre « le circuit breaker a coupé, on a 0 transaction perdue, on rentre chez nous » et « on passe le week-end à réconcilier des écritures comptables ».
Le Go « best practices 2026 » n'est pas dans les nouveautés du langage. Il est dans la discipline de ce qu'on écrit, et surtout de ce qu'on n'écrit pas.