Arquitetura de Tratamento de Erros em Go: Limites e Padrões
Trate erros na fronteira adequada.
Tratar erros em Go é fácil de criticar. Todo desenvolvedor Go escreveu esse código centenas de vezes:
if err != nil {
return err
}
Essa não é a parte interessante. A parte interessante é o que o erro significa, onde ele deve ser tratado, onde deve ser envolvido (wrapped), onde deve ser traduzido, onde deve ser registrado (logado) e o que deve ser exposto ao chamador — essa é a questão de arquitetura.
Go trata erros como valores. Isso torna as falhas explícitas. Também significa que sua base de código precisa de um design claro de tratamento de erros. Sem ele, os erros se tornam strings aleatórias, manipuladores HTTP vazam detalhes do banco de dados, logs duplicam a mesma falha cinco vezes, novas tentativas (retries) acontecem pelos motivos errados e os chamadores inspecionam texto em vez de comportamento.

Este artigo não é uma introdução iniciante a if err != nil.
É um guia prático para a arquitetura de tratamento de erros em Go: envolvimento (wrapping), sentinela, tipos de erro personalizados, errors.Is, errors.As, limites de erro, mapeamento de API, registro (logging), novas tentativas, segurança e padrões de produção.
A versão levemente opinativa: não tente fazer os erros do Go desaparecerem. Torne-os significativos no limite certo.
O que são erros em Go
Em Go, um erro é apenas um valor que implementa esta interface:
type error interface {
Error() string
}
Essa pequena interface é a razão pela qual o tratamento de erros em Go parece tão direto.
Funções retornam erros explicitamente:
func LoadUser(id string) (*User, error) {
// ...
}
Os chamadores decidem o que fazer:
user, err := LoadUser(id)
if err != nil {
return nil, err
}
Não há exceções e nem desenrolamento de pilha oculto. Falha faz parte da assinatura da função.
Isso é bom, mas também significa que os erros precisam de design. Se cada pacote retornar mensagens arbitrárias, os chamadores não podem tomar decisões confiáveis. Se cada camada envolver cada erro sem disciplina, os operadores recebem mensagens barulhentas e os desenvolvedores recebem cadeias confusas. Se nenhuma camada envolver os erros, as falhas perdem contexto.
O objetivo não é menos tratamento de erros, mas melhor significado de erro.
Os três empregos de um erro
Um erro útil geralmente tem um ou mais empregos.
Emprego 1: Explicar o que falhou
Para humanos, o erro deve explicar qual operação falhou.
Exemplo:
return fmt.Errorf("load user %s: %w", id, err)
Isso dá contexto. Diz que a falha ocorreu ao carregar um usuário.
Emprego 2: Preservar a causa
Para o código, o erro deve preservar a causa subjacente quando essa causa importa.
Exemplo:
return fmt.Errorf("load user %s: %w", id, err)
O %w envolve o erro original para que os chamadores possam inspecioná-lo com errors.Is ou errors.As.
Emprego 3: Permitir que um limite tome uma decisão
Em algum limite, o programa deve decidir o que fazer.
Exemplos:
- Retornar HTTP 404
- Retornar HTTP 409
- Refazer a operação
- Registrar em nível de aviso
- Mostrar uma mensagem segura para o usuário
- Abortar a transação
- Enviar o erro para monitoramento
- Ignorar cancelamento
Essa decisão deve geralmente ser baseada na identidade ou tipo do erro, não na correspondência de strings.
As principais ferramentas de erro no Go moderno
O Go moderno oferece um conjunto pequeno, mas poderoso, de ferramentas.
errors.New
Use errors.New para criar um valor de erro simples:
var ErrNotFound = errors.New("not found")
Isso é útil para erros sentinela.
fmt.Errorf com %w
Use fmt.Errorf com %w para envolver um erro:
return fmt.Errorf("query user: %w", err)
Envolver adiciona contexto enquanto preserva o erro original para inspeção.
errors.Is
Use errors.Is para verificar se um erro corresponde a um alvo específico em algum lugar de sua cadeia:
if errors.Is(err, ErrNotFound) {
// handle not found
}
Use isso para erros sentinela e condições conhecidas.
errors.As
Use errors.As para extrair um tipo de erro específico de uma cadeia:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// use validationErr.Field or validationErr.Reason
}
Use isso quando o erro carregar dados estruturados.
errors.Join
Use errors.Join quando vários erros ocorreram e todos devem ser preservados:
return errors.Join(closeErr, flushErr)
Erros unidos ainda podem ser inspecionados com errors.Is e errors.As.
Use isso com cuidado. Um erro unido significa que várias falhas fazem parte de um único resultado.
Erros sentinela
Um erro sentinela é um valor de erro em nível de pacote que representa uma condição conhecida.
Exemplo:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
Erros sentinela são úteis quando o chamador só precisa saber qual categoria de falha ocorreu.
Exemplo:
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
}
Então um serviço ou manipulador pode verificar:
if errors.Is(err, ErrUserNotFound) {
// return 404
}
Quando usar erros sentinela
Use erros sentinela quando:
- A condição é estável.
- O chamador precisa bifurcar com base nela.
- Nenhum dado estruturado extra é necessário.
- O erro pertence ao seu pacote ou domínio.
Bons exemplos:
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")
Quando não usar erros sentinela
Não crie sentinelas para cada falha possível.
Ruim:
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 os chamadores não bifurcarem com base neles, eles podem ser apenas mensagens.
Também tenha cuidado ao exportar muitas sentinelas. Erros sentinela exportados tornam-se parte da API do seu pacote.
Tipos de erro personalizados
Um tipo de erro personalizado é útil quando o erro carrega informações estruturadas.
Exemplo:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}
Chamador:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
Isso é melhor do que analisar uma string de erro.
Quando usar tipos de erro personalizados
Use tipos de erro personalizados quando:
- Os chamadores precisam de dados estruturados.
- O erro tem campos significativos.
- O tipo faz parte do contrato do seu pacote.
- O chamador pode precisar lidar com vários valores de maneira diferente.
Exemplos:
- Erro de validação com nome do campo
- Erro de limite de taxa com tempo de nova tentativa
- Erro HTTP com código de status
- Erro de análise com linha e coluna
- Erro de domínio com ID do recurso
Quando não usar tipos de erro personalizados
Não crie tipos personalizados apenas para evitar errors.New.
Isso é desnecessário:
type NotFoundError struct{}
func (e NotFoundError) Error() string {
return "not found"
}
Se não houver dados úteis, um sentinela geralmente é suficiente.
Envolvimento de erros (Wrapping)
Envolver adiciona contexto a um erro enquanto preserva o erro original.
Exemplo:
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 falhar, o chamador recebe ambos:
- a operação de alto nível: ler configuração
- a causa de baixo nível: permissão negada, arquivo não encontrado, etc.
Ambos estão disponíveis através da cadeia de erros, o que torna o envolvimento com %w vale a pena fazer consistentemente.
Envolver com contexto útil
Um bom envolvimento diz qual operação falhou:
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Um mau envolvimento adiciona ruído:
return fmt.Errorf("error: %w", err)
Isso não diz nada ao chamador.
Também evite repetir o mesmo substantivo em cada camada:
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Esse tipo de cadeia é tecnicamente correto e praticamente irritante.
Envolve onde o contexto muda de significado. Se você não pode explicar em uma frase qual operação falhou, provavelmente está envolvendo com muita agressividade ou não o suficiente.
Quando envolver e quando não envolver
Esta é uma das decisões de arquitetura mais importantes.
Envolver ao cruzar um limite significativo
Envolve quando o erro se move de uma operação para uma operação de nível superior.
Exemplo:
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
}
O erro do repositório agora faz parte de uma operação de serviço, e esse contexto adicionado é útil quando os operadores rastreiam uma falha de volta através dos logs.
Não envolva apenas para dizer “falhou”
Ruim:
if err != nil {
return fmt.Errorf("failed: %w", err)
}
A palavra “falhou” geralmente está implícita no fato de que um erro existe.
Não envolva se estiver traduzindo
Às vezes, você deve traduzir um erro para outro erro de domínio.
Exemplo:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
Isso esconde intencionalmente o detalhe do banco de dados e expõe uma condição de domínio.
Você ainda pode preservar a causa se for útil, mas faça isso deliberadamente.
Não exponha detalhes de implementação acidentalmente
Se você envolver um erro de baixo nível com %w, os chamadores podem inspecioná-lo.
Isso geralmente é bom dentro do seu aplicativo.
Mas em uma API de pacote público, o envolvimento pode expor detalhes de implementação como parte do seu contrato.
Por exemplo, se o seu pacote envolver sql.ErrNoRows, os chamadores podem começar a depender dele:
if errors.Is(err, sql.ErrNoRows) {
// caller now knows you use database/sql
}
Se você puder alterar o armazenamento posteriormente, prefira um sentinela de domínio:
var ErrUserNotFound = errors.New("user not found")
Então retorne isso do limite do pacote.
Limites de erro
A maneira mais útil de pensar sobre o tratamento de erros em Go é através de limites.
Um limite é um lugar onde um erro muda de significado ou público-alvo.
Limites comuns incluem:
- banco de dados para repositório
- repositório para serviço
- serviço para manipulador HTTP
- serviço para comando CLI
- erro interno para mensagem voltada para o usuário
- falha transitória para decisão de nova tentativa
- falha de operação para evento de log
- erro de domínio para resposta de API
A arquitetura de erro é principalmente design de limites. Cada limite é um ponto de decisão onde os erros ganham contexto, perdem detalhes de implementação ou são traduzidos em uma forma que a próxima camada possa atuar.
Limite do repositório
O repositório fala com o armazenamento.
Ele geralmente deve traduzir erros específicos do banco de dados em erros de domínio.
Exemplo:
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
}
O repositório esconde sql.ErrNoRows e expõe ErrUserNotFound — um limite limpo que significa que o serviço não precisa saber nada sobre como o armazenamento representa “não encontrado”.
Limite do serviço
O serviço possui o significado de negócio.
Ele geralmente deve adicionar contexto de operação e preservar erros de domínio.
Exemplo:
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
}
Isso preserva a condição de domínio enquanto adiciona contexto para erros inesperados.
Para regras de negócio mais complexas, o serviço pode criar erros de domínio diretamente:
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
}
O serviço é o lugar certo para erros de nível de negócio — criados diretamente da lógica de domínio em vez de traduzidos de condições de infraestrutura.
Limite do manipulador HTTP
O manipulador HTTP traduz erros de aplicativos em respostas HTTP.
Este é um limite onde detalhes internos devem se tornar respostas seguras para o usuário.
Exemplo:
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)
}
}
Mapeamento de erro:
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)
}
}
O manipulador mapeia erros de domínio para semânticas HTTP em vez de expor detalhes brutos do banco de dados ou de erros internos. É aqui que muitos aplicativos Go erram — eles ou expõem muitos detalhes internos ou colapsam todos os erros em HTTP 500. Para uma visão completa dos padrões de manipuladores e middleware em APIs Go, Building REST APIs in Go cobre autenticação, roteamento e tratamento de erros na biblioteca padrão, Gin, Echo e Fiber.
Limite CLI
Um CLI tem um limite diferente de uma API HTTP.
Em um CLI, o erro deve ser útil para a pessoa que está executando o comando.
Exemplo:
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
}
No limite do comando:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, formatCLIError(err))
os.Exit(exitCode(err))
}
}
Mapeie erros conhecidos para códigos de saída:
func exitCode(err error) int {
switch {
case errors.Is(err, ErrMissingInputFile):
return 2
case errors.Is(err, ErrValidation):
return 3
default:
return 1
}
}
Um CLI muitas vezes pode mostrar mais detalhes do que uma API pública, mas ainda deve evitar vazar segredos.
Padrão de tipo de erro de API
Para APIs HTTP, um pequeno tipo de erro em nível de aplicativo pode ser útil.
Exemplo:
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
}
Construtor:
func NewAPIError(status int, code string, message string, err error) *APIError {
return &APIError{
Status: status,
Code: code,
Message: message,
Err: err,
}
}
Uso:
return NewAPIError(
http.StatusConflict,
"duplicate_email",
"email is already registered",
ErrDuplicateEmail,
)
Manipulador:
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",
})
}
Este padrão é útil quando você deseja erros de API estruturados com códigos estáveis.
Use-o no limite da API. Não force cada pacote interno a retornar erros específicos da API.
Erros de domínio vs erros de transporte
Mantenha erros de domínio separados de erros de transporte.
Erro de domínio:
var ErrInsufficientBalance = errors.New("insufficient balance")
Mapeamento de transporte:
if errors.Is(err, ErrInsufficientBalance) {
http.Error(w, "insufficient balance", http.StatusConflict)
return
}
Não faça sua camada de domínio retornar códigos de status HTTP:
return &APIError{Status: http.StatusConflict}
Isso acopla a lógica de negócio ao HTTP e impede que sua camada de serviço funcione limpa através de HTTP, CLI, workers, testes e adaptadores gRPC futuros. O mapeamento de transporte pertence ao limite de transporte, não no código de domínio. Para orientação sobre onde definir erros de domínio, sentinelas e adaptadores de transporte dentro do layout do seu projeto, Go Project Structure: Practices & Patterns cobre as convenções de internal/, pkg/ e adaptadores que mantêm essas camadas separadas limpa.
Erros passíveis de nova tentativa (Retryable)
Alguns erros devem acionar nova tentativa. Outros não devem.
Não decida isso correspondendo strings.
Use uma interface de marcador ou função explícita.
Exemplo:
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
return e.Err
}
Auxiliar:
func Retryable(err error) error {
if err == nil {
return nil
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryable *RetryableError
return errors.As(err, &retryable)
}
Uso:
if err := callRemoteAPI(ctx); err != nil {
if isTemporaryNetworkError(err) {
return Retryable(fmt.Errorf("call remote api: %w", err))
}
return fmt.Errorf("call remote api: %w", err)
}
Loop de nova tentativa:
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// retry with backoff
}
return err
}
Isso é muito melhor do que verificar se a string de erro contém “timeout” — a correspondência de strings falha silenciosamente quando as mensagens mudam e cria acoplamento invisível entre produtor e consumidor.
Erros de validação
Erros de validação frequentemente precisam de dados estruturados.
Exemplo:
type FieldError struct {
Field string
Message string
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Uso:
func ValidateCreateUser(req CreateUserRequest) error {
var fields []FieldError
if req.Email == "" {
fields = append(fields, FieldError{
Field: "email",
Message: "email is required",
})
}
if len(fields) > 0 {
return &ValidationError{Fields: fields}
}
return nil
}
Manipulador:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, validationErr)
return
}
Este é um bom uso de errors.As porque o chamador precisa de informações estruturadas — nomes de campos e mensagens de validação — e não apenas uma string de erro opaca.
Múltiplos erros
Às vezes, várias coisas falham.
Exemplos:
- fechando múltiplos recursos
- validando muitos campos
- desligando vários workers
- executando verificações independentes
- flushando e fechando saída
Use errors.Join quando todos os erros devem ser preservados.
Exemplo:
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...)
}
Chamador:
if err := CloseAll(a, b, c); err != nil {
return fmt.Errorf("close resources: %w", err)
}
Tanto errors.Is quanto errors.As podem inspecionar erros unidos, o que significa que valores de erro unidos permanecem totalmente compatíveis com padrões padrão de verificação de erro.
Quando não usar errors.Join
Não use errors.Join quando houver um erro principal e algum contexto de log.
Não o use para evitar decidir qual erro importa.
Não retorne erros unidos enormes para usuários.
Erros unidos são úteis, mas podem se tornar barulhentos rapidamente.
Panic não é tratamento de erro
No código de aplicativo normal, não use panic para erros esperados.
Ruim:
if err != nil {
panic(err)
}
Use panic para erros de programador ou situações verdadeiramente irrecuperáveis.
Exemplos:
- violação de invariante interno impossível
- inicialização de pacote inválida
- falha de auxiliar de teste com
t.Fatalou panic em casos limitados - erro de configuração de inicialização irrecuperável, dependendo do estilo
Não entre em pânico porque uma consulta de banco de dados falhou ou um usuário enviou entrada inválida.
Esses são erros normais.
Registrando erros (Logging)
Um erro comum em Go é registrar o mesmo erro em cada camada.
Ruim:
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
}
Isso cria logs duplicados para uma única falha.
Melhor:
- envolva os erros conforme eles sobem
- registre uma vez no limite onde o erro é tratado
- inclua contexto estruturado no log
Exemplo:
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,
)
}
Isso dá um evento de log com a cadeia de erro completa. Para uma configuração de log estruturado pronta para produção, Structured Logging in Go with slog cobre registros log/slog, manipuladores JSON, correlação de contexto e redação — todos os quais se encaixam naturalmente com o registro de erro em nível de limite.
Quando registrar dentro de camadas inferiores
Registre dentro de camadas inferiores apenas quando a camada está realmente tratando o erro ou adicionando contexto operacional importante que não será visível em outro lugar.
Por exemplo, um loop de nova tentativa pode registrar cada tentativa de nova tentativa em nível de debug ou aviso.
Mas um repositório não deve registrar cada erro de consulta se o manipulador registrar a falha final da solicitação.
Erros voltados para o usuário vs erros de operador
Não mostre erros internos diretamente aos usuários.
Erro interno:
query user by id: dial tcp 10.0.4.12:5432: connection refused
Mensagem voltada para o usuário:
internal server error
Log de operador:
request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"
Estes são públicos-alvos diferentes, e uma boa arquitetura de erro os mantém separados:
- erro de diagnóstico interno
- resposta segura para o usuário
- código de erro de API estável
- contexto de log de operador
Forçar uma string de erro para atender a todos esses públicos-alvos produz либо um risco de exposição ou um pesadelo de depuração. Projete sua arquitetura de erro em torno de valores distintos para consumidores distintos.
Tratamento de erro seguro
Erros podem vazar informações sensíveis.
Evite expor:
- strings de conexão de banco de dados
- consultas SQL com segredos
- nomes de host internos
- caminhos de arquivo
- tokens de acesso
- chaves de API
- rastreamentos de pilha (stack traces)
- dados privados de clientes
- detalhes de política de autorização
Isso é especialmente importante em APIs HTTP.
Ruim:
http.Error(w, err.Error(), http.StatusInternalServerError)
Bom:
http.Error(w, "internal server error", http.StatusInternalServerError)
Registre o erro interno com segurança para operadores. Retorne uma mensagem segura para o usuário.
Códigos de erro
Para APIs públicas, códigos de erro estáveis geralmente são melhores do que confiar apenas em mensagens.
Exemplo de resposta:
{
"code": "user_not_found",
"message": "user not found"
}
A mensagem pode mudar. O código deve ser estável.
Use códigos de erro para:
- comportamento do cliente
- documentação
- SDKs
- localização
- diagnósticos de suporte
Não faça os clientes analisarem mensagens de erro em inglês.
Um design de erro em camadas prático
Aqui está um padrão limpo para muitos serviços de backend Go.
Camada de repositório
- Fala com o banco de dados ou armazenamento externo.
- Converte erros de “não encontrado” específicos do armazenamento em erros de domínio.
- Envolve erros de armazenamento inesperados com contexto de operação.
- Não retorna erros HTTP.
- Geralmente não registra logs.
Exemplo:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Camada de serviço
- Possui regras de negócio.
- Cria erros de domínio.
- Preserva erros de domínio conhecidos.
- Envolve erros de nível inferior inesperados.
- Não retorna códigos de status HTTP.
- Geralmente não registra logs.
Exemplo:
if user.Disabled {
return nil, ErrAccountDisabled
}
Camada de transporte
- Mapeia erros de domínio para respostas HTTP, gRPC ou CLI.
- Registra erros não tratados ou inesperados.
- Esconde detalhes internos dos usuários.
- Define códigos de status e códigos de erro de API.
Exemplo:
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")
}
Esta separação mantém o tratamento de erros compreensível e permite que cada camada evolua independentemente — você pode alterar a tecnologia de armazenamento sem tocar na lógica de serviço ou mapeamento de transporte. O design em camadas funciona melhor quando as dependências são injetadas em vez de codificadas; Dependency Injection in Go: Patterns & Best Practices cobre os padrões de construtor e interface que tornam cada limite fácil de testar isoladamente.
Exemplo completo
Aqui está um pequeno exemplo do início ao fim.
Erros de domínio:
package users
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("duplicate email")
ErrAccountDisabled = errors.New("account disabled")
)
Repositório:
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
}
Serviço:
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
}
Manipulador 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",
})
}
}
Esta estrutura lhe dá:
- erros de domínio
- tradução de armazenamento
- contexto de serviço
- mapeamento HTTP seguro
- cadeias de erro inspecionáveis
- sem correspondência de strings
- sem vazamento de transporte para o código de domínio
Esse é o tipo de arquitetura de erro que escala — simples o suficiente para um novo contribuidor entender, mas estruturada o suficiente para que a lógica de domínio nunca vaze para respostas de transporte.
Testando o comportamento de erro
O comportamento de erro deve ser testado tão rigorosamente quanto o caminho feliz, porque decisões de limite — mapeamento de sentinela, extração de tipo, códigos HTTP — são frequentemente onde os bugs se escondem por mais tempo. Para um guia completo sobre estrutura de teste Go, mocks e padrões de cobertura, veja Go Unit Testing: Structure & Best Practices.
Testar mapeamento de sentinela
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)
}
}
Testar extração de erro personalizado
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")
}
}
Testar mapeamento 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)
}
}
Os testes devem provar que erros conhecidos produzem o comportamento correto em cada limite, para que a refatoração de camadas de armazenamento ou transporte não possa alterar silenciosamente o contrato de falha.
Anti-padrões comuns
Anti-padrão 1: Correspondência de strings
Ruim:
if strings.Contains(err.Error(), "not found") {
// ...
}
Use errors.Is ou errors.As em vez disso — ambos lidam com cadeias de erro envolvidas automaticamente e não quebram quando as mensagens são reformuladas ou localizadas.
Anti-padrão 2: Perdendo a causa
Ruim:
return errors.New("query failed")
Melhor:
return fmt.Errorf("query user: %w", err)
Anti-padrão 3: Envolvendo sem significado
Ruim:
return fmt.Errorf("error happened: %w", err)
Envolve com contexto de operação que explica o que estava sendo tentado, como "create invoice %s: %w" em vez de um prefixo vago que não adiciona valor diagnóstico.
Anti-padrão 4: Registrando em cada camada
Ruim:
log.Println(err)
return err
em cada nível. Registre uma vez onde o erro é finalmente tratado, não em cada camada intermediária que simplesmente o passa para cima.
Anti-padrão 5: Retornando erros HTTP do código de domínio
Ruim:
return &APIError{Status: http.StatusNotFound}
de um serviço de domínio. Mapeie erros de domínio para códigos de status HTTP e corpos de resposta no limite do manipulador, mantendo sua camada de serviço independente de preocupações de transporte.
Anti-padrão 6: Expondo erros internos aos usuários
Ruim:
http.Error(w, err.Error(), http.StatusInternalServerError)
Retorne mensagens genéricas seguras para usuários e registre o erro interno completo com contexto estruturado para operadores. Nunca exponha strings de conexão de banco de dados, caminhos de arquivo ou rastreamentos de pilha brutos em respostas de API.
Anti-padrão 7: Muitas sentinelas exportadas
Erros exportados são parte da API do seu pacote, e adicioná-los compromete você a mantê-los. Não exporte cada condição interna a menos que chamadores externos realmente precisem bifurcar com base nela — prefira manter sentinelas não exportadas até que haja uma necessidade clara.
Anti-padrão 8: Usando panic para falhas esperadas
Ruim:
panic(err)
para falhas de tempo de execução normais. Reserve panic para condições verdadeiramente irrecuperáveis ou erros de programador, não para registros ausentes ou entrada de usuário inválida — sempre retorne erros nesses casos.
Anti-padrão 9: Ignorando erros de contexto
Ruim:
return fmt.Errorf("request failed")
quando a causa real foi context.Canceled. Preserve erros de contexto para que os chamadores possam distinguir entre uma falha de operação genuína e uma solicitação cancelada ou com tempo esgotado, e responder adequadamente a cada uma.
Lista de verificação de revisão de erro
Use esta lista de verificação na revisão de código.
Criação de erro
- Esta é uma condição conhecida?
- Deve ser um sentinela?
- Precisa de dados estruturados?
- Deve ser um tipo personalizado?
- A mensagem de erro está clara?
Envolvimento de erro
- O envolvimento adiciona contexto de operação útil?
%wpreserva a causa onde necessário?- O código está acidentalmente expondo detalhes de implementação?
- A cadeia está muito barulhenta?
Tradução de erro
- Um erro de baixo nível está traduzido no limite certo?
- O comportamento específico do banco de dados está oculto do código de serviço?
- Os erros de domínio são independentes de preocupações HTTP ou CLI?
Tratamento de erro
- O chamador bifurca com
errors.Isouerrors.As? - Cancelamento de contexto e prazos estão sendo tratados corretamente?
- Erros passíveis de nova tentativa são identificados explicitamente?
- Erros de validação estão estruturados?
Registro (Logging)
- O erro está registrado uma vez, no limite de tratamento?
- Os logs estão estruturados?
- Detalhes sensíveis estão excluídos das respostas do usuário?
- Há contexto suficiente para operadores?
Testes
- Casos de erro conhecidos estão sendo testados?
- Mapeamentos HTTP ou CLI estão sendo testados?
- Detalhes de validação estão sendo testados?
- Decisões de nova tentativa estão sendo testadas?
Minhas regras opinativas
Regra 1: Erros devem cruzar limites com significado
Não passe apenas erros de um lado para o outro. Decida o que eles significam em cada camada.
Regra 2: Envolve para contexto, não para decoração
Se o envolvimento não adicionar informações úteis sobre qual operação falhou, não envolva. Uma camada extra de contexto sem significado torna a cadeia de erro mais difícil de ler e não adiciona valor diagnóstico.
Regra 3: Traduza erros de implementação em erros de domínio
Não deixe sql.ErrNoRows fazer parte da sua lógica de negócio. Traduza erros de implementação para erros de domínio no limite de armazenamento, para que o resto do aplicativo nunca precise saber qual banco de dados ou ORM está por baixo.
Regra 4: Não analise strings de erro
Se o código precisar bifurcar com base no tipo de falha, use sentinelas, tipos personalizados, errors.Is ou errors.As. A inspeção de strings cria acoplamento invisível que falha silenciosamente quando as mensagens de erro mudam.
Regra 5: Registre uma vez
Envolve conforme os erros sobem. Registre onde o erro é finalmente tratado.
Regra 6: Mantenha mensagens de usuário seguras
Erros de diagnóstico interno são para logs. Mensagens voltadas para o usuário são para usuários.
Regra 7: Mantenha erros de transporte no limite de transporte
Códigos de status HTTP pertencem em manipuladores ou adaptadores de API, não em serviços de domínio. O código de domínio deve ser reutilizável através de transportes — hoje HTTP, amanhã CLI, gRPC ou um worker orientado a eventos.
Pensamentos finais
O tratamento de erros em Go não é sobre escrever if err != nil para sempre — é sobre tornar a falha explícita e compreensível em cada limite.
A mecânica é simples:
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
A arquitetura é a parte mais difícil:
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
Esse é o tratamento de erros em Go bem feito — não esperto, não mágico, mas claro o suficiente para que o próximo desenvolvedor, operador, cliente de API e você do futuro possam entender o que falhou e o que deve acontecer a seguir. Para uma visão mais ampla dos padrões de produção Go em integração, teste e acesso a dados, veja App Architecture in Production.