Architettura della gestione degli errori in Go: confini e pattern

Gestisci gli errori al confine corretto.

Indice

La gestione degli errori in Go è facile da criticare. Ogni sviluppatore Go ha scritto questo codice centinaia di volte:

if err != nil {
	return err
}

Questa non è la parte interessante. La parte interessante è cosa significa l’errore, dove dovrebbe essere gestito, dove dovrebbe essere avvolto (wrapped), dove dovrebbe essere tradotto, dove dovrebbe essere registrato nei log e cosa dovrebbe essere esposto al chiamante — questa è la domanda architetturale.

Go tratta gli errori come valori. Questo rende i fallimenti espliciti. Significa anche che la tua base di codice ha bisogno di un chiaro design della gestione degli errori. Senza di esso, gli errori diventano stringhe casuali, gli handler HTTP fuoriescono dettagli del database, i log duplicano lo stesso fallimento cinque volte, i retry avvengono per i motivi sbagliati e i chiamanti ispezionano il testo invece del comportamento.

Architettura della gestione degli errori in Go: errori che fluiscono tra i livelli

Questo articolo non è un’introduzione per principianti a if err != nil.

È una guida pratica all’architettura della gestione degli errori in Go: wrapping, sentinelle, tipi di errore personalizzati, errors.Is, errors.As, confini degli errori, mappatura API, logging, retry, sicurezza e pattern di produzione.

La versione leggermente opinabile: non provare a far scomparire gli errori in Go. Rendili significativi al confine giusto.

Cosa sono gli errori in Go

In Go, un errore è semplicemente un valore che implementa questa interfaccia:

type error interface {
	Error() string
}

Questa piccola interfaccia è il motivo per cui la gestione degli errori in Go sembra così diretta.

Le funzioni restituiscono errori esplicitamente:

func LoadUser(id string) (*User, error) {
	// ...
}

I chiamanti decidono cosa fare:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

Non ci sono eccezioni né unwind dello stack nascosti. Il fallimento è parte della firma della funzione.

Questo è un bene, ma significa anche che gli errori hanno bisogno di un design. Se ogni pacchetto restituisce messaggi arbitrari, i chiamanti non possono prendere decisioni affidabili. Se ogni livello avvolge ogni errore senza disciplina, gli operatori ricevono messaggi rumorosi e gli sviluppatori ricevono catene confuse. Se nessun livello avvolge gli errori, i fallimenti perdono contesto.

L’obiettivo non è meno gestione degli errori, ma un significato migliore degli errori.

I tre compiti di un errore

Un errore utile ha solitamente uno o più compiti.

Compito 1: Spiegare cosa è fallito

Per gli umani, l’errore dovrebbe spiegare quale operazione è fallita.

Esempio:

return fmt.Errorf("load user %s: %w", id, err)

Questo fornisce contesto. Dice che il fallimento è avvenuto durante il caricamento di un utente.

Compito 2: Preservare la causa

Per il codice, l’errore dovrebbe preservare la causa sottostante quando quella causa è importante.

Esempio:

return fmt.Errorf("load user %s: %w", id, err)

Il %w avvolge l’errore originale in modo che i chiamanti possano ispezionarlo con errors.Is o errors.As. Il wrapping ha una nuova insidia in Go 1.27: il controllo vet dietro go test ora avvisa su fmt.Errorf("...: %w", p) quando p è un puntatore a un tipo che stesso implementa error, perché l’indirezione extra rompe le asserzioni di tipo e l’abbinamento di errors.Is. La Guida all’aggiornamento a Go 1.27 copre questo e le altre segnalazioni vet che emergono dopo un aggiornamento della toolchain.

Compito 3: Lasciare che un confine prenda una decisione

In qualche confine, il programma deve decidere cosa fare.

Esempi:

  • Restituire HTTP 404
  • Restituire HTTP 409
  • Riprovare l’operazione (retry)
  • Registrare a livello di warning
  • Mostrare un messaggio sicuro per l’utente
  • Interrompere la transazione
  • Inviare l’errore al monitoraggio
  • Ignorare l’annullamento

Questa decisione dovrebbe basarsi solitamente sull’identità o sul tipo di errore, non sull’abbinamento di stringhe.

I principali strumenti per gli errori nel Go moderno

Il Go moderno ti fornisce un piccolo ma potente set di strumenti.

errors.New

Usa errors.New per creare un valore di errore semplice:

var ErrNotFound = errors.New("not found")

Questo è utile per gli errori sentinella.

fmt.Errorf con %w

Usa fmt.Errorf con %w per avvolgere un errore:

return fmt.Errorf("query user: %w", err)

Il wrapping aggiunge contesto preservando l’errore originale per l’ispezione.

errors.Is

Usa errors.Is per verificare se un errore corrisponde a un target specifico in qualche punto della sua catena:

if errors.Is(err, ErrNotFound) {
	// handle not found
}

Usa questo per errori sentinella e condizioni note.

errors.As

Usa errors.As per estrarre un tipo di errore specifico da una catena:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// use validationErr.Field or validationErr.Reason
}

Usa questo quando l’errore porta dati strutturati.

errors.Join

Usa errors.Join quando si sono verificati più errori e tutti dovrebbero essere preservati:

return errors.Join(closeErr, flushErr)

Gli errori uniti possono comunque essere ispezionati con errors.Is e errors.As.

Usa questo con cautela. Un errore unito significa che diversi fallimenti fanno parte di un unico risultato.

Errori sentinella

Un errore sentinella è un valore di errore a livello di pacchetto che rappresenta una condizione nota.

Esempio:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

Gli errori sentinella sono utili quando il chiamante ha solo bisogno di sapere che categoria di fallimento si è verificata.

Esempio:

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
}

Poi un servizio o uno handler può verificare:

if errors.Is(err, ErrUserNotFound) {
	// return 404
}

Quando usare gli errori sentinella

Usa gli errori sentinella quando:

  • La condizione è stabile.
  • Il chiamante deve fare un branch (ramificazione) in base ad essa.
  • Non sono necessari dati strutturati extra.
  • L’errore appartiene al tuo pacchetto o dominio.

Esempi buoni:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

Quando non usare gli errori sentinella

Non creare sentinelle per ogni possibile fallimento.

Sbagliato:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

Se i chiamanti non fanno branch in base a questi, potrebbero essere solo messaggi.

Fai anche attenzione a esportare troppe sentinelle. Gli errori sentinella esportati diventano parte dell’API del tuo pacchetto.

Tipi di errore personalizzati

Un tipo di errore personalizzato è utile quando l’errore porta informazioni strutturate.

Esempio:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}

Chiamante:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

Questo è meglio che parsare una stringa di errore.

Quando usare tipi di errore personalizzati

Usa tipi di errore personalizzati quando:

  • I chiamanti hanno bisogno di dati strutturati.
  • L’errore ha campi significativi.
  • Il tipo fa parte del contratto del tuo pacchetto.
  • Il chiamante potrebbe dover gestire valori diversi in modo differente.

Esempi:

  • Errore di validazione con nome del campo
  • Errore di limite di velocità (rate limit) con tempo di retry
  • Errore HTTP con codice di stato
  • Errore di parsing con riga e colonna
  • Errore di dominio con ID della risorsa

Quando non usare tipi di errore personalizzati

Non creare tipi personalizzati solo per evitare errors.New.

Questo non è necessario:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

Se non ci sono dati utili, una sentinella è spesso sufficiente.

Error wrapping

Il wrapping aggiunge contesto a un errore preservando l’errore originale.

Esempio:

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
}

Se os.ReadFile fallisce, il chiamante riceve entrambi:

  • l’operazione ad alto livello: lettura configurazione
  • la causa a basso livello: permesso negato, file non trovato, ecc.

Entrambi sono disponibili attraverso la catena di errori, ed è questo che rende il wrapping con %w utile da fare in modo coerente.

Avvolgi con un contesto utile

Un buon wrapping dice quale operazione è fallita:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

Un cattivo wrapping aggiunge rumore:

return fmt.Errorf("error: %w", err)

Questo non dice nulla al chiamante.

Evita anche di ripetere lo stesso sostantivo a ogni livello:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

Questo tipo di catena è tecnicamente corretto e praticamente fastidioso.

Avvolgi dove il contesto cambia significato. Se non riesci a spiegare in una frase quale operazione è fallita, probabilmente stai avvolgendo troppo aggressivamente o non abbastanza.

Quando avvolgere e quando non avvolgere

Questo è una delle decisioni architetturali più importanti.

Avvolgi quando si attraversa un confine significativo

Avvolgi quando l’errore si sposta da un’operazione a un’operazione di livello superiore.

Esempio:

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’errore del repository diventa ora parte di un’operazione di servizio, e quel contesto aggiunto è utile quando gli operatori tracciano un fallimento indietro attraverso i log.

Non avvolgere solo per dire “fallito”

Sbagliato:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

La parola “failed” è solitamente implicita dal fatto che esista un errore.

Non avvolgere se stai traducendo

A volte dovresti tradurre un errore in un altro errore di dominio.

Esempio:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

Questo nasconde intenzionalmente il dettaglio del database ed espone una condizione di dominio.

Potresti comunque preservare la causa se utile, ma fallo deliberatamente.

Non esporre accidentalmente i dettagli di implementazione

Se avvolgi un errore a basso livello con %w, i chiamanti possono ispezionarlo.

Questo è solitamente un bene all’interno della tua applicazione.

Ma in un’API di pacchetto pubblica, il wrapping potrebbe esporre dettagli di implementazione come parte del tuo contratto.

Ad esempio, se il tuo pacchetto avvolge sql.ErrNoRows, i chiamanti potrebbero iniziare a dipenderne:

if errors.Is(err, sql.ErrNoRows) {
	// caller now knows you use database/sql
}

Se potresti cambiare lo storage in futuro, preferisci una sentinella di dominio:

var ErrUserNotFound = errors.New("user not found")

Poi restituiscilo dal confine del pacchetto.

Confini degli errori (Error boundaries)

Il modo più utile per pensare alla gestione degli errori in Go è attraverso i confini.

Un confine è un luogo in cui un errore cambia significato o pubblico di riferimento.

I confini comuni includono:

  • da database a repository
  • da repository a servizio
  • da servizio a handler HTTP
  • da servizio a comando CLI
  • da errore interno a messaggio per l’utente
  • da fallimento transitorio a decisione di retry
  • da fallimento di operazione a evento di log
  • da errore di dominio a risposta API

L’architettura degli errori è per lo più design dei confini. Ogni confine è un punto di decisione in cui gli errori acquisiscono contesto, perdono dettagli di implementazione o vengono tradotti in una forma su cui il livello successivo può agire.

Confine del Repository

Il repository parla con lo storage.

Dovrebbe solitamente tradurre gli errori specifici del database in errori di dominio.

Esempio:

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
}

Il repository nasconde sql.ErrNoRows ed espone ErrUserNotFound — un confine pulito che significa che il servizio non ha bisogno di sapere nulla su come lo storage rappresenta “non trovato”.

Confine del Servizio

Il servizio possiede il significato di business.

Dovrebbe solitamente aggiungere il contesto dell’operazione e preservare gli errori di dominio.

Esempio:

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
}

Questo preserva la condizione di dominio aggiungendo contesto per gli errori inaspettati.

Per regole di business più complesse, il servizio può creare errori di dominio direttamente:

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
}

Il servizio è il posto giusto per gli errori a livello di business — creati direttamente dalla logica di dominio anziché tradotti da condizioni di infrastruttura.

Confine dello Handler HTTP

Lo handler HTTP traduce gli errori dell’applicazione in risposte HTTP.

Questo è un confine in cui i dettagli interni dovrebbero diventare risposte sicure per l’utente.

I framework Web differiscono per come strutturano questa traduzione. Echo instrada gli errori dello handler attraverso un unico HTTPErrorHandler, mentre gli handler di Gin renderizzano gli errori direttamente sul contesto della richiesta — i tradeoff tra questi due stili sono confrontati in Framework Web Go nel 2026: net/http, chi, Gin, Echo, Fiber Confrontati.

Esempio:

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)
	}
}

Mappatura degli errori:

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)
	}
}

Lo handler mappa gli errori di dominio nelle semantica HTTP invece di esporre dettagli grezzi del database o errori interni. È qui che molte applicazioni Go sbagliano — o espone troppo dettaglio interno o collassa tutti gli errori in HTTP 500. Per un quadro completo dei pattern degli handler e middleware nelle API Go, Costruire API REST in Go copre autenticazione, routing e gestione degli errori attraverso la libreria standard, Gin, Echo e Fiber.

Confine CLI

Una CLI ha un confine diverso da un’API HTTP.

In una CLI, l’errore dovrebbe essere utile alla persona che esegue il comando.

Esempio:

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
}

Al confine del comando:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

Mappa gli errori noti ai codici di uscita:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

Una CLI può spesso mostrare più dettagli di un’API pubblica, ma dovrebbe comunque evitare di fuoriuscire segreti.

Pattern di tipo di errore API

Per le API HTTP, un piccolo tipo di errore a livello di app può essere utile.

Esempio:

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
}

Costruttore:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

Uso:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

Handler:

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",
	})
}

Questo pattern è utile quando si vogliono errori API strutturati con codici stabili.

Usalo al confine API. Non costringere ogni pacchetto interno a restituire errori specifici per l’API.

Errori di dominio vs errori di trasporto

Mantieni separati gli errori di dominio dagli errori di trasporto.

Errore di dominio:

var ErrInsufficientBalance = errors.New("insufficient balance")

Mappatura di trasporto:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

Non far restituire codici di stato HTTP al tuo livello di dominio:

return &APIError{Status: http.StatusConflict}

Questo accoppia la logica di business a HTTP e impedisce al tuo livello di servizio di funzionare pulitamente attraverso HTTP, CLI, worker, test e futuri adapter gRPC. La mappatura di trasporto appartiene al confine di trasporto, non nel codice di dominio. Per indicazioni su dove definire errori di dominio, sentinelle e adapter di trasporto all’interno della struttura del tuo progetto, Struttura del Progetto Go: Pratiche & Pattern copre le convenzioni internal/, pkg/ e adapter che mantengono questi livelli ben separati.

Errori riprovabili (Retryable)

Alcuni errori dovrebbero innescare un retry. Altri no.

Non decidere questo abbinando stringhe.

Usa un’interfaccia marker o una funzione esplicita.

Esempio:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

func (e *RetryableError) Unwrap() error {
	return e.Err
}

Helper:

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)
}

Uso:

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)
}

Loop di retry:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// retry with backoff
	}
	return err
}

Questo è molto meglio che verificare se la stringa di errore contiene “timeout” — l’abbinamento di stringhe si rompe silenziosamente quando i messaggi cambiano e crea un accoppiamento invisibile tra produttore e consumatore.

Errori di validazione

Gli errori di validazione hanno spesso bisogno di dati strutturati.

Esempio:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

Uso:

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
}

Handler:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

Questo è un buon uso di errors.As perché il chiamante ha bisogno di informazioni strutturate — nomi dei campi e messaggi di validazione — non solo una stringa di errore opaca.

Errori multipli

A volte diverse cose falliscono.

Esempi:

  • chiusura di più risorse
  • validazione di molti campi
  • arresto di più worker
  • esecuzione di controlli indipendenti
  • flush e chiusura dell’output

Usa errors.Join quando tutti gli errori dovrebbero essere preservati.

Esempio:

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...)
}

Chiamante:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

Sia errors.Is che errors.As possono ispezionare errori uniti, il che significa che i valori di errore uniti rimangono pienamente compatibili con i pattern standard di verifica degli errori.

Quando non usare errors.Join

Non usare errors.Join quando c’è un errore primario e del contesto di logging.

Non usarlo per evitare di decidere quale errore conta.

Non restituire enormi errori uniti agli utenti.

Gli errori uniti sono utili, ma possono diventare rumorosi rapidamente.

Panic non è gestione degli errori

Nel codice di applicazione normale, non usare panic per errori attesi.

Sbagliato:

if err != nil {
	panic(err)
}

Usa panic per errori di programmazione o situazioni veramente irrecuperabili.

Esempi:

  • violazione impossibile di un’invariante interna
  • inizializzazione del pacchetto non valida
  • fallimento di un helper di test con t.Fatal o panic in casi limitati
  • errore di configurazione di avvio irrecuperabile, a seconda dello stile

Non fare panic perché una query al database è fallita o un utente ha inserito dati non validi.

Quelli sono normali errori.

Logging degli errori

Un errore comune in Go è registrare lo stesso errore a ogni livello.

Sbagliato:

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
}

Questo crea log duplicati per un singolo fallimento.

Meglio:

  • avvolgere gli errori man mano che salgono
  • registrare una volta al confine in cui l’errore è gestito
  • includere contesto strutturato nel log

Esempio:

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,
	)
}

Questo fornisce un evento di log con la catena di errori completa. Per una configurazione di logging strutturato pronta per la produzione, Logging Strutturato in Go con slog copre record log/slog, handler JSON, correlazione di contesto e redazione — tutti elementi che si abbinano naturalmente al logging degli errori a livello di confine.

Quando loggare nei livelli inferiori

Logga nei livelli inferiori solo quando il livello sta effettivamente gestendo l’errore o sta aggiungendo un contesto operativo importante che non sarà visibile altrove.

Ad esempio, un loop di retry potrebbe registrare ogni tentativo di retry a livello debug o warning.

Ma un repository non dovrebbe registrare ogni errore di query se lo handler registrerà il fallimento finale della richiesta.

Errori per l’utente vs errori per l’operatore

Non mostrare direttamente errori interni agli utenti.

Errore interno:

query user by id: dial tcp 10.0.4.12:5432: connection refused

Messaggio per l’utente:

internal server error

Log per l’operatore:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

Questi sono pubblici diversi, e una buona architettura degli errori li tiene separati:

  • errore diagnostico interno
  • risposta sicura per l’utente
  • codice di errore API stabile
  • contesto di log per l’operatore

Forzare una stringa di errore a servire tutti questi pubblici produce o un rischio di esposizione o un incubo di debugging. Progetta la tua architettura degli errori attorno a valori distinti per consumatori distinti.

Gestione sicura degli errori

Gli errori possono fuoriuscire informazioni sensibili.

Evita di esporre:

  • stringhe di connessione al database
  • query SQL con segreti
  • hostnames interni
  • percorsi file
  • token di accesso
  • chiavi API
  • stack trace
  • dati privati dei clienti
  • dettagli delle policy di autorizzazione

Questo è particolarmente importante nelle API HTTP.

Sbagliato:

http.Error(w, err.Error(), http.StatusInternalServerError)

Giusto:

http.Error(w, "internal server error", http.StatusInternalServerError)

Registra l’errore interno in modo sicuro per gli operatori. Restituisci un messaggio sicuro all’utente.

Codici di errore

Per le API pubbliche, codici di errore stabili sono spesso migliori che affidarsi solo ai messaggi.

Esempio di risposta:

{
  "code": "user_not_found",
  "message": "user not found"
}

Il messaggio può cambiare. Il codice dovrebbe essere stabile.

Usa codici di errore per:

  • comportamento del client
  • documentazione
  • SDK
  • localizzazione
  • diagnostica di supporto

Non far parsare ai client messaggi di errore in inglese.

Un design degli errori a livelli pratico

Ecco un pattern pulito per molti servizi backend in Go.

Livello Repository

  • Parla con database o storage esterno.
  • Converte errori di storage specifici di “non trovato” in errori di dominio.
  • Avvolge errori di storage inaspettati con contesto operativo.
  • Non restituisce errori HTTP.
  • Solitamente non registra nei log.

Esempio:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

Livello Servizio

  • Possiede le regole di business.
  • Crea errori di dominio.
  • Preserva errori di dominio noti.
  • Avvolge errori di livello inferiore inaspettati.
  • Non restituisce codici di stato HTTP.
  • Solitamente non registra nei log.

Esempio:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Livello Trasporto

  • Mappa errori di dominio a risposte HTTP, gRPC o CLI.
  • Registra errori non gestiti o inaspettati.
  • Nasconde dettagli interni agli utenti.
  • Imposta codici di stato e codici di errore API.

Esempio:

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")
}

Questa separazione mantiene la gestione degli errori comprensibile e permette a ciascun livello di evolvere indipendentemente — puoi cambiare la tecnologia di storage senza toccare la logica di servizio o la mappatura di trasporto. Il design a livelli funziona meglio quando le dipendenze sono iniettate anziché codificate; Iniezione di Dipendenze in Go: Pattern & Best Practices copre i pattern di costruttore e interfaccia che rendono facile testare ogni confine in isolamento.

Esempio completo

Ecco un piccolo esempio end-to-end.

Errori di dominio:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

Repository:

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
}

Servizio:

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
}

Handler 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",
		})
	}
}

Questa struttura ti fornisce:

  • errori di dominio
  • traduzione dello storage
  • contesto di servizio
  • mappatura HTTP sicura
  • catene di errori ispezionabili
  • nessun abbinamento di stringhe
  • nessuna fuoriuscita di trasporto nel codice di dominio

Questo è il tipo di architettura degli errori che scala — abbastanza straightforward per essere compreso da un nuovo collaboratore, ma abbastanza strutturata che la logica di dominio non mai fuoriesce nelle risposte di trasporto.

Test del comportamento degli errori

Il comportamento degli errori dovrebbe essere testato così a fondo come il percorso felice (happy path), perché le decisioni sui confini — mappatura delle sentinelle, estrazione dei tipi, codici HTTP — sono spesso dove i bug nascondono più a lungo. Per una guida completa alla struttura dei test Go, mocking e pattern di copertura, vedi Unit Testing in Go: Struttura & Best Practices.

Testare la mappatura delle 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)
	}
}

Testare l’estrazione di errori personalizzati

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")
	}
}

Testare la mappatura 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)
	}
}

I test dovrebbero dimostrare che gli errori noti producono il comportamento corretto a ogni confine, in modo che il refactoring dei layer di storage o trasporto non possa cambiare silenziosamente il contratto di fallimento.

Anti-pattern comuni

Anti-pattern 1: Abbinamento di stringhe

Sbagliato:

if strings.Contains(err.Error(), "not found") {
	// ...
}

Usa errors.Is o errors.As invece — entrambi gestiscono automaticamente le catene di errori avvolti e non si rompono quando i messaggi vengono riformattati o localizzati.

Anti-pattern 2: Perdere la causa

Sbagliato:

return errors.New("query failed")

Meglio:

return fmt.Errorf("query user: %w", err)

Anti-pattern 3: Avvolgere senza significato

Sbagliato:

return fmt.Errorf("error happened: %w", err)

Avvolgi con un contesto operativo che spiega cosa stava per essere tentato, come "create invoice %s: %w" invece di un prefisso vago che non aggiunge alcun valore diagnostico.

Anti-pattern 4: Logging a ogni livello

Sbagliato:

log.Println(err)
return err

a ogni livello. Registra una volta dove l’errore viene infine gestito, non a ogni livello intermedio che lo passa semplicemente verso l’alto.

Anti-pattern 5: Restituire errori HTTP dal codice di dominio

Sbagliato:

return &APIError{Status: http.StatusNotFound}

da un servizio di dominio. Mappa gli errori di dominio ai codici di stato HTTP e i corpi della risposta al confine dello handler, mantenendo il tuo livello di servizio indipendente dalle preoccupazioni di trasporto.

Anti-pattern 6: Esposizione di errori interni agli utenti

Sbagliato:

http.Error(w, err.Error(), http.StatusInternalServerError)

Rituisci messaggi generici sicuri agli utenti e registra l’errore interno completo con contesto strutturato per gli operatori. Non esporre mai stringhe di connessione al database, percorsi file o stack trace grezzi nelle risposte API.

Anti-pattern 7: Troppe sentinelle esportate

Gli errori esportati sono parte dell’API del tuo pacchetto e aggiungerli ti impegna a mantenerli. Non esportare ogni condizione interna a meno che i chiamanti esterni non abbiano realmente bisogno di fare branch in base ad essa — preferisci mantenere le sentinelle non esportate fino a quando non c’è un bisogno chiaro.

Anti-pattern 8: Usare panic per fallimenti attesi

Sbagliato:

panic(err)

per fallimenti di runtime normali. Riserva panic per condizioni veramente irrecuperabili o errori di programmazione, non per record mancanti o input utente non valido — restituisce sempre errori in quei casi.

Anti-pattern 9: Ignorare gli errori di context

Sbagliato:

return fmt.Errorf("request failed")

quando la vera causa era context.Canceled. Preserva gli errori di context in modo che i chiamanti possano distinguere tra un vero fallimento di operazione e una richiesta annullata o scaduta, e rispondano di conseguenza. Per un trattamento approfondito di come l’annullamento del context e la propagazione dei timeout funzionano attraverso i layer di servizio, vedi Go context.Context Fatto Giusto.

Checklist di revisione degli errori

Usa questa checklist nella revisione del codice.

Creazione degli errori

  • Questa è una condizione nota?
  • Dovrebbe essere una sentinella?
  • Ha bisogno di dati strutturati?
  • Dovrebbe essere un tipo personalizzato?
  • Il messaggio di errore è chiaro?

Error wrapping

  • L’avvolgimento aggiunge un contesto operativo utile?
  • %w preserva la causa dove necessario?
  • Il codice sta esponendo accidentalmente dettagli di implementazione?
  • La catena è troppo rumorosa?

Traduzione degli errori

  • Un errore a basso livello è tradotto al confine giusto?
  • Il comportamento specifico del database è nascosto dal codice di servizio?
  • Gli errori di dominio sono indipendenti da preoccupazioni HTTP o CLI?

Gestione degli errori

  • Il chiamante fa branch con errors.Is o errors.As?
  • L’annullamento del context e i deadline sono gestiti correttamente?
  • Gli errori riprovabili sono identificati esplicitamente?
  • Gli errori di validazione sono strutturati?

Logging

  • L’errore è registrato una volta, al confine di gestione?
  • I log sono strutturati?
  • I dettagli sensibili sono esclusi dalle risposte per l’utente?
  • C’è abbastanza contesto per gli operatori?

Testing

  • I casi di errore noti sono testati?
  • Le mappature HTTP o CLI sono testate?
  • I dettagli di validazione sono testati?
  • Le decisioni di retry sono testate?

Le mie regole opinabili

Regola 1: Gli errori dovrebbero attraversare i confini con significato

Non limitarti a passare gli errori. Decidi cosa significano a ogni livello.

Regola 2: Avvolgi per contesto, non per decorazione

Se l’avvolgimento non aggiunge informazioni utili su quale operazione è fallita, non avvolgere. Un ulteriore livello di contesto senza significato rende la catena di errori più difficile da leggere e non aggiunge alcun valore diagnostico.

Regola 3: Tradurre gli errori di implementazione in errori di dominio

Non lasciare che sql.ErrNoRows diventi parte della tua logica di business. Traduci gli errori di implementazione in errori di dominio al confine dello storage, in modo che il resto dell’applicazione non abbia mai bisogno di sapere quale database o ORM ci sia sotto.

Regola 4: Non parsare le stringhe di errore

Se il codice deve fare branch in base al tipo di fallimento, usa sentinelle, tipi personalizzati, errors.Is o errors.As. L’ispezione di stringhe crea un accoppiamento invisibile che si rompe silenziosamente quando i messaggi di errore cambiano.

Regola 5: Logga una volta

Avvolgi man mano che gli errori salgono. Registra dove l’errore viene infine gestito.

Regola 6: Mantieni i messaggi per l’utente sicuri

Gli errori diagnostici interni sono per i log. I messaggi per l’utente sono per gli utenti.

Regola 7: Mantieni gli errori di trasporto al confine di trasporto

I codici di stato HTTP appartengono agli handler o agli adapter API, non nei servizi di dominio. Il codice di dominio dovrebbe essere riutilizzabile attraverso i trasporti — oggi HTTP, domani CLI, gRPC o un worker event-driven.

Riflessioni finali

La gestione degli errori in Go non riguarda scrivere if err != nil per sempre — riguarda rendere il fallimento esplicito e comprensibile a ogni confine.

La meccanica è semplice:

return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter

L’architettura è la parte più difficile:

translate at boundaries
preserve causes
hide internals from users
log once
test known failures

Questa è la gestione degli errori in Go fatta bene — non intelligente, non magica, ma abbastanza chiara perché lo sviluppatore successivo, l’operatore, il client API e tu stesso in futuro possano capire cosa è fallito e cosa dovrebbe succedere dopo. Per una visione più ampia dei pattern Go di produzione attraverso integrazione, testing e accesso ai dati, vedi Architettura delle App in Produzione.

Fonti

Iscriviti

Ricevi nuovi articoli su sistemi, infrastruttura e ingegneria AI.