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.Errorf com %w para 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 ==; use errors.Is sempre.

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.New dentro de funções; prefira sentinelas ou tipos customizados para erros esperados.
  • Use errors.Is e errors.As em 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) ou cockroachdb/errors para 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

Exercícios

  1. Crie um pacote domain com erros sentinela para um sistema de biblioteca: ErrBookNotFound, ErrBookUnavailable, ErrInvalidISBN. Em seguida, escreva uma função BorrowBook que retorne esses erros quando apropriado e envolva com contexto usando fmt.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
    }
  2. Defina um tipo de erro customizado ConfigError que contenha um campo Key e Value. Implemente a interface error e use errors.As para 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)
            }
        }
    }
  3. Implemente uma função ReadFileWithContext que tenta abrir um arquivo, e se falhar, envolve o erro com o nome do arquivo e a operação. Depois, usando errors.Is verifique 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)
            }
        }
    }
  4. Crie uma hierarquia de camadas (handler, service, repository) para uma API de usuários. No repository, retorne sql.ErrNoRows quando não encontrar; no service, converta para um erro de domínio ErrUserNotFound; 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)
    }
  5. Escreva um programa que use log/slog para logar um erro estruturado com campos como user_id e action. 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")
    }