Architettura della gestione degli errori in Go: confini e pattern
Gestisci gli errori al giusto confine.
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 questione architetturale.
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, gli handler HTTP espongono 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.

Questo articolo non è un’introduzione per principianti a if err != nil.
È una guida pratica all’architettura della gestione degli errori in Go: wrapping (avvolgimento), sentinelle, tipi di errore personalizzati, errors.Is, errors.As, confini degli errori, mappatura delle API, logging, ritentativi, sicurezza e pattern per la produzione.
La versione leggermente opinione: 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 nessun unwinding (svolgimento) dello stack nascosto. Il fallimento è parte della firma della funzione.
Questo è positivo, ma significa anche che gli errori hanno bisogno di 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 ottengono 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 di solito ha 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.
Compito 3: Permettere a un confine di prendere una decisione
A un certo confine, il programma deve decidere cosa fare.
Esempi:
- Restituire HTTP 404
- Restituire HTTP 409
- Riprovare l’operazione
- Registrare nei log al livello di warning
- Mostrare un messaggio sicuro per l’utente
- Annullare 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.
Gli principali strumenti per gli errori nel Go moderno
Il Go moderno offre 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)
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 "non trovato"
}
Usalo 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
}
Usalo quando l’errore trasporta 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 ancora 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 è avvenuta.
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) {
// restituisci 404
}
Quando usare errori sentinella
Usa errori sentinella quando:
- La condizione è stabile.
- Il chiamante deve fare un branch (ramifica) 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 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 (parsing) di 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 più valori in modo diverso.
Esempi:
- Errore di validazione con nome del campo
- Errore di rate limit con tempo di ritentativo
- 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 è 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 della 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 meritevole 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 brutto avvolgimento aggiunge rumore:
return fmt.Errorf("error: %w", err)
Questo non dice nulla al chiamante.
Evita anche di ripetere lo stesso sostantivo ad ogni livello:
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Quel 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 troppo aggressivamente o non abbastanza.
Quando avvolgere e quando non avvolgere
Questa è una delle decisioni architetturali più importanti.
Avvolgi quando superi 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) è solitamente 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 deliberatamente il dettaglio del database ed espone una condizione di dominio.
Potresti ancora preservare la causa se utile, ma fallo deliberatamente.
Non esporre accidentalmente dettagli implementativi
Se avvolgi un errore di basso livello con %w, i chiamanti possono ispezionarlo.
Questo è solitamente positivo all’interno della tua applicazione.
Ma in un’API di pacchetto pubblico, l’avvolgimento può esporre dettagli implementativi come parte del tuo contratto.
Per 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 restituiscila 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 al messaggio rivolto all’utente
- fallimento transitorio alla decisione di ritentativo
- fallimento dell’operazione all’evento di log
- errore di dominio alla risposta dell’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 implementativi o vengono tradotti in una forma su cui il livello successivo può agire.
Confini del repository
Il repository comunica 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”.
Confini del servizio
Il servizio possiede il significato aziendale.
Dovrebbe solitamente aggiungere contesto operativo 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 imprevisti.
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.
Confini 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 sulla semantica HTTP piuttosto che esporre dettagli grezzi del database o errori interni. Questo è dove molte applicazioni Go sbagliano — o espongono troppo dettaglio interno 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.
Confini CLI
Una CLI ha un confine diverso rispetto a 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 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 costringere ogni pacchetto interno a restituire errori specifici dell’API.
Errori di dominio vs errori di trasporto
Tieni separati gli errori di dominio 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 far restituire codici di stato HTTP al tuo layer di dominio:
return &APIError{Status: http.StatusConflict}
Questo accoppia la logica aziendale a HTTP e impedisce al tuo layer di servizio di funzionare in modo pulito attraverso HTTP, CLI, worker, test e futuri adapter gRPC. La mappatura del trasporto appartiene al confine del 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, Go Project Structure: Practices & Patterns copre le convenzioni internal/, pkg/ e adapter che mantengono questi livelli separati in modo pulito.
Errori ritentabili (Retryable errors)
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)
}
Loop di ritentativo:
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// ritenta con backoff
}
return err
}
Questo è molto meglio rispetto a verificare se la stringa dell’errore contiene “timeout” — il confronto di stringhe si rompe in silenzio 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
- flushing 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 gli 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 primario e del contesto di logging.
Non usarlo per evitare di decidere quale errore è importante.
Non restituire errori uniti enormi agli utenti.
Gli errori uniti sono utili, ma possono diventare rumorosi rapidamente.
Panic non è gestione degli errori
Nel codice dell’applicazione normale, non usare panic per errori previsti.
Male:
if err != nil {
panic(err)
}
Usa panic per errori dello sviluppatore o situazioni veramente irrecuperabili.
Esempi:
- violazione impossibile di un invariante interno
- inizializzazione del pacchetto invalida
- fallimento dell’helper di test con
t.Fatalo panic in casi limitati - errore di configurazione di startup irrecuperabile, a seconda dello stile
Non usare panic perché una query al database è fallita o un utente ha inviato input non valido.
Quelli sono errori normali.
Logging degli errori
Un errore comune in Go è registrare lo stesso errore ad ogni livello.
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:
- avvolgere gli errori mentre si muovono verso l’alto
- loggare una volta al confine dove 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 completa degli errori. Per una configurazione di logging strutturato pronta per la produzione, Structured Logging in Go with slog copre i record log/slog, handler JSON, correlazione del contesto e redazione — tutti elementi che si abbinano naturalmente al logging degli errori a livello di confine.
Quando loggare all’interno dei livelli inferiori
Logga all’interno dei livelli inferiori solo quando il livello sta effettivamente gestendo l’errore o aggiungendo contesto operativo importante che non sarà visibile altrove.
Per esempio, un loop di ritentativo può loggare ogni tentativo di ritentativo al livello debug o warning.
Ma un repository non dovrebbe loggare ogni errore di query se l’handler loggerà il fallimento finale della richiesta.
Errori rivolti 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 rivolto 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 del log per operatori
Forzare una stringa di errore a servire tutti questi pubblici produce sia un rischio di esposizione che un incubo di debugging. Progetta la tua architettura degli errori attorno a valori distinti per consumatori distinti.
Gestione degli errori sicura
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)
Logga 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 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 far analizzare ai client messaggi di errore in inglese.
Un design degli errori a livelli pratico
Ecco un pattern pulito per molti servizi backend Go.
Livello del repository
- Comunica con il database o lo storage esterno.
- Converte gli errori “non trovato” specifici dello storage in errori di dominio.
- Avvolge gli errori di storage imprevisti con contesto operativo.
- Non restituisce errori HTTP.
- Di solito non logga.
Esempio:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Livello del servizio
- Possiede le regole aziendali.
- Crea errori di dominio.
- Preserva gli errori di dominio noti.
- Avvolge gli errori di livello inferiore imprevisti.
- Non restituisce codici di stato HTTP.
- Di solito non logga.
Esempio:
if user.Disabled {
return nil, ErrAccountDisabled
}
Livello del trasporto
- Mappa gli errori di dominio a risposte HTTP, gRPC o CLI.
- Logga errori non gestiti o imprevisti.
- Nasconde i dettagli interni dagli 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 ogni livello di evolversi in modo indipendente — puoi cambiare la tecnologia di storage senza toccare la logica del servizio o la mappatura del trasporto. Il design a livelli funziona meglio quando le dipendenze sono iniettate piuttosto che hard-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 dà:
- errori di dominio
- traduzione dello storage
- contesto del servizio
- mappatura HTTP sicura
- catene di errori ispezionabili
- nessun confronto di stringhe
- nessuna fuoriuscita del trasporto nel codice di dominio
Questo è il tipo di architettura degli errori che scala — abbastanza semplice per un nuovo collaboratore da capire, 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 altrettanto accuratamente del percorso felice (happy path), perché le decisioni ai confini — mappatura delle sentinelle, estrazione dei tipi, codici HTTP — sono spesso dove i bug si 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 le catene di errori avvolti automaticamente e non si rompono quando i messaggi vengono riformattati o localizzati.
Anti-pattern 2: Perdita della 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 contesto operativo 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 ad ogni livello
Male:
log.Println(err)
return err
ad ogni livello. Logga una volta dove l’errore è finalmente gestito, non ad ogni livello 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 delle risposte 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 logga l’errore interno completo con contesto strutturato per gli operatori. Non esporre mai stringhe di connessione al database, percorsi dei file o stack trace grezzi nelle risposte API.
Anti-pattern 7: Troppi errori sentinella esportati
Gli errori esportati fanno parte della tua API del pacchetto, e aggiungerli ti obbliga a mantenerli. Non esportare ogni condizione interna a meno che i chiamanti esterni non abbiano realmente 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 previsti
Male:
panic(err)
per fallimenti runtime normali. Riserva il panic per condizioni veramente irrecuperabili o errori dello sviluppatore, 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 operativo genuino e una richiesta annullata o scaduta, e rispondere in modo appropriato a ciascuno. Per un trattamento approfondito di come la cancellazione del contesto e la propagazione dei timeout funzionano attraverso i layer di servizio, vedi Go context.Context Done Right.
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 (Wrapping)
- L’avvolgimento aggiunge contesto operativo utile?
%wpreserva la causa dove necessario?- Il codice sta accidentalmente esponendo dettagli implementativi?
- 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.Isoerrors.As? - La cancellazione del contesto e le scadenze sono gestite correttamente?
- Gli errori ritentabili sono identificati esplicitamente?
- Gli errori di validazione sono strutturati?
Logging
- L’errore è loggato una volta, al confine di gestione?
- I log sono strutturati?
- I dettagli sensibili sono esclusi dalle risposte agli utenti?
- 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 opiniose
Regola 1: Gli errori dovrebbero attraversare i confini con significato
Non passare semplicemente gli errori. Decidi cosa significano a ogni livello.
Regola 2: Avvolgi per il contesto, non per decorazione
Se l’avvolgimento non aggiunge informazioni utili su quale operazione è fallita, non avvolgere. Un livello extra di contesto senza significato rende la catena degli errori più difficile da leggere e non aggiunge valore diagnostico.
Regola 3: Traduci gli errori implementativi in errori di dominio
Non lasciare che sql.ErrNoRows diventi parte della tua logica aziendale. Traduci gli errori implementativi 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 stringhe di errore
Se il codice ha bisogno di 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 in silenzio quando i messaggi di errore cambiano.
Regola 5: Logga una volta
Avvolgi mentre gli errori si muovono verso l’alto. Logga dove l’errore è finalmente gestito.
Regola 6: Mantieni i messaggi per l’utente sicuri
Gli errori diagnostici interni sono per i log. I messaggi rivolti 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 — 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 astuta, non magica, ma abbastanza 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 per la produzione attraverso integrazione, testing e accesso ai dati, vedi App Architecture in Production.