Architecture de la gestion des erreurs en Go : limites et schémas
Gérer les erreurs à la bonne frontière.
La gestion des erreurs en Go est souvent critiquée. Tout développeur Go a écrit ce code des centaines de fois :
if err != nil {
return err
}
Ce n’est pas là que réside l’intérêt. L’aspect intéressant, c’est ce que signifie l’erreur, où elle doit être gérée, où elle doit être enveloppée (wrapped), où elle doit être traduite, où elle doit être journalisée (logged) et ce qui doit être exposé à l’appelant — c’est là que se pose la question d’architecture.
Go traite les erreurs comme des valeurs. Cela rend les échecs explicites. Cela signifie aussi que votre base de code a besoin d’une conception claire de la gestion des erreurs. Sans cela, les erreurs deviennent des chaînes de caractères aléatoires, les gestionnaires HTTP divulguent des détails de la base de données, les journaux dupliquent la même échec cinq fois, les nouvelles tentatives (retries) se produisent pour les mauvaises raisons, et les appelants inspectent le texte au lieu du comportement.

Cet article n’est pas une introduction pour débutants à if err != nil.
C’est un guide pratique sur l’architecture de la gestion des erreurs en Go : enveloppement (wrapping), erreurs sentinelle, types d’erreurs personnalisés, errors.Is, errors.As, limites d’erreurs (error boundaries), cartographie des API, journalisation (logging), nouvelles tentatives, sécurité et modèles de production.
La version légèrement opinionnée : ne cherchez pas à faire disparaître les erreurs Go. Donnez-leur un sens à la bonne limite (boundary).
Ce que sont les erreurs Go
En Go, une erreur est simplement une valeur implémentant cette interface :
type error interface {
Error() string
}
Cette petite interface est la raison pour laquelle la gestion des erreurs en Go semble si directe.
Les fonctions renvoient explicitement des erreurs :
func LoadUser(id string) (*User, error) {
// ...
}
Les appelants décident quoi faire :
user, err := LoadUser(id)
if err != nil {
return nil, err
}
Il n’y a ni exceptions ni déballage de pile caché. L’échec fait partie de la signature de la fonction.
C’est bien, mais cela signifie aussi que les erreurs ont besoin d’une conception. Si chaque paquet renvoie des messages arbitraires, les appelants ne peuvent pas prendre de décisions fiables. Si chaque couche enveloppe chaque erreur sans discipline, les opérateurs obtiennent des messages bruyants et les développeurs des chaînes d’erreurs confuses. Si aucune couche n’enveloppe les erreurs, les échecs perdent leur contexte.
L’objectif n’est pas moins de gestion des erreurs, mais un meilleur sens des erreurs.
Les trois rôles d’une erreur
Une erreur utile a généralement un ou plusieurs rôles.
Rôle 1 : Expliquer ce qui a échoué
Pour les humains, l’erreur doit expliquer quelle opération a échoué.
Exemple :
return fmt.Errorf("load user %s: %w", id, err)
Cela donne un contexte. Cela indique que l’échec s’est produit lors du chargement d’un utilisateur.
Rôle 2 : Préserver la cause
Pour le code, l’erreur doit préserver la cause sous-jacente lorsque celle-ci est importante.
Exemple :
return fmt.Errorf("load user %s: %w", id, err)
Le %w enveloppe l’erreur d’origine pour que les appelants puissent l’inspecter avec errors.Is ou errors.As. L’enveloppement a un nouveau piège en Go 1.27 : la vérification par vet derrière go test émet maintenant un avertissement sur fmt.Errorf("...: %w", p) lorsque p est un pointeur vers un type qui lui-même implémente error, car l’indirection supplémentaire brise les assertions de type et la correspondance de errors.Is. Le Guide de mise à niveau Go 1.27 couvre cela ainsi que les autres constats de vet qui apparaissent après une montée de version de la chaîne d’outils.
Rôle 3 : Permettre à une limite de prendre une décision
À une certaine limite, le programme doit décider quoi faire.
Exemples :
- Retourner HTTP 404
- Retourner HTTP 409
- Retenter l’opération
- Journaliser au niveau avertissement
- Afficher un message sûr pour l’utilisateur
- Abandonner la transaction
- Envoyer l’erreur à la supervision
- Ignorer l’annulation
Cette décision doit généralement être basée sur l’identité ou le type d’erreur, et non sur la correspondance de chaînes.
Les principaux outils d’erreurs dans Go moderne
Le Go moderne vous offre un petit mais puissant ensemble d’outils.
errors.New
Utilisez errors.New pour créer une valeur d’erreur simple :
var ErrNotFound = errors.New("not found")
Cela est utile pour les erreurs sentinelle.
fmt.Errorf avec %w
Utilisez fmt.Errorf avec %w pour envelopper une erreur :
return fmt.Errorf("query user: %w", err)
L’enveloppement ajoute du contexte tout en préservant l’erreur d’origine pour inspection.
errors.Is
Utilisez errors.Is pour vérifier si une erreur correspond à une cible spécifique quelque part dans sa chaîne :
if errors.Is(err, ErrNotFound) {
// handle not found
}
Utilisez ceci pour les erreurs sentinelle et les conditions connues.
errors.As
Utilisez errors.As pour extraire un type d’erreur spécifique d’une chaîne :
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// use validationErr.Field or validationErr.Reason
}
Utilisez ceci lorsque l’erreur transporte des données structurées.
errors.Join
Utilisez errors.Join lorsque plusieurs erreurs se sont produites et doivent toutes être préservées :
return errors.Join(closeErr, flushErr)
Les erreurs jointes peuvent toujours être inspectées avec errors.Is et errors.As.
Utilisez cela avec précaution. Une erreur jointe signifie que plusieurs échecs font partie d’un seul résultat.
Erreurs sentinelle
Une erreur sentinelle est une valeur d’erreur au niveau du paquet qui représente une condition connue.
Exemple :
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
Les erreurs sentinelle sont utiles lorsque l’appelant n’a besoin que de savoir quelle catégorie d’échec s’est produite.
Exemple :
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.queryUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user: %w", err)
}
return user, nil
}
Puis un service ou un gestionnaire peut vérifier :
if errors.Is(err, ErrUserNotFound) {
// return 404
}
Quand utiliser les erreurs sentinelle
Utilisez des erreurs sentinelle lorsque :
- La condition est stable.
- L’appelant doit en faire une condition de branchement.
- Aucune donnée structurée supplémentaire n’est nécessaire.
- L’erreur appartient à votre paquet ou à votre domaine.
Bonnes exemples :
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")
Quand ne pas utiliser les erreurs sentinelle
Ne créez pas de sentinelles pour chaque échec possible.
Mauvais :
var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")
Si les appelants ne font pas de branchement sur ceux-ci, ils ne sont peut-être que des messages.
Soyez également prudent quant à l’exportation de trop de sentinelles. Les erreurs sentinelle exportées deviennent partie de l’API de votre paquet.
Types d’erreurs personnalisés
Un type d’erreur personnalisé est utile lorsque l’erreur transporte des informations structurées.
Exemple :
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}
Appelant :
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
C’est meilleur que d’analyser une chaîne d’erreur.
Quand utiliser des types d’erreurs personnalisés
Utilisez des types d’erreurs personnalisés lorsque :
- Les appelants ont besoin de données structurées.
- L’erreur a des champs significatifs.
- Le type fait partie du contrat de votre paquet.
- L’appelant peut avoir besoin de traiter plusieurs valeurs différemment.
Exemples :
- Erreur de validation avec le nom du champ
- Erreur de limite de débit avec le temps de nouvelle tentative
- Erreur HTTP avec code de statut
- Erreur d’analyse avec ligne et colonne
- Erreur de domaine avec l’ID de la ressource
Quand ne pas utiliser des types d’erreurs personnalisés
Ne créez pas de types personnalisés juste pour éviter errors.New.
Ceci est inutile :
type NotFoundError struct{}
func (e NotFoundError) Error() string {
return "not found"
}
S’il n’y a pas de données utiles, une sentinelle est souvent suffisante.
Enveloppement des erreurs (Wrapping)
L’enveloppement ajoute du contexte à une erreur tout en préservant l’erreur d’origine.
Exemple :
func LoadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if err := parseConfig(data); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}
Si os.ReadFile échoue, l’appelant obtient à la fois :
- l’opération de haut niveau : lire la configuration
- la cause de bas niveau : permission refusée, fichier introuvable, etc.
Les deux sont disponibles à travers la chaîne d’erreurs, ce qui rend l’enveloppement avec %w utile à faire de manière cohérente.
Envelopper avec un contexte utile
Un bon enveloppement dit quelle opération a échoué :
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Un mauvais enveloppement ajoute du bruit :
return fmt.Errorf("error: %w", err)
Cela ne dit rien à l’appelant.
Évitez également de répéter le même nom commun à chaque couche :
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Ce type de chaîne est techniquement correct et pratiquement ennuyeux.
Enveloppez là où le contexte change le sens. Si vous ne pouvez pas expliquer en une phrase quelle opération a échoué, vous enveloppez probablement trop agressivement ou pas assez.
Quand envelopper et quand ne pas envelopper
C’est l’une des décisions d’architecture les plus importantes.
Envelopper lorsque l’on traverse une limite significative
Enveloppez lorsque l’erreur passe d’une opération à une opération de niveau supérieur.
Exemple :
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
L’erreur du dépôt fait maintenant partie d’une opération de service, et ce contexte ajouté est utile lorsque les opérateurs retracent un échec à travers les journaux.
Ne pas envelopper juste pour dire “échec”
Mauvais :
if err != nil {
return fmt.Errorf("failed: %w", err)
}
Le mot “failed” (échec) est généralement implicite par le fait qu’une erreur existe.
Ne pas envelopper si vous traduisez
Parfois, vous devez traduire une erreur en une autre erreur de domaine.
Exemple :
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
Cela cache intentionnellement le détail de la base de données et expose une condition de domaine.
Vous pouvez toujours préserver la cause si c’est utile, mais faites-le intentionnellement.
Ne pas exposer accidentellement les détails d’implémentation
Si vous enveloppez une erreur de bas niveau avec %w, les appelants peuvent l’inspecter.
C’est généralement bien à l’intérieur de votre application.
Mais dans une API publique de paquet, l’enveloppement peut exposer des détails d’implémentation comme partie de votre contrat.
Par exemple, si votre paquet enveloppe sql.ErrNoRows, les appelants peuvent commencer à en dépendre :
if errors.Is(err, sql.ErrNoRows) {
// caller now knows you use database/sql
}
Si vous pouvez changer le stockage plus tard, préférez une sentinelle de domaine :
var ErrUserNotFound = errors.New("user not found")
Puis renvoyez celle-ci depuis la limite du paquet.
Limites d’erreurs (Error boundaries)
La façon la plus utile de penser à la gestion des erreurs en Go est à travers les limites.
Une limite est un endroit où une erreur change de sens ou d’audience.
Les limites courantes incluent :
- de la base de données au dépôt
- du dépôt au service
- du service au gestionnaire HTTP
- du service à la commande CLI
- de l’erreur interne au message destiné à l’utilisateur
- de l’échec transitoire à la décision de nouvelle tentative
- de l’échec de l’opération à l’événement journalisé
- de l’erreur de domaine à la réponse d’API
L’architecture des erreurs est principalement une conception de limites. Chaque limite est un point de décision où les erreurs gagnent du contexte, perdent des détails d’implémentation, ou sont traduites en une forme sur laquelle la couche suivante peut agir.
Limite du dépôt
Le dépôt parle au stockage.
Il devrait généralement traduire les erreurs spécifiques à la base de données en erreurs de domaine.
Exemple :
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Le dépôt cache sql.ErrNoRows et expose ErrUserNotFound — une limite propre qui signifie que le service n’a pas besoin de savoir comment le stockage représente “introuvable”.
Limite du service
Le service possède le sens métier.
Il devrait généralement ajouter le contexte d’opération et préserver les erreurs de domaine.
Exemple :
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Cela préserve la condition de domaine tout en ajoutant du contexte pour les erreurs inattendues.
Pour des règles métier plus complexes, le service peut créer des erreurs de domaine directement :
var ErrAccountDisabled = errors.New("account disabled")
func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user by email: %w", err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
// ...
return session, nil
}
Le service est l’endroit approprié pour les erreurs de niveau métier — créées directement de la logique de domaine plutôt que traduites de conditions d’infrastructure.
Limite du gestionnaire HTTP
Le gestionnaire HTTP traduit les erreurs de l’application en réponses HTTP.
C’est une limite où les détails internes doivent devenir des réponses sûres pour l’utilisateur.
Les frameworks web diffèrent dans la façon dont ils structurent cette traduction. Echo achemine les erreurs du gestionnaire à travers un seul HTTPErrorHandler, tandis que les gestionnaires Gin rendent les erreurs directement sur le contexte de la requête — les compromis entre ces deux styles sont comparés dans Frameworks Web Go en 2026 : net/http, chi, Gin, Echo, Fiber comparés.
Exemple :
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
writeHTTPError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Cartographie des erreurs :
func writeHTTPError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrAccountDisabled):
http.Error(w, "account disabled", http.StatusForbidden)
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Le gestionnaire mappe les erreurs de domaine sur la sémantique HTTP plutôt que d’exposer les détails bruts de la base de données ou des erreurs internes. C’est là que de nombreuses applications Go se trompent — elles exposent soit trop de détails internes, soit réduisent toutes les erreurs à HTTP 500. Pour une image complète des modèles de gestionnaires et des middleware dans les API Go, Construire des API REST en Go couvre l’authentification, le routage et la gestion des erreurs à travers la bibliothèque standard, Gin, Echo et Fiber.
Limite CLI
Une CLI a une limite différente d’une API HTTP.
Dans une CLI, l’erreur doit être utile à la personne qui exécute la commande.
Exemple :
func RunImport(ctx context.Context, args []string) error {
if len(args) == 0 {
return ErrMissingInputFile
}
if err := importFile(ctx, args[0]); err != nil {
return fmt.Errorf("import %s: %w", args[0], err)
}
return nil
}
À la limite de la commande :
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, formatCLIError(err))
os.Exit(exitCode(err))
}
}
Mapper les erreurs connues aux codes de sortie :
func exitCode(err error) int {
switch {
case errors.Is(err, ErrMissingInputFile):
return 2
case errors.Is(err, ErrValidation):
return 3
default:
return 1
}
}
Une CLI peut souvent afficher plus de détails qu’une API publique, mais elle devrait toujours éviter de divulguer des secrets.
Modèle de type d’erreur d’API
Pour les API HTTP, un petit type d’erreur au niveau de l’application peut être utile.
Exemple :
type APIError struct {
Status int
Code string
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err == nil {
return e.Message
}
return e.Message + ": " + e.Err.Error()
}
func (e *APIError) Unwrap() error {
return e.Err
}
Constructeur :
func NewAPIError(status int, code string, message string, err error) *APIError {
return &APIError{
Status: status,
Code: code,
Message: message,
Err: err,
}
}
Utilisation :
return NewAPIError(
http.StatusConflict,
"duplicate_email",
"email is already registered",
ErrDuplicateEmail,
)
Gestionnaire :
func writeAPIError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
writeJSON(w, apiErr.Status, map[string]string{
"code": apiErr.Code,
"message": apiErr.Message,
})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
Ce modèle est utile lorsque vous souhaitez des erreurs d’API structurées avec des codes stables.
Utilisez-le à la limite de l’API. Ne forcez pas chaque paquet interne à renvoyer des erreurs spécifiques à l’API.
Erreurs de domaine vs erreurs de transport
Gardez les erreurs de domaine séparées des erreurs de transport.
Erreur de domaine :
var ErrInsufficientBalance = errors.New("insufficient balance")
Cartographie du transport :
if errors.Is(err, ErrInsufficientBalance) {
http.Error(w, "insufficient balance", http.StatusConflict)
return
}
Ne laissez pas votre couche de domaine renvoyer des codes de statut HTTP :
return &APIError{Status: http.StatusConflict}
Cela couple la logique métier au HTTP et empêche votre couche de service de fonctionner proprement à travers HTTP, CLI, workers, tests et futurs adaptateurs gRPC. La cartographie du transport appartient à la limite de transport, et non dans le code de domaine. Pour des conseils sur où définir les erreurs de domaine, les sentinelles et les adaptateurs de transport dans votre structure de projet, Structure de Projet Go : Pratiques et Modèles couvre les conventions internal/, pkg/ et adaptateur qui gardent ces couches proprement séparées.
Erreurs pouvant faire l’objet d’une nouvelle tentative
Certaines erreurs devraient déclencher une nouvelle tentative. D’autres non.
Ne décidez pas cela en faisant correspondre des chaînes.
Utilisez une interface marqueur ou une fonction explicite.
Exemple :
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
return e.Err
}
Assistant :
func Retryable(err error) error {
if err == nil {
return nil
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryable *RetryableError
return errors.As(err, &retryable)
}
Utilisation :
if err := callRemoteAPI(ctx); err != nil {
if isTemporaryNetworkError(err) {
return Retryable(fmt.Errorf("call remote api: %w", err))
}
return fmt.Errorf("call remote api: %w", err)
}
Boucle de nouvelle tentative :
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// retry with backoff
}
return err
}
C’est beaucoup meilleur que de vérifier si la chaîne d’erreur contient “timeout” — la correspondance de chaînes échoue silencieusement lorsque les messages changent et crée un couplage invisible entre le producteur et le consommateur.
Erreurs de validation
Les erreurs de validation ont souvent besoin de données structurées.
Exemple :
type FieldError struct {
Field string
Message string
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Utilisation :
func ValidateCreateUser(req CreateUserRequest) error {
var fields []FieldError
if req.Email == "" {
fields = append(fields, FieldError{
Field: "email",
Message: "email is required",
})
}
if len(fields) > 0 {
return &ValidationError{Fields: fields}
}
return nil
}
Gestionnaire :
var validationErr *ValidationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, validationErr)
return
}
C’est une bonne utilisation de errors.As car l’appelant a besoin d’informations structurées — noms de champs et messages de validation — et non juste une chaîne d’erreur opaque.
Erreurs multiples
Parfois, plusieurs choses échouent.
Exemples :
- fermeture de plusieurs ressources
- validation de nombreux champs
- arrêt de plusieurs workers
- exécution de vérifications indépendantes
- vidage et fermeture de la sortie
Utilisez errors.Join lorsque toutes les erreurs doivent être préservées.
Exemple :
func CloseAll(closers ...io.Closer) error {
var errs []error
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Appelant :
if err := CloseAll(a, b, c); err != nil {
return fmt.Errorf("close resources: %w", err)
}
À la fois errors.Is et errors.As peuvent inspecter les erreurs jointes, ce qui signifie que les valeurs d’erreurs jointes restent entièrement compatibles avec les modèles standards de vérification des erreurs.
Quand ne pas utiliser errors.Join
N’utilisez pas errors.Join lorsqu’il y a une erreur primaire et du contexte de journalisation.
Ne l’utilisez pas pour éviter de décider quelle erreur compte.
Ne renvoyez pas d’énormes erreurs jointes aux utilisateurs.
Les erreurs jointes sont utiles, mais elles peuvent devenir bruyantes rapidement.
Panic n’est pas la gestion des erreurs
Dans le code d’application normal, n’utilisez pas panic pour les erreurs attendues.
Mauvais :
if err != nil {
panic(err)
}
Utilisez panic pour les erreurs de programmeur ou des situations véritablement irrécupérables.
Exemples :
- violation impossible d’un invariant interne
- initialisation de paquet invalide
- échec d’un assistant de test avec
t.Fatalou panic dans des cas limités - erreur de configuration de démarrage irrécupérable, selon le style
Ne paniquez pas parce qu’une requête de base de données a échoué ou qu’un utilisateur a soumis une entrée invalide.
Ce sont des erreurs normales.
Journalisation des erreurs
Une erreur courante en Go est de journaliser la même erreur à chaque couche.
Mauvais :
func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.query(ctx, id)
if err != nil {
log.Printf("query failed: %v", err)
return nil, err
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
log.Printf("service failed: %v", err)
return nil, err
}
return user, nil
}
Cela crée des journaux dupliqués pour un seul échec.
Mieux :
- envelopper les erreurs à mesure qu’elles montent
- journaliser une seule fois à la limite où l’erreur est gérée
- inclure un contexte structuré dans le journal
Exemple :
func (s *Server) handleError(r *http.Request, err error) {
s.logger.ErrorContext(
r.Context(),
"request failed",
"method", r.Method,
"path", r.URL.Path,
"err", err,
)
}
Cela donne un événement de journal avec la chaîne d’erreurs complète. Pour une configuration de journalisation structurée prête pour la production, Journalisation Structurée en Go avec slog couvre les enregistrements log/slog, les gestionnaires JSON, la corrélation du contexte et la révélation — tous ceux-ci s’accordent naturellement avec la journalisation des erreurs au niveau des limites.
Quand journaliser dans les couches inférieures
Journalisez dans les couches inférieures uniquement lorsque la couche gère réellement l’erreur ou ajoute un contexte opérationnel important qui ne sera pas visible ailleurs.
Par exemple, une boucle de nouvelle tentative peut journaliser chaque tentative au niveau de débogage ou d’avertissement.
Mais un dépôt ne devrait pas journaliser chaque erreur de requête si le gestionnaire journalisera l’échec final de la requête.
Erreurs destinées à l’utilisateur vs erreurs destinées à l’opérateur
N’affichez pas les erreurs internes directement aux utilisateurs.
Erreur interne :
query user by id: dial tcp 10.0.4.12:5432: connection refused
Message destiné à l’utilisateur :
internal server error
Journal de l’opérateur :
request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"
Ce sont des audiences différentes, et une bonne architecture d’erreurs les garde séparées :
- erreur de diagnostic interne
- réponse sûre pour l’utilisateur
- code d’erreur d’API stable
- contexte de journal pour l’opérateur
Forcer une chaîne d’erreur unique à servir toutes ces audiences produit soit un risque d’exposition, soit un cauchemar de débogage. Concevez votre architecture d’erreurs autour de valeurs distinctes pour des consommateurs distincts.
Gestion sécurisée des erreurs
Les erreurs peuvent divulguer des informations sensibles.
Évitez d’exposer :
- chaînes de connexion à la base de données
- requêtes SQL avec des secrets
- noms d’hôtes internes
- chemins de fichiers
- jetons d’accès
- clés API
- traces de pile
- données privées des clients
- détails de la politique d’autorisation
Cela importe particulièrement dans les API HTTP.
Mauvais :
http.Error(w, err.Error(), http.StatusInternalServerError)
Bien :
http.Error(w, "internal server error", http.StatusInternalServerError)
Journalisez l’erreur interne de manière sécurisée pour les opérateurs. Renvoyez un message sûr à l’utilisateur.
Codes d’erreur
Pour les API publiques, des codes d’erreur stables sont souvent meilleurs que de s’appuyer uniquement sur des messages.
Exemple de réponse :
{
"code": "user_not_found",
"message": "user not found"
}
Le message peut changer. Le code devrait être stable.
Utilisez des codes d’erreur pour :
- comportement du client
- documentation
- SDKs
- localisation
- diagnostics de support
Ne laissez pas les clients analyser des messages d’erreur en anglais.
Conception d’erreurs en couches pratique
Voici un modèle propre pour de nombreux services backend Go.
Couche dépôt
- Parle à la base de données ou au stockage externe.
- Convertit les erreurs “introuvable” spécifiques au stockage en erreurs de domaine.
- Enveloppe les erreurs de stockage inattendues avec le contexte d’opération.
- Ne renvoie pas d’erreurs HTTP.
- Ne journalise généralement pas.
Exemple :
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Couche service
- Possède les règles métier.
- Crée des erreurs de domaine.
- Préserve les erreurs de domaine connues.
- Enveloppe les erreurs de bas niveau inattendues.
- Ne renvoie pas de codes de statut HTTP.
- Ne journalise généralement pas.
Exemple :
if user.Disabled {
return nil, ErrAccountDisabled
}
Couche transport
- Mappe les erreurs de domaine vers les réponses HTTP, gRPC ou CLI.
- Journalise les erreurs non gérées ou inattendues.
- Cache les détails internes aux utilisateurs.
- Définit les codes de statut et les codes d’erreur d’API.
Exemple :
switch {
case errors.Is(err, ErrUserNotFound):
writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
Cette séparation maintient la gestion des erreurs compréhensible et permet à chaque couche d’évoluer indépendamment — vous pouvez changer la technologie de stockage sans toucher à la logique de service ou à la cartographie du transport. La conception en couches fonctionne le mieux lorsque les dépendances sont injectées plutôt qu’harcodées ; Injection de Dépendances en Go : Modèles et Meilleures Pratiques couvre les modèles de constructeur et d’interface qui rendent chaque limite facile à tester de manière isolée.
Exemple complet
Voici un petit exemple de bout en bout.
Erreurs de domaine :
package users
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("duplicate email")
ErrAccountDisabled = errors.New("account disabled")
)
Dépôt :
package users
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name, disabled
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Disabled,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Service :
package users
import (
"context"
"errors"
"fmt"
)
type Service struct {
repo *Repository
}
func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get profile for user %s: %w", id, err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
return &Profile{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}
Gestionnaire HTTP :
package httpapi
import (
"context"
"errors"
"net/http"
"example.com/app/users"
)
type Handler struct {
users *users.Service
}
func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
if err != nil {
h.writeError(w, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
func (h *Handler) writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, users.ErrUserNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{
"code": "user_not_found",
"message": "user not found",
})
case errors.Is(err, users.ErrAccountDisabled):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "account_disabled",
"message": "account is disabled",
})
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusGatewayTimeout, map[string]string{
"code": "request_timeout",
"message": "request timed out",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
}
Cette structure vous donne :
- erreurs de domaine
- traduction du stockage
- contexte du service
- cartographie HTTP sûre
- chaînes d’erreurs inspectables
- pas de correspondance de chaînes
- pas de fuite de transport dans le code de domaine
C’est le type d’architecture d’erreurs qui se scale — assez simple pour qu’un nouveau contributeur puisse comprendre, mais assez structuré pour que la logique métier ne fuite jamais dans les réponses de transport.
Test du comportement des erreurs
Le comportement des erreurs devrait être testé aussi rigoureusement que le cas nominal, car les décisions aux limites — mapping sentinelle, extraction de type, codes HTTP — sont souvent là que les bugs cachent le plus longtemps. Pour un guide complet de la structure de test Go, du mocking et des modèles de couverture, voir Unit Testing en Go : Structure et Meilleures Pratiques.
Tester le mapping sentinelle
func TestGetByIDNotFound(t *testing.T) {
repo := newTestRepository(t)
_, err := repo.GetByID(t.Context(), "missing")
if !errors.Is(err, users.ErrUserNotFound) {
t.Fatalf("got %v, want ErrUserNotFound", err)
}
}
Tester l’extraction d’erreurs personnalisées
func TestValidationError(t *testing.T) {
err := ValidateCreateUser(CreateUserRequest{})
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("got %T, want ValidationError", err)
}
if len(validationErr.Fields) == 0 {
t.Fatal("expected validation fields")
}
}
Tester la cartographie HTTP
func TestWriteErrorNotFound(t *testing.T) {
rec := httptest.NewRecorder()
writeHTTPError(rec, users.ErrUserNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
Les tests devraient prouver que les erreurs connues produisent le bon comportement à chaque limite, de sorte que la refactorisation des couches de stockage ou de transport ne puisse pas modifier silencieusement le contrat d’échec.
Anti-modèles courants
Anti-modèle 1 : Correspondance de chaînes
Mauvais :
if strings.Contains(err.Error(), "not found") {
// ...
}
Utilisez errors.Is ou errors.As à la place — les deux gèrent automatiquement les chaînes d’erreurs enveloppées et ne cassent pas lorsque les messages sont reformattés ou localisés.
Anti-modèle 2 : Perte de la cause
Mauvais :
return errors.New("query failed")
Mieux :
return fmt.Errorf("query user: %w", err)
Anti-modèle 3 : Envelopper sans signification
Mauvais :
return fmt.Errorf("error happened: %w", err)
Enveloppez avec un contexte d’opération qui explique ce qui était tenté, comme "create invoice %s: %w" plutôt qu’un préfixe vague qui n’ajoute aucune valeur diagnostique.
Anti-modèle 4 : Journaliser à chaque couche
Mauvais :
log.Println(err)
return err
à chaque niveau. Journalisez une seule fois là où l’erreur est finalement gérée, et non à chaque couche intermédiaire qui la passe simplement vers le haut.
Anti-modèle 5 : Renvoyer des erreurs HTTP depuis le code de domaine
Mauvais :
return &APIError{Status: http.StatusNotFound}
depuis un service de domaine. Mappez les erreurs de domaine vers les codes de statut HTTP et les corps de réponse à la limite du gestionnaire, gardant votre couche de service indépendante des préoccupations de transport.
Anti-modèle 6 : Exposer les erreurs internes aux utilisateurs
Mauvais :
http.Error(w, err.Error(), http.StatusInternalServerError)
Renvoyez des messages génériques sûrs aux utilisateurs et journalisez l’erreur interne complète avec un contexte structuré pour les opérateurs. N’exposez jamais les chaînes de connexion à la base de données, les chemins de fichiers ou les traces de pile brutes dans les réponses d’API.
Anti-modèle 7 : Trop de sentinelles exportées
Les erreurs exportées font partie de l’API de votre paquet, et les ajouter vous engage à les maintenir. N’exportez pas chaque condition interne à moins que les appelants externes aient réellement besoin d’en faire une condition de branchement — préférez de garder les sentinelles non exportées jusqu’à ce qu’il y ait un besoin clair.
Anti-modèle 8 : Utiliser panic pour les échecs attendus
Mauvais :
panic(err)
pour des échecs de runtime normaux. Réservez panic pour des conditions véritablement irrécupérables ou des erreurs de programmeur, et non pour des enregistrements manquants ou des entrées utilisateur invalides — renvoyez toujours des erreurs dans ces cas.
Anti-modèle 9 : Ignorer les erreurs de contexte
Mauvais :
return fmt.Errorf("request failed")
lorsque la vraie cause était context.Canceled. Préservez les erreurs de contexte pour que les appelants puissent distinguer entre un échec d’opération réel et une requête annulée ou expirée, et répondre de manière appropriée à chacune. Pour un traitement exhaustif de la façon dont l’annulation du contexte et la propagation des délais fonctionnent à travers les couches de service, voir Go context.Context Bien Fait.
Liste de contrôle de revue des erreurs
Utilisez cette liste de contrôle lors de la revue de code.
Création d’erreurs
- S’agit-il d’une condition connue ?
- Devrait-elle être une sentinelle ?
- A-t-elle besoin de données structurées ?
- Devrait-elle être un type personnalisé ?
- Le message d’erreur est-il clair ?
Enveloppement des erreurs
- L’enveloppement ajoute-t-il un contexte d’opération utile ?
- Le
%wpréserve-t-il la cause là où nécessaire ? - Le code expose-t-il accidentellement des détails d’implémentation ?
- La chaîne est-elle trop bruyante ?
Traduction des erreurs
- Une erreur de bas niveau est-elle traduite à la bonne limite ?
- Le comportement spécifique à la base de données est-il caché du code de service ?
- Les erreurs de domaine sont-elles indépendantes des préoccupations HTTP ou CLI ?
Gestion des erreurs
- L’appelant fait-il du branchement avec
errors.Isouerrors.As? - L’annulation du contexte et les délais sont-ils gérés correctement ?
- Les erreurs pouvant faire l’objet d’une nouvelle tentative sont-elles identifiées explicitement ?
- Les erreurs de validation sont-elles structurées ?
Journalisation
- L’erreur est-elle journalisée une seule fois, à la limite de gestion ?
- Les journaux sont-ils structurés ?
- Les détails sensibles sont-ils exclus des réponses utilisateur ?
- Y a-t-il suffisamment de contexte pour les opérateurs ?
Test
- Les cas d’erreurs connus sont-ils testés ?
- Les cartographies HTTP ou CLI sont-elles testées ?
- Les détails de validation sont-ils testés ?
- Les décisions de nouvelle tentative sont-elles testées ?
Mes règles opinionnées
Règle 1 : Les erreurs doivent traverser les limites avec un sens
Ne vous contentez pas de passer les erreurs. Décidez ce qu’elles signifient à chaque couche.
Règle 2 : Envelopper pour le contexte, pas pour la décoration
Si l’enveloppement n’ajoute pas d’informations utiles sur l’opération qui a échoué, n’enveloppez pas. Une couche supplémentaire de contexte sans sens rend la chaîne d’erreurs plus difficile à lire et n’ajoute aucune valeur diagnostique.
Règle 3 : Traduire les erreurs d’implémentation en erreurs de domaine
Ne laissez pas sql.ErrNoRows devenir partie de votre logique métier. Traduisez les erreurs d’implémentation en erreurs de domaine à la limite de stockage, pour que le reste de l’application n’ait jamais besoin de savoir quelle base de données ou quel ORM est en dessous.
Règle 4 : Ne pas analyser les chaînes d’erreurs
Si le code a besoin de faire du branchement sur le type d’échec, utilisez des sentinelles, des types personnalisés, errors.Is ou errors.As. L’inspection de chaînes crée un couplage invisible qui casse silencieusement lorsque les messages d’erreur changent.
Règle 5 : Journaliser une seule fois
Enveloppez à mesure que les erreurs montent. Journalisez là où l’erreur est finalement gérée.
Règle 6 : Garder les messages utilisateur sûrs
Les erreurs de diagnostic internes sont pour les journaux. Les messages destinés à l’utilisateur sont pour les utilisateurs.
Règle 7 : Garder les erreurs de transport à la limite de transport
Les codes de statut HTTP appartiennent aux gestionnaires ou aux adaptateurs d’API, et non aux services de domaine. Le code de domaine devrait être réutilisable à travers les transports — aujourd’hui HTTP, demain CLI, gRPC, ou un worker piloté par événements.
Réflexions finales
La gestion des erreurs en Go ne consiste pas à écrire if err != nil pour toujours — cela consiste à rendre l’échec explicite et compréhensible à chaque limite.
Les mécaniques sont simples :
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
L’architecture est la partie plus difficile :
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
C’est ce que c’est la gestion des erreurs en Go bien faite — pas astucieux, pas magique, mais assez clair pour que le prochain développeur, opérateur, client d’API, et vous-même à l’avenir puissiez comprendre ce qui a échoué et ce qui devrait se passer ensuite. Pour une vue plus large des modèles Go de production à travers l’intégration, le test et l’accès aux données, voir Architecture d’Application en Production.