Architettura della Gestione degli Errori in Go: Confini e Pattern

Gestire gli errori al confine appropriato.

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
}

Non è questa 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 (logged) e cosa dovrebbe essere esposto al chiamante — questa è una questione di architettura.

Go tratta gli errori come valori. Questo rende i fallimenti espliciti. Significa anche che la tua codebase ha bisogno di un design chiaro per la gestione degli errori. Senza di esso, gli errori diventano stringhe casuali, i gestori HTTP (handlers) filtrano dettagli del database, i log duplicano lo stesso fallimento cinque volte, i ritentativi 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 layer

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 (error boundaries), mappatura delle API, logging, ritentativi, sicurezza e pattern per la produzione.

La versione leggermente opinionata: non cercare di far scomparire gli errori di 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 e non c’è uno stack unwinding nascosto. Il fallimento è parte della firma della funzione.

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

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

I tre compiti di un errore

Un errore utile di solito ha uno o più compiti.

Compito 1: Spiegare cosa è fallito

Per gli esseri 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 questa è rilevante.

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.

Compito 3: Consentire a un confine di prendere una decisione

A un certo confine, il programma deve decidere cosa fare.

Esempi:

  • Restituire HTTP 404
  • Restituire HTTP 409
  • Rientrare nell’operazione (Retry)
  • Registrare nei log 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 sul confronto di stringhe.

I principali strumenti per gli errori nel Go moderno

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

errors.New

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

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

Questo è utile per le sentinelle di errore.

fmt.Errorf con %w

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

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

L’avvolgimento aggiunge contesto preservando l’errore originale per l’ispezione.

errors.Is

Usa errors.Is per verificare se un errore corrisponde a un target specifico da qualche parte nella sua catena:

if errors.Is(err, ErrNotFound) {
	// gestisci "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) {
	// usa validationErr.Field o validationErr.Reason
}

Usa questo quando l’errore contiene dati strutturati.

errors.Join

Usa errors.Join quando si verificano 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.

Usalo 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 bisogno solo di sapere quale 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 un handler può verificare:

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

Quando usare gli errori sentinella

Usa gli errori sentinella quando:

  • La condizione è stabile.
  • Il chiamante deve fare un branch su di essa.
  • Non sono necessari dati strutturati aggiuntivi.
  • L’errore appartiene al tuo pacchetto o dominio.

Buoni esempi:

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.

Male:

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 un branch su 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 trasporta 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 è migliore rispetto all’analisi di una stringa di errore.

Quando usare i tipi di errore personalizzati

Usa i 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 più valori in modo diverso.

Esempi:

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

Quando non usare i tipi di errore personalizzati

Non creare tipi personalizzati solo per evitare errors.New.

Questo è inutile:

type NotFoundError struct{}

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

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

Avvolgimento degli errori (Error wrapping)

L’avvolgimento 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 ottiene entrambi:

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

Entrambi sono disponibili attraverso la catena di errori, il che rende l’avvolgimento con %w degno di essere fatto in modo coerente.

Avvolgi con un contesto utile

Un buon avvolgimento dice quale operazione è fallita:

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

Un cattivo avvolgimento aggiunge rumore:

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

Questo non dice nulla al chiamante.

Evita anche di ripetere lo stesso sostantivo a ogni layer:

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

Questo tipo di catena è tecnicamente corretta ma praticamente fastidiosa.

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

Quando avvolgere e quando non avvolgere

Questa è una delle decisioni architetturali più importanti.

Avvolgi quando si attraversa un confine significativo

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

Non avvolgere solo per dire “fallito”

Male:

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

La parola “failed” (fallito) è di solito implicata dal fatto che esiste 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 ancora preservare la causa se utile, ma fallo deliberatamente.

Non esporre dettagli di implementazione per errore

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

Questo è di solito positivo all’interno della tua applicazione.

Ma in un’API di pacchetto pubblico, l’avvolgimento 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) {
	// il chiamante ora sa che usi database/sql
}

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

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

Poi restituisci quella 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.

I confini comuni includono:

  • database al repository
  • repository al servizio
  • servizio all’handler HTTP
  • servizio al comando CLI
  • errore interno a messaggio visibile all’utente
  • fallimento transitorio a decisione di ritentativo
  • fallimento dell’operazione a evento di log
  • 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 guadagnano contesto, perdono dettagli di implementazione o vengono tradotti in una forma su cui il layer successivo può agire.

Confine del repository

Il repository parla con lo storage.

Di solito dovrebbe 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 aziendale.

Di solito dovrebbe 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 errori inaspettati.

Per regole aziendali 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 di livello aziendale — creati direttamente dalla logica di dominio piuttosto che tradotti da condizioni infrastrutturali.

Confine dell’handler HTTP

L’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.

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

L’handler mappa gli errori di dominio alle semantiche HTTP anziché esporre dettagli grezzi del database o errori interni. Questo è dove molte applicazioni Go sbagliano — o espongono troppi dettagli interni o collassano tutti gli errori in HTTP 500. Per una visione completa dei pattern degli handler e del middleware nelle API Go, Building REST APIs in Go copre autenticazione, routing e gestione degli errori attraverso la libreria standard, Gin, Echo e Fiber.

Confine CLI

Un CLI ha un confine diverso rispetto a un’API HTTP.

In un 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
	}
}

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

Pattern del 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,
	}
}

Utilizzo:

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 vuoi errori API strutturati con codici stabili.

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

Errori di dominio vs errori di trasporto

Mantieni gli errori di dominio separati dagli errori di trasporto.

Errore di dominio:

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

Mappatura del trasporto:

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

Non fare in modo che il tuo layer di dominio restituisca codici di stato HTTP:

return &APIError{Status: http.StatusConflict}

Questo accoppia la logica aziendale a HTTP e impedisce al tuo layer di servizio di funzionare pulitamente attraverso HTTP, CLI, worker, test e futuri adapter gRPC. La mappatura del trasporto appartiene al confine del trasporto, non nel codice di dominio. Per una guida su dove definire errori di dominio, sentinelle e adapter di trasporto all’interno del layout del tuo progetto, Go Project Structure: Practices & Patterns copre le convenzioni internal/, pkg/ e adapter che mantengono questi layer chiaramente separati.

Errori ritentabili (Retryable)

Alcuni errori dovrebbero innescare un ritentativo. Altri no.

Non decidere questo confrontando stringhe.

Usa un’interfaccia marcatrice 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)
}

Utilizzo:

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

Ciclo di ritentativo:

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

Questo è molto migliore rispetto a verificare se la stringa di errore contiene “timeout” — il confronto 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 spesso hanno 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"
}

Utilizzo:

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
  • spegnimento 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 controllo degli errori.

Quando non usare errors.Join

Non usare errors.Join quando c’è un errore principale e qualche contesto di logging.

Non usarlo per evitare di decidere quale errore è importante.

Non restituire enormi errori uniti agli utenti.

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

Panic non è gestione degli errori

Nel codice applicativo normale, non usare panic per errori attesi.

Male:

if err != nil {
	panic(err)
}

Usa panic per errori di programmazione o situazioni davvero irrecuperabili.

Esempi:

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

Non usare panic perché una query al database è fallita o un utente ha inserito input non valido.

Quelli sono errori normali.

Logging degli errori

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

Male:

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:

  • avvolgi gli errori mentre salgono
  • registra una volta al confine dove l’errore è gestito
  • includi 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 l’intera catena di errori. Per una configurazione di logging strutturato pronta per la produzione, Structured Logging in Go with slog copre i record log/slog, gli handler JSON, la correlazione del contesto e la redaction — tutto ciò si abbina naturalmente al logging degli errori a livello di confine.

Quando registrare nei log all’interno di layer inferiori

Registra nei log all’interno di layer inferiori solo quando il layer sta effettivamente gestendo l’errore o aggiungendo un contesto operativo importante che non sarà visibile altrove.

Ad esempio, un ciclo di ritentativo può registrare ogni tentativo di ritentativo a livello debug o warning.

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

Errori visibili all’utente vs errori per operatori

Non mostrare errori interni direttamente agli utenti.

Errore interno:

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

Messaggio visibile all’utente:

internal server error

Log per operatori:

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 mantiene separati:

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

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

Gestione sicura degli errori

Gli errori possono filtrare informazioni sensibili.

Evita di esporre:

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

Questo è particolarmente importante nelle API HTTP.

Male:

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

Buono:

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, i codici di errore stabili sono spesso migliori rispetto 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 i codici di errore per:

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

Non fare in modo che i client analizzino messaggi di errore in inglese.

Un design degli errori stratificato pratico

Ecco un pattern pulito per molti servizi backend Go.

Layer del repository

  • Parla con il database o lo storage esterno.
  • Converte gli errori di “non trovato” specifici dello storage in errori di dominio.
  • Avvolge gli errori di storage inaspettati con il contesto dell’operazione.
  • Non restituisce errori HTTP.
  • Di solito non registra nei log.

Esempio:

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

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

Layer del servizio

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

Esempio:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Layer del trasporto

  • Mappa gli errori di dominio a risposte HTTP, gRPC o CLI.
  • Registra nei log gli errori non gestiti o inaspettati.
  • Nasconde i dettagli interni dagli utenti.
  • Imposta i codici di stato e i 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 ogni layer di evolversi indipendentemente — puoi cambiare la tecnologia di storage senza toccare la logica del servizio o la mappatura del trasporto. Il design stratificato funziona meglio quando le dipendenze sono iniettate piuttosto che codificate; Dependency Injection in Go: Patterns & Best Practices copre i pattern di costruttore e interfaccia che rendono ogni confine facile da testare 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 del servizio
  • mappatura HTTP sicura
  • catene di errori ispezionabili
  • nessun confronto di stringhe
  • nessuna perdita di trasporto nel codice di dominio

È questo il tipo di architettura degli errori che scala — abbastanza semplice da essere compresa da un nuovo collaboratore, ma abbastanza strutturata che la logica di dominio non filtra mai nelle risposte di trasporto.

Test del comportamento degli errori

Il comportamento degli errori dovrebbe essere testato tanto accuratamente quanto il percorso felice, perché le decisioni ai confini — mappatura delle sentinelle, estrazione del tipo, 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 Go Unit Testing: Structure & Best Practices.

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

Test dell’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")
	}
}

Test della 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: Confronto di stringhe

Male:

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

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

Anti-pattern 2: Perdere la causa

Male:

return errors.New("query failed")

Meglio:

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

Anti-pattern 3: Avvolgimento senza significato

Male:

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

Avvolgi con il contesto dell’operazione che spiega cosa si stava tentando, come "create invoice %s: %w" piuttosto che un prefetto vago che non aggiunge valore diagnostico.

Anti-pattern 4: Logging a ogni layer

Male:

log.Println(err)
return err

a ogni livello. Registra nei log una volta dove l’errore è finalmente gestito, non a ogni layer intermedio che semplicemente lo passa verso l’alto.

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

Male:

return &APIError{Status: http.StatusNotFound}

da un servizio di dominio. Mappa gli errori di dominio ai codici di stato HTTP e ai corpi di risposta al confine dell’handler, mantenendo il tuo layer di servizio indipendente dalle preoccupazioni di trasporto.

Anti-pattern 6: Esposizione di errori interni agli utenti

Male:

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

Restituisci 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 di file o stack trace grezzi nelle risposte API.

Anti-pattern 7: Troppi 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 davvero bisogno di fare un branch su di essa — preferisci mantenere le sentinelle non esportate finché non c’è un bisogno chiaro.

Anti-pattern 8: Uso di panic per fallimenti attesi

Male:

panic(err)

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

Anti-pattern 9: Ignorare gli errori di contesto

Male:

return fmt.Errorf("request failed")

quando la causa reale era context.Canceled. Preserva gli errori di contesto in modo che i chiamanti possano distinguere tra un fallimento genuino dell’operazione e una richiesta annullata o scaduta, e rispondere appropriatamente a ciascuno.

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?

Avvolgimento degli errori

  • 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 di basso livello è tradotto al confine giusto?
  • Il comportamento specifico del database è nascosto dal codice del servizio?
  • Gli errori di dominio sono indipendenti dalle preoccupazioni HTTP o CLI?

Gestione degli errori

  • Il chiamante fa un branch con errors.Is o errors.As?
  • L’annullamento del contesto e le scadenze sono gestite correttamente?
  • Gli errori ritentabili 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 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 ritentativo sono testate?

Le mie regole opinionate

Regola 1: Gli errori dovrebbero attraversare i confini con significato

Non passare semplicemente errori avanti e indietro. Decidi cosa significano a ogni layer.

Regola 2: Avvolgi per il contesto, non per la decorazione

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

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

Non lasciare che sql.ErrNoRows diventi parte della tua logica aziendale. 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 si trova sotto.

Regola 4: Non analizzare le stringhe di errore

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

Regola 5: Registra nei log una volta

Avvolgi mentre gli errori salgono. Registra nei log dove l’errore è finalmente gestito.

Regola 6: Mantieni i messaggi utente sicuri

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

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

I codici di stato HTTP appartengono agli handler o agli adapter API, non ai servizi di dominio. Il codice di dominio dovrebbe essere riutilizzabile attraverso i trasporti — oggi HTTP, domani CLI, gRPC o un worker guidato da eventi.

Pensieri finali

La gestione degli errori in Go non riguarda scrivere if err != nil per sempre — si tratta di 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 ingegnosa, non magica, ma足够mente chiara che il prossimo sviluppatore, operatore, client API e il tuo futuro sé possano capire cosa è fallito e cosa dovrebbe accadere dopo. Per una visione più ampia dei pattern Go di produzione attraverso integrazione, testing e accesso ai dati, vedi App Architecture in Production.

Fonti

Iscriviti

Ricevi nuovi articoli su sistemi, infrastruttura e ingegneria AI.