Arquitetura de Tratamento de Erros em Go: Fronteiras e Padrões

Trate os erros na fronteira correta.

Conteúdo da página

Tratar erros no Go é fácil de criticar. Todo desenvolvedor Go já escreveu este 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, onde deve ser traduzido, onde deve ser registrado em logs e o que deve ser exposto ao chamador — essa é a questão de arquitetura.

O 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, os handlers HTTP vazam detalhes do banco de dados, os logs duplicam a mesma falha cinco vezes, as tentativas de repetição acontecem pelos motivos errados e os chamadores inspecionam texto em vez de comportamento.

Arquitetura de tratamento de erros no Go: erros fluindo entre camadas

Este artigo não é uma introdução para iniciantes sobre if err != nil.

É um guia prático para a arquitetura de tratamento de erros no Go: envolvimento (wrapping), sentinéis, tipos de erro personalizados, errors.Is, errors.As, fronteiras de erro, mapeamento de API, logs, repetições (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 na fronteira certa.

O que são erros no Go

No Go, um erro é apenas um valor que implementa esta interface:

type error interface {
	Error() string
}

Essa pequena interface é o motivo pelo qual o tratamento de erros no 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 nem desempilhamento de pilha oculto. A falha é parte da assinatura da função.

Isso é bom, mas também significa que os erros precisam de design. Se todos os pacotes retornam mensagens arbitrárias, os chamadores não podem tomar decisões confiáveis. Se todas as camadas envolvem todos os erros sem disciplina, os operadores recebem mensagens ruidosas e os desenvolvedores recebem cadeias confusas. Se nenhuma camada envolve erros, as falhas perdem contexto.

O objetivo não é menos tratamento de erros, mas sim um significado de erro melhor.

Os três papéis de um erro

Um erro útil geralmente tem um ou mais papéis.

Papel 1: Explicar o que falhou

Para humanos, o erro deve explicar qual operação falhou.

Exemplo:

return fmt.Errorf("carregar usuário %s: %w", id, err)

Isso fornece contexto. Diga que a falha ocorreu durante o carregamento de um usuário.

Papel 2: Preservar a causa

Para o código, o erro deve preservar a causa subjacente quando essa causa importa.

Exemplo:

return fmt.Errorf("carregar usuário %s: %w", id, err)

O %w envolve o erro original para que os chamadores possam inspecioná-lo com errors.Is ou errors.As. O envolvimento tem uma nova armadilha no Go 1.27: a verificação de vetor (vet check) por trás de go test agora emite um aviso sobre fmt.Errorf("...: %w", p) quando p é um ponteiro para um tipo que ele mesmo implementa error, porque a indireção extra quebra as asserções de tipo e o correspondência do errors.Is. O Guia de atualização para Go 1.27 cobre isso e as outras descobertas de vet que surgem após uma atualização da ferramenta de compilação.

Papel 3: Permitir que uma fronteira tome uma decisão

Em alguma fronteira, o programa deve decidir o que fazer.

Exemplos:

  • Retornar HTTP 404
  • Retornar HTTP 409
  • Repetir a operação
  • Registrar em log no 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 geralmente deve ser baseada em identidade ou tipo de erro, não em correspondência de strings.

As principais ferramentas de erro no Go moderno

O Go moderno oferece um conjunto pequeno, porém poderoso, de ferramentas.

errors.New

Use errors.New para criar um valor de erro simples:

var ErrNotFound = errors.New("não encontrado")

Isso é útil para erros sentinelas.

fmt.Errorf com %w

Use fmt.Errorf com %w para envolver um erro:

return fmt.Errorf("consultar usuário: %w", err)

O envolvimento adiciona contexto, preservando 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 da sua cadeia:

if errors.Is(err, ErrNotFound) {
	// tratar não encontrado
}

Use isso para erros sentinelas 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 combinados ainda podem ser inspecionados com errors.Is e errors.As.

Use isso com cuidado. Um erro combinado significa que várias falhas fazem parte de um resultado.

Erros sentinelas

Um erro sentinela é um valor de erro no nível do pacote que representa uma condição conhecida.

Exemplo:

var ErrUserNotFound = errors.New("usuário não encontrado")
var ErrDuplicateEmail = errors.New("e-mail duplicado")

Erros sentinelas 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("consultar usuário: %w", err)
	}

	return user, nil
}

Então um serviço ou handler pode verificar:

if errors.Is(err, ErrUserNotFound) {
	// retornar 404
}

Quando usar erros sentinelas

Use erros sentinelas quando:

  • A condição é estável.
  • O chamador precisa ramificar com base nela.
  • Nenhum dado estruturado extra é necessário.
  • O erro pertence ao seu pacote ou domínio.

Bons exemplos:

var ErrNotFound = errors.New("não encontrado")
var ErrAlreadyExists = errors.New("já existe")
var ErrPermissionDenied = errors.New("permissão negada")
var ErrConflict = errors.New("conflito")

Quando não usar erros sentinelas

Não crie sentinéis para cada falha possível.

Ruim:

var ErrCouldNotOpenFile = errors.New("não foi possível abrir o arquivo")
var ErrCouldNotReadFile = errors.New("não foi possível ler o arquivo")
var ErrCouldNotParseLine = errors.New("não foi possível analisar a linha")

Se os chamadores não ramificarem com base neles, eles podem ser apenas mensagens.

Também tenha cuidado ao exportar muitos sentinéis. Erros sentinelas exportados se tornam 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("falha na validação para %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 possui campos significativos.
  • O tipo faz parte do contrato do seu pacote.
  • O chamador pode precisar tratar múltiplos valores de maneira 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 "não encontrado"
}

Se não houver dados úteis, um sentinela geralmente é suficiente.

Envolvimento de erros (Error wrapping)

O envolvimento adiciona contexto a um erro preservando o erro original.

Exemplo:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("ler configuração %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("analisar configuração %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 consistente e valioso.

Envolva com contexto útil

Um bom envolvimento diz qual operação falhou:

return fmt.Errorf("criar fatura %s: %w", invoiceID, err)

Um mau envolvimento adiciona ruído:

return fmt.Errorf("erro: %w", err)

Isso não diz nada ao chamador.

Evite também repetir o mesmo substantivo em cada camada:

return fmt.Errorf("serviço de usuário: obter usuário: repositório de usuário: consultar usuário: %w", err)

Esse tipo de cadeia é tecnicamente correto e praticamente irritante.

Envolva onde o contexto muda o significado. Se você não consegue explicar em uma frase qual operação falhou, provavelmente está envolvendo agressivamente demais ou não o suficiente.

Quando envolver e quando não envolver

Esta é uma das decisões de arquitetura mais importantes.

Envolve ao cruzar uma fronteira significativa

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("obter usuário %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("falhou: %w", err)
}

A palavra “falhou” geralmente está implícita pelo fato de um erro existir.

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 intencionalmente oculta 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ê 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ública, o envolvimento pode expor detalhes de implementação como parte do seu contrato.

Por exemplo, se seu pacote envolver 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ê pode mudar o armazenamento mais tarde, prefira um sentinela de domínio:

var ErrUserNotFound = errors.New("usuário não encontrado")

Então retorne isso da fronteira do pacote.

Fronteiras de erro (Error boundaries)

A maneira mais útil de pensar sobre o tratamento de erros no Go é através de fronteiras.

Uma fronteira é um local onde um erro muda de significado ou de público.

Fronteiras comuns incluem:

  • banco de dados para repositório
  • repositório para serviço
  • serviço para handler HTTP
  • serviço para comando CLI
  • erro interno para mensagem voltada para o 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 fronteiras. Cada fronteira é um ponto de decisão onde os erros ganham contexto, perdem detalhes de implementação ou são traduzidos para uma forma que a próxima camada pode processar.

Fronteira de 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("usuário não encontrado")
var ErrDuplicateEmail = errors.New("e-mail duplicado")

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("consultar usuário por id: %w", err)
	}

	return &user, nil
}

O repositório oculta sql.ErrNoRows e expõe ErrUserNotFound — uma fronteira limpa que significa que o serviço não precisa saber nada sobre como o armazenamento representa “não encontrado”.

Fronteira de serviço

O serviço detém o significado de negócio.

Ele geralmente deve adicionar contexto da 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("obter usuário %s: %w", id, err)
	}

	return user, nil
}

Isso preserva a condição de domínio, adicionando 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("conta desativada")

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("obter usuário por e-mail: %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.

Fronteira de handler HTTP

O handler HTTP traduz erros de aplicação em respostas HTTP.

Esta é uma fronteira onde detalhes internos devem se tornar respostas seguras para o usuário.

Os frameworks web diferem na forma como estruturam essa tradução. O Echo roteia erros do handler através de um único HTTPErrorHandler, enquanto os handlers Gin renderizam erros diretamente no contexto da solicitação — as vantagens e desvantagens entre os dois estilos são comparadas em Frameworks Web em Go em 2026: net/http, chi, Gin, Echo, Fiber Comparados.

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, "usuário não encontrado", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "conta desativada", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "tempo limite da solicitação excedido", http.StatusGatewayTimeout)

	default:
		http.Error(w, "erro interno do servidor", http.StatusInternalServerError)
	}
}

O handler mapeia erros de domínio para semântica HTTP, em vez de expor detalhes crus do banco de dados ou do erro interno. É aqui que muitos aplicativos Go dão errado — eles expõem demais detalhes internos ou colapsam todos os erros em HTTP 500. Para uma visão completa de padrões de handler e middleware em APIs Go, Construindo APIs REST em Go cobre autenticação, roteamento e tratamento de erros através da biblioteca padrão, Gin, Echo e Fiber.

Fronteira CLI

Um CLI tem uma fronteira 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("importar %s: %w", args[0], err)
	}

	return nil
}

Na fronteira 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 geralmente 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 tipo de erro de 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",
	"e-mail já está registrado",
	ErrDuplicateEmail,
)

Handler:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "erro interno do servidor",
	})
}

Esse padrão é útil quando você quer erros de API estruturados com códigos estáveis.

Use na fronteira da API. Não force todos os pacotes internos a retornarem 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("saldo insuficiente")

Mapeamento de transporte:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "saldo insuficiente", 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 de forma limpa através de HTTP, CLI, workers, testes e futuros adaptadores gRPC. O mapeamento de transporte pertence à fronteira de transporte, não ao código de domínio. Para orientação sobre onde definir erros de domínio, sentinéis e adaptadores de transporte dentro do layout do seu projeto, Estrutura de Projeto Go: Práticas & Padrões cobre as convenções de internal/, pkg/ e adaptadores que mantêm essas camadas separadas de forma limpa.

Erros repetíveis (Retryable errors)

Alguns erros devem acionar repetição. Outros não.

Não decida isso correspondendo strings.

Use uma interface de marcador ou uma 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("chamar api remota: %w", err))
	}

	return fmt.Errorf("chamar api remota: %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 um acoplamento invisível entre produtor e consumidor.

Erros de validação

Erros de validação geralmente precisam de dados estruturados.

Exemplo:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "falha na validação"
}

Uso:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "e-mail é obrigatório",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

Handler:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

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
  • lavando 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("fechar recursos: %w", err)
}

Tanto errors.Is quanto errors.As podem inspecionar erros combinados, o que significa que valores de erro combinados permanecem totalmente compatíveis com os padrões padrão de verificação de erros.

Quando não usar errors.Join

Não use errors.Join quando há um erro primário e algum contexto de log.

Não use para evitar decidir qual erro importa.

Não retorne grandes erros combinados para usuários.

Erros combinados são úteis, mas podem se tornar ruidosos rapidamente.

Panic não é tratamento de erros

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 invariância interna impossível
  • inicialização de pacote inválida
  • falha de auxiliar de teste com t.Fatal ou panic em casos limitados
  • erro de configuração de inicialização irrecuperável, dependendo do estilo

Não use panic porque uma consulta de banco de dados falhou ou um usuário enviou entrada inválida.

Esses são erros normais.

Registrando erros em logs

Um erro comum no Go é registrar o mesmo erro em todas as camadas.

Ruim:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("consulta falhou: %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("serviço falhou: %v", err)
		return nil, err
	}
	return user, nil
}

Isso cria logs duplicados para uma falha.

Melhor:

  • envolva erros conforme eles sobem
  • registre uma vez na fronteira onde o erro é tratado
  • inclua contexto estruturado no log

Exemplo:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"solicitação falhou",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

Isso fornece um evento de log com a cadeia de erros completa. Para uma configuração de log estruturado pronta para produção, Logging Estruturado em Go com slog cobre registros log/slog, handlers JSON, correlação de contexto e redação — todos os quais se combinam naturalmente com a logging de erros em nível de fronteira.

Quando registrar em camadas inferiores

Registre em camadas inferiores apenas quando a camada está realmente tratando o erro ou adicionando contexto operacional importante que não será visível em outros lugares.

Por exemplo, um loop de repetição pode registrar cada tentativa de repetição no nível de depuração ou aviso.

Mas um repositório não deve registrar cada erro de consulta se o handler 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:

consultar usuário por id: dial tcp 10.0.4.12:5432: conexão recusada

Mensagem voltada para o usuário:

erro interno do servidor

Log de operador:

solicitação falhou err="obter usuário 123: consultar usuário por id: dial tcp 10.0.4.12:5432: conexão recusada"

Estes são públicos diferentes, e uma boa arquitetura de erros 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 do operador

Forçar uma string de erro a atender a todos esses públicos produz 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 do banco de dados
  • consultas SQL com segredos
  • nomes de host internos
  • caminhos de arquivo
  • tokens de acesso
  • chaves de API
  • rastros 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, "erro interno do servidor", http.StatusInternalServerError)

Registre o erro interno com segurança para operadores. Retorne uma mensagem segura ao 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": "usuário não encontrado"
}

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 clientes analisar mensagens de erro em inglês.

Um design de erros em camadas prático

Aqui está um padrão limpo para muitos serviços backend em Go.

Camada de repositório

  • Fala com banco de dados ou armazenamento externo.
  • Converte erros específicos de armazenamento “não encontrado” para erros de domínio.
  • Envolvem erros inesperados de armazenamento com contexto da operação.
  • Não retorna erros HTTP.
  • Geralmente não registra em log.

Exemplo:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("consultar usuário por id: %w", err)

Camada de serviço

  • Detém regras de negócio.
  • Cria erros de domínio.
  • Preserva erros de domínio conhecidos.
  • Envolvem erros inferiores inesperados.
  • Não retorna códigos de status HTTP.
  • Geralmente não registra em log.

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.
  • Oculta 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", "usuário não encontrado")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "erro interno do servidor")
}

Essa separação mantém o tratamento de erros compreensível e permite que cada camada evolua independentemente — você pode mudar a tecnologia de armazenamento sem tocar na lógica do serviço ou no mapeamento do transporte. O design em camadas funciona melhor quando as dependências são injetadas em vez de codificadas diretamente; Injeção de Dependência em Go: Padrões & Melhores Práticas cobre os padrões de construtor e interface que tornam cada fronteira fácil de testar isoladamente.

Exemplo completo

Aqui está um pequeno exemplo de ponta a ponta.

Erros de domínio:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("usuário não encontrado")
	ErrDuplicateEmail = errors.New("e-mail duplicado")
	ErrAccountDisabled = errors.New("conta desativada")
)

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("consultar usuário por 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("obter perfil para usuário %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

Handler HTTP:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "usuário não encontrado",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "conta está desativada",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "tempo limite da solicitação excedido",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "erro interno do servidor",
		})
	}
}

Essa estrutura oferece:

  • erros de domínio
  • tradução de armazenamento
  • contexto de serviço
  • mapeamento seguro HTTP
  • cadeias de erros inspecionáveis
  • sem correspondência de strings
  • sem vazamento de transporte para o 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 erros

O comportamento de erros deve ser testado tão rigorosamente quanto o caminho feliz, porque as decisões de fronteira — 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 testes Go, mocks e padrões de cobertura, veja Teste Unitário em Go: Estrutura & Melhores Práticas.

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("recebido %v, esperado 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("recebido %T, esperado ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("esperados campos de validação")
	}
}

Testar mapeamento HTTP

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, esperado %d", rec.Code, http.StatusNotFound)
	}
}

Os testes devem provar que erros conhecidos produzem o comportamento correto em cada fronteira, para que refatorar camadas de armazenamento ou transporte não possa alterar silenciosamente o contrato de falha.

Antipadrões comuns

Antipadrã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 envolvidos automaticamente e não quebram quando as mensagens são reformuladas ou localizadas.

Antipadrão 2: Perder a causa

Ruim:

return errors.New("consulta falhou")

Melhor:

return fmt.Errorf("consultar usuário: %w", err)

Antipadrão 3: Envolvimento sem significado

Ruim:

return fmt.Errorf("erro aconteceu: %w", err)

Envolve com contexto da operação que explica o que estava sendo tentado, como "criar fatura %s: %w", em vez de um prefixo vago que não adiciona valor de diagnóstico.

Antipadrão 4: Log 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.

Antipadrã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 na fronteira do handler, mantendo sua camada de serviço independente de preocupações de transporte.

Antipadrão 6: Expondo erros internos para 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 rastros de pilha crus em respostas de API.

Antipadrão 7: Muitos sentinéis exportados

Erros exportados são parte da API do seu pacote, e adicioná-los o compromete a mantê-los. Não exporte cada condição interna a menos que os chamadores externos genuinamente precisem ramificar com base nela — prefira manter os sentinéis não exportados até que haja uma necessidade clara.

Antipadrã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 entradas de usuário inválidas — sempre retorne erros nesses casos.

Antipadrão 9: Ignorando erros de contexto

Ruim:

return fmt.Errorf("solicitação falhou")

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 excedido, e respondam adequadamente a cada um. Para um tratamento aprofundado de como o cancelamento de contexto e a propagação de tempo limite funcionam através das camadas do serviço, veja Go context.Context Correto.

Checklist de revisão de erros

Use esta lista de verificação na revisão de código.

Criação de erros

  • 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 erros

  • O envolvimento adiciona contexto útil da operação?
  • O %w preserva a causa onde necessário?
  • O código está acidentalmente expondo detalhes de implementação?
  • A cadeia está muito ruidosa?

Tradução de erros

  • Um erro de baixo nível é traduzido na fronteira certa?
  • O comportamento específico do banco de dados está oculto do código do serviço?
  • Os erros de domínio são independentes de preocupações HTTP ou CLI?

Tratamento de erros

  • O chamador ramifica com errors.Is ou errors.As?
  • O cancelamento de contexto e os prazos são tratados corretamente?
  • Erros repetíveis são identificados explicitamente?
  • Erros de validação são estruturados?

Logs

  • O erro é registrado uma vez, na fronteira de tratamento?
  • Os logs são estruturados?
  • Detalhes sensíveis são excluídos das respostas ao usuário?
  • Há contexto suficiente para 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: Os erros devem cruzar fronteiras com significado

Não passe erros apenas para frente e para trás. Decida o que eles significam em cada camada.

Regra 2: Envolva para contexto, não para decoração

Se o envolvimento não adiciona informações úteis sobre qual operação falhou, não envolva. Uma camada extra de contexto sem significado torna a cadeia de erros mais difícil de ler e não adiciona valor de 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ócio. Traduza erros de implementação para erros de domínio na fronteira de armazenamento, para que o restante da aplicação nunca precise saber qual banco de dados ou ORM está por baixo.

Regra 4: Não analise strings de erro

Se o código precisa ramificar com base no tipo de falha, use sentinéis, tipos personalizados, errors.Is ou errors.As. A inspeção de strings cria um acoplamento invisível que falha silenciosamente quando as mensagens de erro mudam.

Regra 5: Registre uma vez

Envolva conforme os erros sobem. Registre onde o erro é finalmente tratado.

Regra 6: Mantenha mensagens do 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 na fronteira de transporte

Códigos de status HTTP pertencem a handlers ou adaptadores de API, não a serviços de domínio. O código de domínio deve ser reutilizável entre transportes — hoje HTTP, amanhã CLI, gRPC ou um worker orientado a eventos.

Pensamentos finais

O tratamento de erros no Go não é sobre escrever if err != nil para sempre — é sobre tornar a falha explícita e compreensível em cada fronteira.

A mecânica é simples:

retornar erros
envolver com %w
verificar com errors.Is
extrair com errors.As
combinar quando vários erros importam

A arquitetura é a parte mais difícil:

traduzir nas fronteiras
preservar causas
ocultar internos dos usuários
registrar uma vez
testar falhas conhecidas

Esse é o tratamento de erros no 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 seu eu 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 através de integração, testes e acesso a dados, veja Arquitetura de Aplicativo em Produção.

Fontes

Subscrever

Receba novos artigos sobre sistemas, infraestrutura e engenharia de IA.