Arquitetura de tratamento de erros em Go
LidoNesta aula, você aprenderá a arquitetar o tratamento de erros em Go de forma profissional, organizando o código em camadas, adicionando contexto com wrapping, definindo erros de domínio e implementando logging eficaz. Vamos explorar padrões e práticas recomendadas para construir sistemas robustos e fáceis de depurar.
Em Go, o tratamento de erros é explícito e faz parte do fluxo normal do programa. No entanto, em aplicações reais, simplesmente retornar erros não é suficiente: é preciso arquitetar o tratamento para que o sistema seja legível, manutenível e forneça informações úteis para diagnóstico. Nesta aula, vamos mergulhar em uma arquitetura de tratamento de erros em camadas, usando wrapping com contexto, definindo erros de domínio e integrando logging de forma consistente.
Vamos começar entendendo por que uma boa arquitetura de erros é crucial: ela permite separar a preocupação de 'o que aconteceu' (domínio) de 'onde aconteceu' (técnico), facilita a adição de contexto sem perder a rastreabilidade e possibilita que diferentes camadas (handler, serviço, repositório) tratem erros de maneira adequada. Ao final, você terá um repertório de padrões para aplicar em seus projetos.
Camadas
Em uma aplicação bem estruturada, costumamos dividir o código em camadas: handler (transporte), serviço (aplicação), repositório (persistência) e possivelmente outras, como cliente HTTP ou mensageria. Cada camada tem responsabilidades distintas e o tratamento de erros deve refletir isso. A ideia central é que erros de baixo nível (como falha de conexão com banco) sejam capturados nas camadas inferiores e convertidos em erros de domínio ou em respostas HTTP adequadas nas camadas superiores.
Por exemplo, um handler HTTP não deve expor detalhes internos do banco de dados; ele deve mapear erros para códigos de status HTTP e mensagens amigáveis. Já o repositório deve retornar erros que indiquem falhas de infraestrutura, sem se preocupar com a interface com o usuário. Vamos ver um exemplo de como organizar isso em Go:
// Camada de repositório (ex.: banco de dados)
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
}
type postgresRepository struct {
db *sql.DB
}
func (r *postgresRepository) FindByID(ctx context.Context, id int) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
// Erro técnico: vamos envolver com contexto
return nil, fmt.Errorf("find user by id %d: %w", id, err)
}
return &u, nil
}
// Camada de serviço (aplicação)
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// Erro de domínio: o serviço pode adicionar mais contexto
return nil, fmt.Errorf("service: user %d: %w", id, ErrUserNotFound)
}
return nil, err // repassa o erro técnico já com contexto
}
return user, nil
}
// Camada de handler (HTTP)
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(chi.URLParam(r, "id"))
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
default:
// Log do erro técnico
h.logger.Error("failed to get user", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}
json.NewEncoder(w).Encode(user)
}Perceba que cada camada adiciona contexto conforme o erro sobe, mas sem quebrar a cadeia de erros. O repositório envolve com informações técnicas, o serviço pode adicionar regras de negócio e o handler decide como apresentar ao cliente. Essa arquitetura evita que detalhes internos vazem e permite que cada camada trate apenas o que lhe diz respeito.
Wrapping com contexto
O wrapping é a prática de envolver um erro com informações adicionais usando fmt.Errorf com o verbo %w. Isso cria uma cadeia de erros que pode ser desempacotada com errors.Unwrap ou verificada com errors.Is e errors.As. O objetivo é adicionar contexto (como parâmetros de função, IDs, operações) sem perder a referência ao erro original.
Por exemplo, em vez de retornar um erro genérico, você pode fazer:
if err != nil {
return fmt.Errorf("failed to process order %d: %w", orderID, err)
}Aqui, o erro original é preservado e pode ser verificado posteriormente. É importante usar %w (não %v), pois %v quebra a cadeia. Além disso, é uma boa prática adicionar contexto em cada camada, mas sem exagerar: o contexto deve ajudar a localizar o problema rapidamente, não poluir a mensagem.
Vamos ver um exemplo mais completo com errors.Is para verificar um erro sentinela:
var ErrNotFound = errors.New("not found")
func GetItem(id int) (*Item, error) {
item, err := fetchFromDB(id)
if err != nil {
return nil, fmt.Errorf("get item %d: %w", id, err)
}
if item == nil {
return nil, fmt.Errorf("get item %d: %w", id, ErrNotFound)
}
return item, nil
}
func main() {
_, err := GetItem(42)
if err != nil {
if errors.Is(err, ErrNotFound) {
fmt.Println("Item não encontrado")
} else {
fmt.Println("Erro genérico:", err)
}
}
}Nesse código, errors.Is verifica se em algum lugar da cadeia existe ErrNotFound, mesmo que ele tenha sido envolvido. Isso é poderoso para tratar erros de domínio de forma consistente.
Boas práticas para wrapping:
- Use
fmt.Errorfcom%wpara preservar a cadeia. - Adicione contexto que ajude a identificar a operação e os parâmetros.
- Evite prefixos como "error:" redundantes, pois o erro já é um erro.
- Prefira frases que começam com letra minúscula, já que podem ser encadeadas.
- Não envolva erros sentinela desnecessariamente se você precisar comparar com
==; useerrors.Issempre.
Erros de domínio
Erros de domínio representam violações de regras de negócio ou situações esperadas que a aplicação precisa tratar de forma específica. Por exemplo, "usuário não encontrado", "saldo insuficiente", "email já cadastrado" são erros de domínio. Em Go, é comum defini-los como variáveis globais do tipo error (erros sentinela) ou como tipos customizados que carregam mais informações.
Erros sentinela são simples e eficazes:
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidEmail = errors.New("invalid email")
ErrInsufficientFunds = errors.New("insufficient funds")
)Eles são usados com errors.Is para comparação. Já erros customizados permitem adicionar campos estruturados:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field %s: %s", e.Field, e.Message)
}
func ValidateUser(u User) error {
if u.Name == "" {
return &ValidationError{Field: "name", Message: "name is required"}
}
// ...
return nil
}Com errors.As, você pode extrair o tipo e acessar os campos:
err := ValidateUser(u)
if err != nil {
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("Campo:", valErr.Field)
fmt.Println("Mensagem:", valErr.Message)
}
}Os erros de domínio devem ser definidos perto das regras de negócio, geralmente no pacote de domínio ou serviço, e não devem conter detalhes técnicos. Eles são a ponte entre o domínio e as camadas externas.
Exemplo de uso em um caso de uso de transferência bancária:
var (
ErrAccountNotFound = errors.New("account not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrTransferNotAllowed = errors.New("transfer not allowed")
)
type Account struct {
ID int
Balance float64
}
func Transfer(accounts map[int]*Account, from, to int, amount float64) error {
src, ok := accounts[from]
if !ok {
return fmt.Errorf("transfer from %d: %w", from, ErrAccountNotFound)
}
dst, ok := accounts[to]
if !ok {
return fmt.Errorf("transfer to %d: %w", to, ErrAccountNotFound)
}
if amount <= 0 {
return fmt.Errorf("transfer amount %v: %w", amount, ErrTransferNotAllowed)
}
if src.Balance < amount {
return fmt.Errorf("transfer from %d: %w", from, ErrInsufficientFunds)
}
src.Balance -= amount
dst.Balance += amount
return nil
}Observe que usamos fmt.Errorf para adicionar contexto e depois podemos verificar com errors.Is no chamador.
Logging
Um bom sistema de logging é essencial para diagnosticar problemas em produção. Em Go, a biblioteca padrão oferece log, mas para aplicações sérias é recomendável usar uma biblioteca estruturada como logrus, zap ou slog (a partir da versão 1.21). O logging deve ser integrado ao tratamento de erros: quando um erro ocorre, você deve logar informações suficientes para reproduzir o problema, mas sem expor dados sensíveis.
Uma prática comum é logar o erro completo (com stack trace) nas camadas inferiores, e nas camadas superiores logar apenas um resumo ou o ID de correlação. Por exemplo:
// Em um serviço
func (s *Service) DoSomething() error {
result, err := s.repo.Query(...)
if err != nil {
s.logger.Error("query failed", "error", err, "operation", "DoSomething")
return fmt.Errorf("do something: %w", err)
}
return nil
}No handler, você pode logar o erro recebido e retornar uma resposta genérica:
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
err := h.svc.DoSomething()
if err != nil {
h.logger.Error("request failed", "error", err, "path", r.URL.Path)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}Para logging estruturado, vamos usar slog como exemplo (a partir do Go 1.21):
import "log/slog"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
err := doSomething()
if err != nil {
slog.Error("operation failed", "error", err, "module", "main")
}
}O slog permite adicionar atributos estruturados, facilitando a filtragem e análise em ferramentas como ELK ou Grafana. É importante logar em momentos apropriados: não logue em excesso (para evitar ruído), mas também não deixe de logar erros críticos. Uma boa prática é logar o erro na camada onde ele é tratado e também nas camadas intermediárias com contexto adicional, mas sempre evitando duplicação desnecessária.
Algumas diretrizes:
- Use níveis de log (Debug, Info, Warn, Error) de forma consistente.
- Inclua um ID de correlação (por exemplo, de um header HTTP) para rastrear requisições.
- Nunca logue senhas, tokens ou dados pessoais.
- Combine logging com wrapping: o log pode incluir a cadeia de erros, mas não precisa repetir toda a mensagem em cada camada.
- Em ambientes de produção, configure rotação e retenção de logs.
Boas práticas e observações finais
Uma arquitetura de erros eficaz não surge por acaso; ela exige disciplina e padronização. Algumas recomendações finais:
- Defina erros de domínio no pacote onde a regra de negócio está, e exporte-os para serem usados pelas camadas superiores.
- Evite criar erros com
errors.Newdentro de funções; prefira sentinelas ou tipos customizados para erros esperados. - Use
errors.Iseerrors.Asem vez de comparação direta com==para suportar wrapping. - Documente os erros que sua função pode retornar, especialmente se for uma API pública.
- Teste o tratamento de erros: escreva testes que verificam se os erros são envolvidos corretamente e se os sentinelas são preservados.
- Considere usar pacotes como
pkg/errors(antigo) oucockroachdb/errorspara funcionalidades extras, mas a biblioteca padrão já cobre a maioria dos casos.
Ao aplicar essas técnicas, você terá um código mais robusto, fácil de manter e de depurar, tanto em desenvolvimento quanto em produção.
Referências
- Error handling and Go - The Go Blog
- Errors are values - The Go Blog
- Package errors - Go Documentation
- fmt.Errorf documentation
- Package slog - Go Documentation
- Package log - Go Documentation
- Uber Zap - Logging library
- Logrus - Logging library
Exercícios
Crie um pacote
domaincom erros sentinela para um sistema de biblioteca:ErrBookNotFound,ErrBookUnavailable,ErrInvalidISBN. Em seguida, escreva uma funçãoBorrowBookque retorne esses erros quando apropriado e envolva com contexto usandofmt.Errorf.✓ Resposta:package domain import ( "errors" "fmt" ) var ( ErrBookNotFound = errors.New("book not found") ErrBookUnavailable = errors.New("book unavailable") ErrInvalidISBN = errors.New("invalid ISBN") ) func BorrowBook(books map[string]Book, isbn string) error { book, ok := books[isbn] if !ok { return fmt.Errorf("borrow book with ISBN %s: %w", isbn, ErrBookNotFound) } if !book.Available { return fmt.Errorf("borrow book %s: %w", isbn, ErrBookUnavailable) } return nil }Defina um tipo de erro customizado
ConfigErrorque contenha um campoKeyeValue. Implemente a interfaceerrore useerrors.Aspara extrair o tipo em uma função que valida configuração.✓ Resposta:type ConfigError struct { Key string Value string } func (e *ConfigError) Error() string { return fmt.Sprintf("invalid config %s=%s", e.Key, e.Value) } func ValidateConfig(cfg map[string]string) error { for k, v := range cfg { if v == "" { return &ConfigError{Key: k, Value: v} } } return nil } // Uso: func main() { cfg := map[string]string{"host": "", "port": "8080"} err := ValidateConfig(cfg) if err != nil { var ce *ConfigError if errors.As(err, &ce) { fmt.Printf("Chave inválida: %s\n", ce.Key) } } }Implemente uma função
ReadFileWithContextque tenta abrir um arquivo, e se falhar, envolve o erro com o nome do arquivo e a operação. Depois, usandoerrors.Isverifique se o erro éos.ErrNotExist.✓ Resposta:func ReadFileWithContext(filename string) ([]byte, error) { data, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("read file %s: %w", filename, err) } return data, nil } func main() { _, err := ReadFileWithContext("nonexistent.txt") if err != nil { if errors.Is(err, os.ErrNotExist) { fmt.Println("Arquivo não existe") } else { fmt.Println("Erro:", err) } } }Crie uma hierarquia de camadas (handler, service, repository) para uma API de usuários. No repository, retorne
sql.ErrNoRowsquando não encontrar; no service, converta para um erro de domínioErrUserNotFound; no handler, responda com 404 se for esse erro e 500 caso contrário. Use wrapping em cada camada.✓ Resposta:// repository.go func (r *repo) FindByID(ctx context.Context, id int) (*User, error) { row := r.db.QueryRowContext(ctx, "SELECT ...") var u User if err := row.Scan(&u.ID, &u.Name); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("repository find user %d: %w", id, sql.ErrNoRows) } return nil, fmt.Errorf("repository find user %d: %w", id, err) } return &u, nil } // service.go var ErrUserNotFound = errors.New("user not found") func (s *service) GetUser(ctx context.Context, id int) (*User, error) { user, err := s.repo.FindByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("service get user %d: %w", id, ErrUserNotFound) } return nil, err } return user, nil } // handler.go func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(chi.URLParam(r, "id")) user, err := h.svc.GetUser(r.Context(), id) if err != nil { if errors.Is(err, ErrUserNotFound) { http.Error(w, "user not found", http.StatusNotFound) } else { h.logger.Error("get user failed", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) } return } writeJSON(w, user) }Escreva um programa que use
log/slogpara logar um erro estruturado com campos comouser_ideaction. Simule uma falha e registre o log.✓ Resposta:package main import ( "log/slog" "os" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) userID := 123 action := "update_profile" err := performAction(userID, action) if err != nil { slog.Error("action failed", "user_id", userID, "action", action, "error", err) } } func performAction(userID int, action string) error { // Simula falha return fmt.Errorf("simulated failure") }