Chi Router é uma das melhores escolhas para construir API REST em Go quando você quer mais organização que o net/http puro, mas não quer abandonar o modelo idiomático de http.Handler. Ele adiciona parâmetros de URL, grupos, sub-roteadores e composição de middleware, mantendo handlers, testes e bibliotecas compatíveis com a standard library. Para APIs pequenas, o roteamento nativo do Go 1.22+ pode bastar; para serviços modulares, Chi costuma oferecer o meio-termo mais equilibrado.
Este guia mostra como usar github.com/go-chi/chi/v5 em uma API de produção: estrutura de projeto, rotas, parâmetros, grupos, middleware, JSON, erros, testes com httptest, timeouts, observabilidade e graceful shutdown. Se você ainda está escolhendo a stack, compare antes Gin, Echo, Fiber, Chi e net/http. Para rever os fundamentos do protocolo e dos handlers, comece pelo tutorial de API REST em Go.
Resposta rápida: quando escolher Chi
| Cenário | Escolha inicial |
|---|---|
| API pequena com poucas rotas no Go 1.22+ | net/http puro |
| API modular, grupos e middleware por recurso | Chi |
| Time quer binding, validação e convenções prontas | Gin ou Echo |
Biblioteca interna precisa aceitar http.Handler | Chi ou net/http |
| Migração gradual de uma API da standard library | Chi |
Equipe já padronizou gin.Context e middleware Gin | Continue com Gin |
| Throughput de “hello world” é o único argumento | Meça a aplicação real antes de escolher |
A principal vantagem do Chi não é “ser o router mais rápido”. É preservar o contrato do ecossistema HTTP de Go. Um handler Chi continua sendo um http.Handler; um middleware continua podendo ter a assinatura func(http.Handler) http.Handler; testes continuam usando httptest; instrumentações de net/http continuam encaixando sem reescrever a aplicação.
Instalação e servidor mínimo
Crie o módulo e instale a versão principal atual do pacote:
go mod init example.com/catalogo
go get github.com/go-chi/chi/v5
Um servidor mínimo pode usar recursos do Chi sem esconder a configuração importante do http.Server:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("servidor ouvindo em %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
Os timeouts precisam refletir o comportamento da aplicação. Não copie valores sem considerar upload, streaming ou endpoints longos. O ponto é não depender dos zeros do http.Server por acidente. Para encerrar o processo sem cortar requisições em andamento, use o padrão completo de graceful shutdown em Go.
Rotas, métodos e parâmetros de URL
Chi oferece métodos como Get, Post, Put, Patch e Delete, além de Method e Handle quando você precisa de controle explícito. Parâmetros declarados com {nome} são lidos por chi.URLParam:
func produtoPorID(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "produtoID")
if id == "" {
http.Error(w, "produto não informado", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"id": id,
"nome": "Teclado mecânico",
})
}
func routes() http.Handler {
r := chi.NewRouter()
r.Get("/produtos/{produtoID}", produtoPorID)
return r
}
Não confunda “a rota casou” com “o parâmetro é válido”. O router extrai texto; seu handler ou camada de entrada ainda precisa validar UUID, número, tamanho e formato. Se o ID for inteiro, use strconv.ParseInt e responda 400 Bad Request quando o valor for inválido. Se o recurso válido não existir, responda 404 Not Found.
Também evite colocar regras de negócio dentro da função HTTP. O handler deve traduzir protocolo: ler path, query, headers e body; chamar um caso de uso; mapear o resultado para status e JSON.
Organize a API com Route, Group, With e Mount
Em uma aplicação real, o valor do Chi aparece quando as rotas deixam de ser uma lista plana.
Route: prefixo e sub-roteador
r.Route("/api/v1/produtos", func(r chi.Router) {
r.Get("/", listarProdutos)
r.Post("/", criarProduto)
r.Get("/{produtoID}", buscarProduto)
r.Put("/{produtoID}", atualizarProduto)
r.Delete("/{produtoID}", removerProduto)
})
Group: conjunto isolado de middleware
r.Group(func(r chi.Router) {
r.Use(autenticar)
r.Get("/me", perfilAtual)
r.Get("/pedidos", listarMeusPedidos)
})
With: middleware só para uma rota ou pequeno conjunto
r.With(exigirPapel("admin")).Delete(
"/api/v1/usuarios/{usuarioID}",
removerUsuario,
)
Mount: módulo que já expõe http.Handler
r.Mount("/api/v1/produtos", produtoHandler.Routes())
r.Mount("/api/v1/pedidos", pedidoHandler.Routes())
Mount é útil em monólitos modulares: cada domínio constrói seu sub-router, recebe dependências pelo construtor e expõe apenas um http.Handler. Evite, porém, criar um router global importado por todos os pacotes. Isso esconde dependências, dificulta testes e transforma registro de rotas em efeito colateral.
Uma estrutura de projeto simples e testável
Não existe uma árvore obrigatória para Chi. Uma estrutura razoável para uma API média é:
cmd/api/main.go
internal/httpapi/router.go
internal/httpapi/respond.go
internal/produto/handler.go
internal/produto/service.go
internal/produto/repository.go
internal/postgres/produto_repository.go
A montagem fica explícita no main:
repo := postgres.NewProdutoRepository(db)
service := produto.NewService(repo, logger)
handler := produto.NewHandler(service, logger)
router := httpapi.NewRouter(handler, logger)
E o router recebe módulos prontos:
func NewRouter(produtos *produto.Handler, logger *slog.Logger) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(requestLogger(logger))
r.Get("/healthz", healthz)
r.Mount("/api/v1/produtos", produtos.Routes())
return r
}
Esse desenho combina com dependency injection em Go sem framework e mantém o bootstrap legível. Chi organiza a camada HTTP; ele não deve construir banco, carregar variável de ambiente ou decidir implementação de repositório.
JSON de entrada: limite, decode e validação
Chi não faz binding automático. Isso parece trabalho extra, mas deixa a fronteira explícita. Um decoder seguro deve limitar o corpo, rejeitar campos desconhecidos quando o contrato exigir e impedir múltiplos objetos JSON concatenados:
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("json inválido: %w", err)
}
if dec.Decode(&struct{}{}) != io.EOF {
return errors.New("o body deve conter um único objeto JSON")
}
return nil
}
No handler:
type criarProdutoInput struct {
Nome string `json:"nome"`
PrecoCent int64 `json:"preco_centavos"`
}
func (h *Handler) criar(w http.ResponseWriter, r *http.Request) {
var input criarProdutoInput
if err := decodeJSON(w, r, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if strings.TrimSpace(input.Nome) == "" || input.PrecoCent <= 0 {
writeError(w, http.StatusUnprocessableEntity, "validation_error", "nome e preço são obrigatórios")
return
}
produto, err := h.service.Create(r.Context(), input.Nome, input.PrecoCent)
if err != nil {
h.writeServiceError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, produto)
}
Para uma discussão completa sobre DTOs, números, streaming e contratos, leia JSON em Go com encoding/json. Não exponha structs internas do banco como contrato público só para economizar um tipo.
Respostas e erros consistentes
Centralize a escrita de JSON para não esquecer Content-Type nem escrever status depois do body:
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]string{
"code": code,
"message": message,
},
})
}
Mapeie erros conhecidos da camada de negócio com errors.Is:
| Erro de aplicação | HTTP |
|---|---|
| Entrada sintaticamente inválida | 400 Bad Request |
| Validação semântica falhou | 422 Unprocessable Entity |
| Credencial ausente ou inválida | 401 Unauthorized |
| Identidade válida sem permissão | 403 Forbidden |
| Recurso não existe | 404 Not Found |
| Conflito de unicidade/estado | 409 Conflict |
| Limite excedido | 429 Too Many Requests |
| Falha inesperada | 500 Internal Server Error |
Não devolva SQL, stack trace ou mensagem interna para o cliente. Registre o erro completo com request ID e devolva um código público estável. O guia de tratamento de erros em Go aprofunda errors.Is, errors.As, wrapping com %w e erros sentinela.
Middleware no Chi: ordem e escopo
Chi aceita qualquer middleware no padrão func(http.Handler) http.Handler. Uma ordem típica é:
- request ID;
- IP real, somente com proxies confiáveis;
- recovery;
- logging e métricas;
- timeout, quando adequado ao endpoint;
- CORS;
- autenticação;
- autorização e regras específicas do grupo.
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(requestLogger(logger))
r.Use(middleware.Timeout(30 * time.Second))
A ordem afeta o que cada camada consegue observar. O logger precisa enxergar o request ID; o recovery deve cobrir handlers e middlewares internos; autenticação não deve ser global se /healthz e /metrics precisam continuar acessíveis às probes.
Há outro cuidado: timeout via contexto não interrompe magicamente uma query ou chamada externa que ignora r.Context(). Propague o contexto para banco, HTTP client e filas. Leia context, timeout e cancelamento em Go e o guia dedicado de middleware com net/http, Chi e Gin.
NotFound e MethodNotAllowed com o mesmo contrato
Uma API não deve responder HTML padrão em alguns erros e JSON em outros. Configure os handlers do router:
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "rota não encontrada")
})
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "método não permitido")
})
Isso também melhora testes e clientes gerados: o envelope de erro continua previsível. Se a API é pública, formalize rotas, schemas e códigos com OpenAPI e oapi-codegen, em vez de depender só de exemplos no README.
Testes com httptest sem abrir porta
Como Chi implementa http.Handler, o teste HTTP é direto:
func TestBuscarProduto(t *testing.T) {
service := &fakeService{
produto: Produto{ID: "prod_123", Nome: "Teclado"},
}
handler := NewHandler(service, slog.New(slog.NewTextHandler(io.Discard, nil)))
router := handler.Routes()
req := httptest.NewRequest(http.MethodGet, "/prod_123", nil)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d; esperado %d", res.Code, http.StatusOK)
}
var got Produto
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.ID != "prod_123" {
t.Fatalf("id = %q; esperado prod_123", got.ID)
}
}
Cubra pelo menos:
- caminho feliz;
- parâmetro inválido;
- body acima do limite;
- JSON malformado e campo desconhecido;
- recurso inexistente;
- conflito;
- autenticação ausente;
- método e rota inexistentes;
- cancelamento do contexto;
- panic convertido em 500 pelo recovery.
Use testes de tabela para repetir casos sem duplicação e deixe integração com PostgreSQL para uma camada separada. O artigo de mocks, fakes e httptest em Go ajuda a escolher entre fake manual, mock gerado e servidor HTTP de teste.
Observabilidade e operação
O router é a melhor fronteira para iniciar telemetria HTTP, mas não pare nele. Uma API Chi de produção deve medir:
- quantidade de requests por rota, método e status;
- duração por rota, sem usar a URL bruta como label;
- requests em andamento;
- tamanho de request e response quando útil;
- panics recuperados;
- timeout e cancelamento;
- saturação de pool do banco e chamadas downstream.
Use o padrão da rota, não /produtos/prod_123, como atributo de baixa cardinalidade. IDs em labels de Prometheus ou nomes de span explodem séries e custo. Combine OpenTelemetry em Go com logging estruturado usando slog e request ID.
Health checks também devem ser deliberados: liveness responde se o processo vive; readiness indica se pode receber tráfego. Não faça uma consulta pesada ao banco em toda liveness. Veja health checks em Go para Kubernetes.
Chi vs net/http, Gin e Echo
| Critério | net/http | Chi | Gin/Echo |
|---|---|---|---|
| Tipo central | http.Handler | http.Handler | Contexto do framework |
| Path params | Nativo no Go 1.22+ | Sim | Sim |
| Grupos e sub-routers | Mais manual | Excelente | Excelente |
| Binding/validação | Manual | Manual | Mais pronto |
| Middleware standard library | Nativo | Nativo | Pode exigir adaptação |
| Acoplamento do handler | Mínimo | Mínimo | Maior |
| Curva para dev que conhece net/http | Nenhuma | Baixa | Baixa/média |
Escolha net/http quando ele já deixa a aplicação simples. Escolha Chi quando você quer modularidade e composição sem criar uma ilha de framework. Escolha Gin ou Echo quando o time valoriza suas conveniências, já tem padrões internos e aceita o contexto próprio.
Não migre uma API estável só porque outro router ganhou benchmark. Meça gargalos reais: banco, serialização, chamadas externas e lock contention quase sempre dominam o custo. Uma migração faz sentido por manutenção, ergonomia, padronização ou compatibilidade — não por uma barra de throughput isolada.
Checklist de produção para uma API com Chi
Antes do deploy, confirme:
-
http.Servertem timeouts deliberados; - processo implementa graceful shutdown;
- body JSON tem limite de tamanho;
- handlers propagam
r.Context(); - erros públicos não vazam detalhes internos;
- autenticação e autorização estão separadas;
- rate limit protege login e endpoints caros;
- request ID, logs, métricas e tracing estão integrados;
-
/healthze readiness não estão atrás de auth por engano; - CORS usa origens explícitas quando há navegador;
- testes cobrem 404, 405, 400, 401, 403, 409, 422 e 500 relevantes;
- documentação OpenAPI acompanha o comportamento real;
- nenhuma métrica usa ID ou URL bruta como label;
- dependências chegam por construtor, não por variável global.
Rate limit, autenticação e autorização merecem políticas próprias; continue com rate limiting em APIs Go e autenticação e autorização em Go.
Perguntas frequentes
O que é Chi Router em Go?
Chi é um roteador HTTP leve e composável construído sobre net/http. Ele adiciona parâmetros, grupos, sub-roteadores e uma API conveniente de middleware sem substituir http.Handler por um tipo proprietário.
Quando usar Chi em vez de net/http puro?
Use net/http puro quando o roteamento nativo deixa a API clara. Adote Chi quando grupos, módulos montáveis, middleware por recurso e organização de rotas começam a exigir código manual demais, mas você quer preservar compatibilidade com a standard library.
Chi é melhor que Gin para API REST?
Depende do time. Chi favorece handlers portáveis, composição e controle. Gin oferece binding, validação integrada e mais convenções prontas. A melhor escolha é a que reduz complexidade total sem esconder o comportamento operacional.
Como testar rotas Chi em Go?
Monte o router com dependências fake, crie a requisição com httptest.NewRequest, grave a resposta com httptest.NewRecorder e chame ServeHTTP. Não é necessário abrir porta nem iniciar um processo separado.
Chi serve para APIs de produção?
Sim. Chi é usado como camada de roteamento, mas produção exige também timeouts, shutdown, limites, segurança, observabilidade e testes. O router ajuda a organizar essas preocupações; ele não substitui a engenharia do serviço.
Conclusão
Chi Router ocupa um espaço muito útil no ecossistema Go: entrega rotas e composição suficientes para APIs grandes sem afastar a aplicação de net/http. Comece pela standard library, adicione Chi quando a organização justificar e mantenha o domínio independente do router.
Uma base sólida usa handlers finos, serviços injetados por construtor, JSON limitado e validado, erros consistentes, middleware por escopo, testes com httptest e operação observável. Depois de implementar o primeiro módulo, revise o guia de API REST em Go, o comparativo de frameworks HTTP e o roadmap de Go para backend. Para transformar o projeto em experiência profissional, acompanhe também as vagas Go no Brasil.