trilha

Tecnologias/01 - Linguagens/01 - Go6 min

Erros, Interfaces e o Estilo Go

Conceito

Duas decisões dão ao código Go a cara que ele tem. As duas são sobre tornar visível o que outras linguagens escondem.

1. Erro é valor, não fluxo de controle

error é só uma interface de um método:

type error interface {
	Error() string
}

Erros esperados costumam aparecer como error no retorno. Go permite descartar resultados, inclusive ignorar o retorno de uma chamada; o compilador não obriga a tratar todo erro. Revisão, testes e análise estática complementam o contrato. Referência: chamadas usadas como instruções.

O ferramental moderno é o wrapping, que constrói uma cadeia de causa:

if err != nil {
	return fmt.Errorf("buscar usuário %d: %w", id, err) // %w preserva a causa
}

E do outro lado você inspeciona por identidade ou por tipo, nunca por texto:

var ErrNaoEncontrado = errors.New("não encontrado") // erro sentinela

errors.Is(err, ErrNaoEncontrado)  // é este erro (ou embrulha ele)?
errors.Is(err, sql.ErrNoRows)     // idem, atravessando o wrapping

var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // violação de unique
	...
}

Comparar err.Error() == "não encontrado" é a versão Go de catch (Exception e): compila, passa nos testes e quebra quando alguém melhorar a mensagem.

panic não é exceção. panic é para bug — índice fora do slice, invariante violada, configuração impossível na inicialização. Erro esperado (arquivo não existe, rede caiu, validação falhou) é error. A única exceção pragmática é recover na fronteira de um servidor, para que um bug num handler não derrube o processo inteiro — que é exatamente o que o net/http já faz por você.

2. Interface é implícita e pequena

Você não declara que implementa uma interface. Se o método existe com a assinatura certa, o tipo satisfaz — verificado em tempo de compilação:

type Repositório interface {                 // definida por QUEM CONSOME
	BuscarUsuário(ctx context.Context, id int) (*Usuário, error)
}

Isso inverte a dependência sem framework, sem anotação e sem injeção mágica: o pacote de serviço declara o que precisa; o pacote de Postgres apenas acontece de atender. Ver 3. Interfaces.

Daí saem duas máximas do estilo Go:

  • "Accept interfaces, return structs." Receba o mínimo que você usa; devolva o tipo concreto, para o chamador não perder informação.
  • Quanto menor, mais útil. io.Reader tem um método e é a interface mais reutilizada da história da linguagem. Uma interface com 15 métodos é uma classe abstrata com outro nome.

E a composição substitui a herança por embedding:

type ServiçoBase struct{ log *slog.Logger }
func (s ServiçoBase) Log() *slog.Logger { return s.log }

type ServiçoPedidos struct {
	ServiçoBase       // embedding: promove os métodos, NÃO cria subtipo
	repo Repositório
}

ServiçoPedidos ganha Log(), mas não é um ServiçoBase — não há polimorfismo por hierarquia, só por interface. Ver 4. Composição vs Herança.

Trade-offs

Ganho Preço
Caminho de erro visível na leitura linear if err != nil ocupa ~30% das linhas
Sem stack trace implícito, sem custo escondido você precisa construir o contexto no wrapping, ou perde a trilha
Interface implícita = zero acoplamento ao contrato achar "quem implementa isto" exige ferramenta, não grep
Sem herança = sem hierarquia frágil reuso de comportamento fica mais verboso
error é só uma interface qualquer coisa pode ser um erro; sem tipos-soma, o compilador não exige que você trate os casos

O ponto mais sentido na prática é o stack trace. Em Java você recebe o rastro de graça. Em Go, um err que sobe 8 camadas sem wrapping chega como "EOF" — verdadeiro e inútil. A disciplina compensa: embrulhe com o que você sabe e o chamador não sabe (qual id, qual operação), nunca com o que ele já sabe.

O trade-off central: Go trocou "tratar erro é opcional e invisível" por "tratar erro fica explícito no retorno". O ruído é o preço combinado — o que não é aceitável é pagar o ruído e escrever if err != nil { return err } sem contexto, porque aí você perdeu os dois lados.

Exemplo prático

Erros de domínio atravessando camadas com contexto, e o handler HTTP traduzindo para status — o padrão que substitui a árvore de exceções:

// domínio: erros sentinela, sem saber nada de HTTP ou de banco
var (
	ErrNaoEncontrado = errors.New("recurso não encontrado")
	ErrConflito      = errors.New("conflito de estado")
)

// repositório: traduz o erro do driver para o erro do domínio
func (r *PgRepo) BuscarUsuário(ctx context.Context, id int) (*Usuário, error) {
	var u Usuário
	err := r.db.QueryRowContext(ctx, `SELECT id, email FROM usuarios WHERE id = $1`, id).
		Scan(&u.ID, &u.Email)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		return nil, fmt.Errorf("usuário %d: %w", id, ErrNaoEncontrado)
	case err != nil:
		return nil, fmt.Errorf("consultar usuário %d: %w", id, err)
	}
	return &u, nil
}

// transporte: o ÚNICO lugar que conhece códigos HTTP
func status(err error) int {
	switch {
	case errors.Is(err, ErrNaoEncontrado):
		return http.StatusNotFound
	case errors.Is(err, ErrConflito):
		return http.StatusConflict
	case errors.Is(err, context.DeadlineExceeded):
		return http.StatusGatewayTimeout
	default:
		return http.StatusInternalServerError
	}
}

Repare no desenho: o sql.ErrNoRows não vaza para o handler, e o handler não conhece Postgres. Se amanhã o repositório for MongoDB, só a função de tradução muda. É a mesma lição de 2. Abstração e 1. Encapsulamento, obtida com interface implícita em vez de herança.

Como isso falha em produção

  • Erro engolido. _ = json.NewEncoder(w).Encode(v) — a resposta sai truncada e nada é registrado.
  • nil tipado. Uma interface que guarda um ponteiro nulo não é nil:
    func f() error { var e *MeuErro; return e } // e == nil, mas f() != nil
    if err := f(); err != nil { /* entra aqui! */ }
    Nunca declare o retorno como tipo concreto de erro; declare error.
  • defer no laço. defer rows.Close() dentro de um for só executa no fim da função — em 10 mil iterações, 10 mil cursores abertos.
  • Wrapping sem contexto. 8 camadas de return err e o log final diz "EOF".
  • Interface prematura. Uma interface por struct "para poder mockar" — o resultado é o dobro de arquivos e nenhum ganho. Ver 1. Code Smells.

Aprofundamento aplicado

Um ponteiro nil dentro de uma interface

type Falha struct{}
func (*Falha) Error() string { return "falha" }

func incorreto() error {
    var f *Falha
    return f
}

incorreto() == nil é falso: a interface contém um tipo dinâmico *Falha, mesmo com valor ponteiro nil. Para representar ausência de erro, retorne nil explicitamente. Esse problema aparece quando uma função muda de retorno concreto para error. Referência: interfaces e nil.

Quem decide o que fazer com o erro

A camada de banco acrescenta contexto sobre a operação; a regra decide se a ausência é esperada; a borda HTTP traduz para uma resposta. Registrar a mesma falha em todas as camadas produz vários logs para um incidente e dificulta contar falhas reais.

Exercício: represente “pedido inexistente” com um erro identificável e envolva-o com fmt.Errorf("buscar pedido %s: %w", id, err). Resultado esperado: errors.Is continua reconhecendo a causa, mesmo após a mensagem ganhar contexto. A resposta pública não precisa expor detalhes do driver.

Relacionado


Parte de MOC Go · go.dev/blog/error-handling-and-go

Buscar

Busca por título, seção e texto das notas