← Voltar para o blog

errors.Join em Go: Agregando Múltiplos Erros em Produção

Aprenda errors.Join em Go para agregar múltiplos erros: multi-error pattern, errors.Is/As com Join, validação batch, coleta de falhas em pipelines e boas práticas de logging estruturado.

Validar um formulário e parar no primeiro erro é frustrante para quem consome a API. Rodar um pipeline de ingestão e abortar na primeira falha esconde dezenas de problemas que iam aparecer um a um nas próximas execuções. Fechar vários recursos e registrar só o último erro disfarça a causa original. Em todos esses casos, o problema não é o erro individual, mas a falta de uma forma limpa de juntar vários erros em um só valor.

A partir do Go 1.20, a biblioteca padrão oferece errors.Join para exatamente isso. Este guia mostra como usar errors.Join e padrões de multi-error em serviços reais, complementando o guia definitivo de tratamento de erros, graceful shutdown em Go, idempotência com retry e DLQ e slog para logging estruturado.

Por que agregar erros muda a qualidade do sistema

O comportamento padrão em Go é retornar no primeiro erro:

func processarPedido(p Pedido) error {
    if err := validarEstoque(p); err != nil {
        return err
    }
    if err := reservarPagamento(p); err != nil {
        return err
    }
    if err := emitirNota(p); err != nil {
        return err
    }
    return nil
}

Esse padrão é seguro e aparece em todo código Go. Ele só passa a atrapalhar quando o chamador se beneficiaria de ver todas as falhas de uma vez. Pense em uma API que recebe um JSON com cinco campos inválidos: retornar só o primeiro obriga o cliente a fazer cinco round-trips para descobrir todos os problemas. Pense em um job noturno que processa mil registros: abortar no primeiro registro ruim adia a descoberta dos outros 999 problemas.

errors.Join resolve isso sem inventar uma estrutura nova. Ele recebe zero ou mais erros e devolve um único erro que representa todos os não-nil.

O básico do errors.Join

A assinatura é deliberadamente simples:

func Join(errs ...error) error

Se todos os argumentos forem nil, o retorno é nil. Se pelo menos um for não-nil, o retorno é um erro cujo método Error() concatena as mensagens separadas por nova linha:

err1 := errors.New("nome é obrigatório")
err2 := errors.New("email inválido")
err3 := errors.New("cpf com formato errado")

err := errors.Join(err1, err2, err3)
fmt.Println(err)
// nome é obrigatório
// email inválido
// cpf com formato errado

O detalhe importante é que errors.Join ignora nil automaticamente. Você não precisa filtrar antes:

err := errors.Join(
    validarNome(input.Nome),       // pode retornar nil
    validarEmail(input.Email),     // pode retornar nil
    validarCPF(input.CPF),         // pode retornar nil
    validarEndereco(input.End),    // pode retornar nil
)
if err != nil {
    return err
}

Se nenhuma validação falhar, err é nil e o fluxo continua. Esse é o multi-error pattern idiomático em Go moderno.

errors.Is e errors.As funcionam com Join

A parte que costuma surpreender é que o erro retornado por errors.Join implementa a interface Unwrap() []error. Isso significa que errors.Is e errors.As atravessam todos os erros agregados, não só o primeiro:

var ErrEstoqueInsuficiente = errors.New("estoque insuficiente")

func main() {
    err := errors.Join(
        ErrEstoqueInsuficiente,
        fmt.Errorf("pagamento recusado: %w", ErrCartaoRecusado),
    )

    if errors.Is(err, ErrEstoqueInsuficiente) {
        fmt.Println("há problema de estoque")
    }
    if errors.Is(err, ErrCartaoRecusado) {
        fmt.Println("há problema de pagamento")
    }
}

Os dois errors.Is retornam verdadeiro. Isso é fundamental: o chamador não precisa saber que o erro veio de um Join. Ele continua inspecionando com sentinelas e tipos como faz com qualquer erro envelopado. O mesmo vale para errors.As quando você usa erros tipados carregando metadados como código de status HTTP ou ID de correlação.

Validação batch: o caso de uso mais comum

O cenário onde errors.Join brilha é validação que deve coletar todas as falhas antes de responder. Em vez de encadear if err != nil { return err }, acumule:

func validarUsuario(u UsuarioRequest) error {
    var errs []error

    if strings.TrimSpace(u.Nome) == "" {
        errs = append(errs, errors.New("nome é obrigatório"))
    }
    if !emailValido(u.Email) {
        errs = append(errs, fmt.Errorf("email inválido: %s", u.Email))
    }
    if u.Idade < 0 || u.Idade > 130 {
        errs = append(errs, fmt.Errorf("idade fora do intervalo: %d", u.Idade))
    }
    if u.Senha != "" && len(u.Senha) < 8 {
        errs = append(errs, errors.New("senha deve ter ao menos 8 caracteres"))
    }

    return errors.Join(errs...)
}

O handler HTTP chama uma vez e devolve a lista inteira para o cliente:

if err := validarUsuario(input); err != nil {
    respondWithError(w, http.StatusBadRequest, err)
    return
}

A diferença para o usuário da API é enorme: em vez de cinco chamadas para descobrir cinco erros, uma chamada revela todos. Em APIs públicas, isso reduz carga no backend e melhora a experiência de integração. Combine com OpenAPI para documentar o contrato e os clientes saberão exatamente o que esperar.

Coleta de falhas em pipelines e workers

Jobs que processam múltiplos itens independentes costumam se beneficiar de continuar processando mesmo quando um item falha. O padrão é acumular erros e registrar o contexto de cada um:

func importarRegistros(ctx context.Context, regs []Registro) error {
    var errs []error

    for i, r := range regs {
        if err := ctx.Err(); err != nil {
            return fmt.Errorf("importação cancelada no item %d: %w", i, err)
        }
        if err := importarUm(ctx, r); err != nil {
            errs = append(errs, fmt.Errorf("item %d (%s): %w", i, r.ID, err))
        }
    }

    return errors.Join(errs...)
}

Duas decisões importantes nesse padrão. Primeiro, o erro de cada item recebe contexto (índice e ID) via fmt.Errorf com %w antes de entrar na lista, então o log final mostra exatamente quais itens falharam e por quê. Segundo, o cancelamento de contexto é tratado à parte e retorna imediatamente, porque um job cancelado não deve continuar processando — esse comportamento está alinhado com o que discutimos em context.Context com timeout e cancelamento.

O chamador decide o que fazer com o erro agregado. Um job noturno pode registrar tudo via slog e seguir; uma API síncrona pode retornar 207 Multi-Status ou um 422 com a lista detalhada.

Erros tipados agregados com errors.As

Quando cada erro carrega dados estruturados, errors.As percorre o agregado e captura a primeira ocorrência do tipo. Para coletar todas as ocorrências, você precisa iterar manualmente sobre Unwrap() []error:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func colectValidationErrors(err error) []*ValidationError {
    var out []*ValidationError

    var target *ValidationError
    if errors.As(err, &target) {
        out = append(out, target)
    }

    // Percorre erros agregados por Join
    type joinUnwrapper interface{ Unwrap() []error }
    var uw joinUnwrapper
    if errors.As(err, &uw) {
        for _, e := range uw.Unwrap() {
            var t *ValidationError
            if errors.As(e, &t) {
                out = append(out, t)
            }
        }
    }

    return out
}

Esse padrão aparece quando você quer converter o erro agregado em uma resposta JSON estruturada por campo, em vez de uma lista de strings. É mais trabalhoso, mas mantém a fronteira entre domínio e transporte — o mesmo princípio de separar DTO de entidade que vale para JSON em APIs Go.

Fechamento de múltiplos recursos

Outro caso clássico é fechar vários recursos e não mascarar o primeiro erro ao fechar o segundo. O padrão defer ingênuo perde erros:

// Problemático: só o último Close sobrevive
defer f1.Close()
defer f2.Close()
defer f3.Close()

Se f1.Close() falhar, você nunca saberá, porque f2.Close() roda depois e o defer só propaga o último. Com errors.Join você coleta todos:

func processarArquivos(caminhos []string) (errRet error) {
    var arquivos []*os.File
    defer func() {
        var errs []error
        for _, f := range arquivos {
            if err := f.Close(); err != nil {
                errs = append(errs, fmt.Errorf("ao fechar %s: %w", f.Name(), err))
            }
        }
        errRet = errors.Join(errRet, errors.Join(errs...))
    }()

    for _, c := range caminhos {
        f, err := os.Open(c)
        if err != nil {
            return fmt.Errorf("ao abrir %s: %w", c, err)
        }
        arquivos = append(arquivos, f)
        // ... processa
    }
    return nil
}

A linha-chave é errors.Join(errRet, errors.Join(errs...)): ela combina o erro principal do processamento com quaisquer erros de fechamento, sem que um esconda o outro. Esse padrão é especialmente útil em shutdown gracioso onde você precisa fechar conexões de banco, filas e HTTP servers e registrar todas as falhas.

logging estruturado de erros agregados

Quando o erro agregado chega ao ponto de logging, registre-o de forma que preserve a estrutura. Com slog:

if err := importarRegistros(ctx, regs); err != nil {
    slog.Error("importação concluída com falhas",
        "err", err,
        "total_registros", len(regs),
    )
}

O método Error() do erro agregado concatena com nova linha, o que é legível em logs texto. Se você quiser extrair os erros individuais para um campo estruturado, use o mesmo padrão de iterar sobre Unwrap() []error mostrado acima. O importante é não registrar só err.Error() quando o contexto agregado importa — o número de sub-erros é informação tão valiosa quanto as mensagens.

Quando NÃO usar errors.Join

errors.Join não é substituto universal de return err. Os casos onde agregar faz sentido são específicos:

  • Validação onde o chamador quer ver todas as falhas.
  • Pipelines e workers onde itens são independentes e vale a pena continuar.
  • Fechamento de recursos onde múltiplos Close podem falhar.
  • Operações paralelas com errgroup onde você quer a árvore inteira de falhas.

Nos demais casos, o padrão if err != nil { return err } continua sendo o correto. Agregar erros onde não há benefício só adiciona ruído e dificulta o tratamento específico no chamador. Em particular, dentro de uma transação de banco de dados, abortar no primeiro erro é quase sempre o comportamento desejado — retomar com erros acumulados não faz sentido quando você já vai fazer rollback. Releia transações PostgreSQL com locks e retry antes de agregar erros dentro de uma unidade de trabalho transacional.

Boas práticas resumidas

  • Use errors.Join quando o chamador se beneficia de ver todas as falhas, não apenas a primeira.
  • Adeque contexto a cada erro (fmt.Errorf com %w) antes de agregá-lo, para preservar a trilha de onde falhou.
  • Confie em errors.Is e errors.As para inspecionar agregados — eles atravessam Unwrap() []error automaticamente.
  • Para converter erros agregados em estrutura (JSON por campo, lista de códigos), itere manualmente sobre Unwrap() []error.
  • Combine com context.Context para cancelamento: erros de cancelamento retornam imediatamente, erros de domínio são acumulados.
  • Não agregue dentro de transações onde o rollback exige abortar no primeiro erro.
  • Registre erros agregados com logging estruturado, preservando o número de sub-erros e o contexto de cada um.

Erros agregados bem usados transformam APIs frustrantes em APIs informativas e jobs opacos em jobs depuráveis. A biblioteca padrão já entrega a ferramenta; o trabalho é decidir onde coletar faz sentido e onde retornar cedo continua sendo a escolha certa. Frameworks mudam, mas os padrões de contrato de erro são os mesmos — se você trabalha em múltiplas stacks, vale comparar com como o Python Dev Brasil aborda tratamento de erros em serviços backend.