Arquitetura de Tratamento de Erros em Go: Limites e Padrões
Trate erros na fronteira correta.
A manipulação de erros em Go é fácil de criticar. Todo desenvolvedor Go já 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 encapsulado (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. Isso 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, tentativas de repetição (retries) ocorrem por motivos errados e os chamadores inspecionam texto em vez de comportamento.

Este artigo não é uma introdução para iniciantes sobre if err != nil.
É um guia prático para a arquitetura de tratamento de erros em Go: encapsulamento (wrapping), sentinels, tipos de erro personalizados, errors.Is, errors.As, limites de erro, mapeamento de API, logs, retries, 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 não há desempilhamento de pilha oculto (stack unwinding). A 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 encapsular cada erro sem disciplina, os operadores recebem mensagens ruidosas e os desenvolvedores obtêm cadeias confusas. Se nenhuma camada encapsular os erros, as falhas perdem contexto.
O objetivo não é menos tratamento de erros, mas melhor significado de erro.
As três funções de um erro
Um erro útil geralmente tem uma ou mais funções.
Função 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 enquanto carregava um usuário.
Função 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 encapsula o erro original para que os chamadores possam inspecioná-lo com errors.Is ou errors.As.
Função 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
- Tentar novamente a operação
- Registrar em nível de aviso
- Exibir uma mensagem segura para o usuário
- Abortar a transação
- Enviar o erro para monitoramento
- Ignorar cancelamento
Essa decisão geralmente deve ser baseada na identidade ou tipo do erro, não na correspondência de strings.
As principais ferramentas de erro em Go moderno
Go moderno oferece a você 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 encapsular um erro:
return fmt.Errorf("query user: %w", err)
O encapsulamento 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 em sua cadeia:
if errors.Is(err, ErrNotFound) {
// tratar não encontrado
}
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) {
// usar validationErr.Field ou validationErr.Reason
}
Use isso quando o erro carrega 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 precisa apenas 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) {
// retornar 404
}
Quando usar erros sentinela
Use erros sentinela quando:
- A condição é estável.
- O chamador precisa bifurcar o fluxo 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 sentinela para cada possível falha.
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 o fluxo com base neles, eles podem ser apenas mensagens.
Também tenha cuidado ao exportar muitos sentinela. 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 precisarem de dados estruturados.
- O erro tiver campos significativos.
- O tipo faça parte do contrato do seu pacote.
- O chamador possa precisar lidar com vários valores de forma diferente.
Exemplos:
- Erro de validação com nome do campo
- Erro de limite de taxa com tempo de repetição
- Erro HTTP com código de status
- Erro de análise com linha e coluna
- Erro de domínio com ID de 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.
Encapsulamento de erros (Wrapping)
O encapsulamento 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 obtém 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 encapsulamento com %w algo que vale a pena fazer consistentemente.
Encapsule com contexto útil
Um bom encapsulamento diz qual operação falhou:
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Um encapsulamento ruim 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 correta e praticamente irritante.
Encapsule onde o contexto muda de significado. Se você não puder explicar em uma frase qual operação falhou, provavelmente está encapsulando agressivamente demais ou não o suficiente.
Quando encapsular e quando não encapsular
Esta é uma das decisões de arquitetura mais importantes.
Encapsule ao cruzar um limite significativo
Encapsule 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 encapsule 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 encapsule se você 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-o deliberadamente.
Não exponha detalhes de implementação acidentalmente
Se você encapsular 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 encapsulamento pode expor detalhes de implementação como parte do seu contrato.
Por exemplo, se seu pacote encapsular sql.ErrNoRows, os chamadores podem começar a depender dele:
if errors.Is(err, sql.ErrNoRows) {
// o chamador agora sabe que você usa database/sql
}
Se você puder alterar o armazenamento mais tarde, 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.
Os 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 ao usuário
- falha transitória para decisão de repetição
- falha de operação para evento de log
- erro de domínio para resposta de API
A arquitetura de erros é 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 deve geralmente 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ócios.
Ele deve geralmente 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ócios 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ócios — 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 aplicativo 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 cometem erros — 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 os erros de domínio separados dos 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ócios 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, sentinela e adaptadores de transporte dentro do layout do seu projeto, Go Project Structure: Practices & Patterns cobre as convenções internal/, pkg/ e adaptador que mantêm essas camadas separadas limpa.
Erros repetíveis (Retryable)
Alguns erros devem acionar repetição. 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 repetição:
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// repetir com 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 — 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 os valores de erro unidos permanecem totalmente compatíveis com padrões padrão de verificação de erros.
Quando não usar errors.Join
Não use errors.Join quando há um erro principal e algum contexto de log.
Não o use para evitar decidir qual erro importa.
Não retorne erros unidos gigantes para os usuários.
Erros unidos são úteis, mas podem se tornar ruidosos rapidamente.
Panic não é tratamento de erro
Em 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 faça panic porque uma consulta ao banco de dados falhou ou um usuário enviou entrada inválida.
Esses são erros normais.
Logando erros
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:
- encapsular erros conforme eles sobem
- registrar uma vez no limite onde o erro é tratado
- incluir 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 erros 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 log de erros em nível de limite.
Quando registrar dentro de camadas mais baixas
Registre dentro de camadas mais baixas 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 repetição pode registrar cada tentativa de repetição em nível de depuração 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 ao 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 ao usuário:
internal server error
Log do 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 erros os mantém separados:
- erro diagnóstico interno
- resposta segura para o usuário
- código de erro de API estável
- contexto de log do operador
Forçar uma string de erro a servir a todos esses públicos-alvos produz ou um risco de exposição ou um pesadelo de depuração. Projete sua arquitetura de erros em torno de valores distintos para consumidores distintos.
Tratamento de erros 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 arquivos
- tokens de acesso
- chaves de API
- rastros de pilha (stack traces)
- dados privados de clientes
- detalhes de políticas 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 de forma segura para os operadores. Retorne uma mensagem segura ao usuário.
Códigos de erro
Para APIs públicas, códigos de erro estáveis são frequentemente melhores do que depender apenas de 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 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.
- Encapsula 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ócios.
- Cria erros de domínio.
- Preserva erros de domínio conhecidos.
- Encapsula 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 em isolamento.
Exemplo completo
Aqui está um pequeno exemplo de ponta a ponta.
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 fornece a você:
- erros de domínio
- tradução de armazenamento
- contexto de serviço
- mapeamento HTTP seguro
- cadeias de erros inspecionáveis
- sem correspondência de strings
- sem vazamento de transporte para código de domínio
Esse é o tipo de arquitetura de erros que escala — simples o suficiente para um novo colaborador 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 ficam escondidos por mais tempo. Para um guia completo sobre estrutura de testes Go, mocking 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 erros encapsulados automaticamente e não quebram quando as mensagens são reformuladas ou localizadas.
Anti-padrão 2: Perder a causa
Ruim:
return errors.New("query failed")
Melhor:
return fmt.Errorf("query user: %w", err)
Anti-padrão 3: Encapsulamento sem significado
Ruim:
return fmt.Errorf("error happened: %w", err)
Encapsule com contexto de operação que explique 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: Logando 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 aos usuários e registre o erro interno completo com contexto estruturado para os operadores. Nunca exponha strings de conexão de banco de dados, caminhos de arquivos ou rastros de pilha brutos em respostas de API.
Anti-padrão 7: Muitos sentinela exportados
Erros exportados fazem 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 o fluxo com base nela — prefira manter sentinela não exportados até que haja uma necessidade clara.
Anti-padrão 8: Usando panic para falhas esperadas
Ruim:
panic(err)
para falhas normais de tempo de execução. 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 genuína de operação e uma solicitação cancelada ou com tempo limite, e responda adequadamente a cada um. Para um tratamento completo de como o cancelamento de contexto e a propagação de tempo limite funcionam através das camadas de serviço, veja Go context.Context Done Right.
Checklist de revisão de erros
Use este checklist na revisão de código.
Criação de erro
- Esta é uma condição conhecida?
- Deveria ser um sentinela?
- Precisa de dados estruturados?
- Deveria ser um tipo personalizado?
- A mensagem de erro é clara?
Encapsulamento de erro
- O encapsulamento 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 ruidosa?
Tradução de erro
- Um erro de baixo nível está traduzido no limite certo?
- O comportamento específico do banco de dados está escondido 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 são tratados corretamente?
- Erros repetíveis são identificados explicitamente?
- Erros de validação são estruturados?
Log
- O erro é registrado uma vez, no limite de tratamento?
- Os logs são estruturados?
- Detalhes sensíveis são excluídos das respostas do usuário?
- Há contexto suficiente para os operadores?
Testes
- Casos de erro conhecidos são testados?
- Mapeamentos HTTP ou CLI são testados?
- Detalhes de validação são testados?
- Decisões de repetição são testadas?
Minhas regras opinativas
Regra 1: Erros devem cruzar limites com significado
Não passe apenas erros de um lugar para outro. Decida o que eles significam em cada camada.
Regra 2: Encapsule para contexto, não para decoração
Se o encapsulamento não adicionar informações úteis sobre qual operação falhou, não encapsule. Uma camada extra de contexto sem significado torna a cadeia de erros 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 se tornar parte da sua lógica de negócios. 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 o fluxo com base no tipo de falha, use sentinela, 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
Encapsule conforme os erros sobem. Registre onde o erro é finalmente tratado.
Regra 6: Mantenha as mensagens do usuário seguras
Erros de diagnóstico interno são para logs. Mensagens voltadas ao usuário são para usuários.
Regra 7: Mantenha erros de transporte no limite de transporte
Códigos de status HTTP pertencem a manipuladores ou adaptadores de API, não a 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:
retornar erros
encapsular com %w
verificar com errors.Is
extrair com errors.As
unir quando vários erros importam
A arquitetura é a parte mais difícil:
traduzir nos limites
preservar causas
esconder internos dos usuários
registrar uma vez
testar falhas conhecidas
Esse é o tratamento de erros em Go feito bem — 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.