← Voltar para o blog

Middleware em Go: net/http, Chi, Gin e o Padrão Handler

Middleware em Go: padrão http.Handler, chain com net/http, Chi e Gin, recovery, request ID, logging, CORS, auth e rate limit — com exemplos de produção.

Toda API Go de produção tem o mesmo conjunto de preocupações transversais: logar cada request, recuperar panic, propagar context, validar token, limitar taxa, adicionar headers de segurança e medir latência. Se você espalha isso em cada handler, o código vira um copiar-e-colar frágil. Se você concentra em middleware, cada rota declara só o que é de negócio e a chain cuida do resto.

Este guia mostra o padrão idiomático de middleware em Go — func(http.Handler) http.Handler — com net/http puro, Chi e Gin, além dos middlewares que quase toda API no Brasil precisa em 2026. Ele complementa o comparativo de frameworks HTTP, o guia de rate limiting, o guia de autenticação e autorização, o logging com slog, o context com timeout e o tutorial de API REST.

O que é middleware (na definição de Go)

Em Go, middleware não é um plugin mágico do framework. É uma função que recebe um http.Handler e devolve outro http.Handler. O handler devolvido faz algo antes de chamar o próximo, depois, ou os dois:

type Middleware func(http.Handler) http.Handler

O handler final continua sendo o que você já conhece:

http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte(`{"ok":true}`))
})

A beleza do padrão é a composição. Como tudo é http.Handler, você empilha middlewares sem o framework saber (ou precisar saber) o que cada um faz. Isso é o mesmo espírito do clean architecture sem overengineering: interfaces pequenas, composição explícita.

O padrão canônico: wrapping

O middleware mais simples possível só loga e repassa:

package middleware

import (
	"log"
	"net/http"
	"time"
)

func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

Para usar com net/http puro:

mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)

var handler http.Handler = mux
handler = Logging(handler)

srv := &http.Server{
	Addr:    ":8080",
	Handler: handler,
}

A ordem importa. Se você aplicar A(B(C(mux))), a requisição passa por A → B → C → mux, e a resposta sobe C → B → A. Pense em cebola: o middleware mais externo vê a request primeiro e a response por último.

Chain: montar a pilha sem dor

Em vez de aninhar na mão, um helper de chain deixa a ordem legível. A convenção comum é aplicar da esquerda para a direita, com o primeiro da lista sendo o mais externo:

package middleware

import "net/http"

func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
	// aplica do último para o primeiro para que mws[0] fique por fora
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

Uso em produção:

handler := middleware.Chain(
	mux,
	middleware.RequestID,
	middleware.Recover,
	middleware.Logging,
	middleware.CORS(allowedOrigins),
)

Essa stack é o mínimo que eu esperaria de qualquer serviço exposto na internet. Recovery e request ID quase sempre ficam por fora do logging, para que panics e correlação apareçam no log.

Recovery: o middleware que salva o processo

Um panic em handler, sem recovery, encerra o processo. Em Kubernetes isso vira restart; sob carga, pode virar cascata. O recovery captura, loga e responde 500:

package middleware

import (
	"log/slog"
	"net/http"
	"runtime/debug"
)

func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				slog.ErrorContext(r.Context(), "panic recuperado",
					"err", rec,
					"stack", string(debug.Stack()),
					"path", r.URL.Path,
					"method", r.Method,
				)
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Combine com slog estruturado e, se já tiver, com o request ID no contexto. Em incidentes, stack + request_id + path resolvem 80% da triagem.

Request ID: correlação de logs

Sem identificador por requisição, correlacionar logs de um timeout em produção é caça ao tesouro. O middleware gera (ou propaga) um ID e coloca no contexto e no header de resposta:

package middleware

import (
	"context"
	"net/http"

	"github.com/google/uuid"
)

type ctxKey int

const requestIDKey ctxKey = 1

func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.NewString()
		}
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		w.Header().Set("X-Request-ID", id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func GetRequestID(ctx context.Context) string {
	if v, ok := ctx.Value(requestIDKey).(string); ok {
		return v
	}
	return ""
}

Propague o mesmo header para serviços downstream (HTTP client, gRPC metadata). Isso fecha o circuito com OpenTelemetry e com o guia de context: o context.Context carrega cancelamento e valores de correlação.

Logging de request com status e bytes

O ResponseWriter padrão não expõe o status code depois do WriteHeader. Para logar status e bytes escritos, envolva o writer:

package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

type statusWriter struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
	if w.status == 0 {
		w.status = http.StatusOK
	}
	n, err := w.ResponseWriter.Write(b)
	w.bytes += n
	return n, err
}

func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(sw, r)
		slog.InfoContext(r.Context(), "request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", sw.status,
			"bytes", sw.bytes,
			"duration_ms", time.Since(start).Milliseconds(),
			"request_id", GetRequestID(r.Context()),
			"remote", r.RemoteAddr,
		)
	})
}

Em produção, evite logar body ou query string com dados sensíveis (tokens, CPF, cartão). Prefira campos estruturados e sampling em rotas de health check de alto volume — o health check guide explica por que /healthz não deve poluir o log.

CORS, auth e rate limit como middleware

Três middlewares aparecem em quase toda API pública ou SPA+backend no Brasil:

CORS

func CORS(origins map[string]struct{}) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			origin := r.Header.Get("Origin")
			if _, ok := origins[origin]; ok {
				w.Header().Set("Access-Control-Allow-Origin", origin)
				w.Header().Set("Vary", "Origin")
				w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID")
				w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
			}
			if r.Method == http.MethodOptions {
				w.WriteHeader(http.StatusNoContent)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

Nunca use Access-Control-Allow-Origin: * junto com cookies ou Authorization em APIs autenticadas. Liste origens explicitamente.

Autenticação

O middleware de auth valida o Bearer token, coloca o usuário no contexto e deixa a autorização (pode editar este recurso?) para o handler ou para um middleware de grupo. Detalhes de JWT, refresh e RBAC estão no guia de autenticação e no tutorial de JWT:

func Auth(validate func(*http.Request) (userID string, err error)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			uid, err := validate(r)
			if err != nil {
				http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
				return
			}
			ctx := context.WithValue(r.Context(), userKey, uid)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Rate limiting

Rate limit é middleware por excelência: corta abuso antes do handler tocar banco ou fila. Implementações com token bucket, Redis e limites por IP/API key estão no guia de rate limiting em produção. Na chain, ele costuma ficar depois de request ID e antes de auth pesada, ou depois de auth se o limite for por usuário.

Middleware no Chi

Chi é 100% http.Handler. Por isso o middleware que você escreveu acima funciona sem adaptador:

r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recover)
r.Use(middleware.Logging)

r.Get("/healthz", healthz)

r.Group(func(r chi.Router) {
	r.Use(middleware.Auth(validateToken))
	r.Get("/me", getMe)
	r.Post("/orders", createOrder)
})

Use aplica a todos os handlers daquele router/grupo; With aplica só a uma rota. Grupos são a forma correta de separar rotas públicas de privadas sem if dentro do middleware global.

Middleware no Gin (e a diferença de tipo)

Gin não usa http.Handler na chain pública. O tipo é gin.HandlerFunc, e a continuação é c.Next():

func GinRequestID() gin.HandlerFunc {
	return func(c *gin.Context) {
		id := c.GetHeader("X-Request-ID")
		if id == "" {
			id = uuid.NewString()
		}
		c.Set("request_id", id)
		c.Writer.Header().Set("X-Request-ID", id)
		c.Next()
	}
}

func main() {
	r := gin.New()
	r.Use(GinRequestID(), gin.Recovery(), gin.Logger())
	r.GET("/healthz", healthz)
	r.Run(":8080")
}

Você pode adaptar http.Handler middleware para Gin com gin.WrapH / gin.WrapF, mas a ida e volta tem custo de ergonomia. Se o time já é Gin, mantenha a chain em gin.HandlerFunc. Se ainda está escolhendo framework, o comparativo Gin/Echo/Fiber/Chi pesa exatamente isso: Chi reutiliza o ecossistema net/http; Fiber, por cima de fasthttp, não.

net/http 1.22+, timeouts e graceful shutdown

Middleware não substitui configuração do servidor. Timeouts de leitura/escrita e shutdown gracioso continuam no http.Server e no processo:

srv := &http.Server{
	Addr:              ":8080",
	Handler:           handler,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       60 * time.Second,
}

Sem ReadHeaderTimeout, conexões lentas seguram workers. O encerramento limpo — drenar requests, fechar listeners, flush de logs — está no guia de graceful shutdown. Middleware de logging e recovery continuam ativos até o último request drenado.

Ordem recomendada da chain

Uma ordem que funciona bem na maioria das APIs:

PosiçãoMiddlewarePor quê
1 (externo)Request IDTudo que logar depois já tem correlação
2RecoverPanic não mata o processo; log leva o ID
3LoggingMede duração real, inclusive de middlewares internos
4CORS / security headersResponde OPTIONS cedo, sem auth
5Rate limitCorta abuso barato
6AuthSó quem passou do limite gasta validação de token
7 (interno)Handler de negócioSó regra de domínio

Ajustes: se o rate limit for por usuário, auth precisa vir antes. Se for por IP na borda, rate limit antes de auth economiza CPU. Documente a escolha no README do serviço.

Armadilhas comuns

  1. Esquecer de chamar next: middleware que autentica e não chama o próximo em caso de sucesso é bug silencioso (a request “trava” sem response em alguns caminhos). Sempre chame next ou escreva a response de erro e return.
  2. Mutar *http.Request sem WithContext: o request é compartilhado; o idiomático é r = r.WithContext(ctx) e passar adiante.
  3. Bufferizar body inteiro em middleware de log: em upload grande isso estoura memória. Logue metadata; deixe body para o handler. Veja também upload multipart e S3.
  4. Middleware global demais: auth global em /healthz e /metrics quebra probes do Kubernetes. Use grupos (Chi) ou r.Group (Gin).
  5. Depender de Fiber middleware em biblioteca interna: se um dia o time migrar para Chi/net/http, você reescreve tudo. Prefira func(http.Handler) http.Handler em libs compartilhadas.
  6. Logar 100% de /healthz: em cluster grande isso domina o volume. Ignore paths de probe no logger ou use sampling.

Testando middleware

Middleware é função pura o bastante para testar com httptest:

func TestRecover(t *testing.T) {
	h := Recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		panic("boom")
	}))
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)
	if rr.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d", rr.Code)
	}
}

Para auth, injete um validate fake. Para logging, use um slog.Handler em buffer. O guia de mocks e httptest aprofunda o padrão.

Quando não usar middleware

Middleware é péssimo para regra de negócio de um único endpoint (“se o pedido for do tipo X e o estoque Y…”). Isso é handler. Middleware brilha em políticas transversais. Também não use middleware para esconder dependências enormes (banco, fila) sem injeção clara — prefira constructors que recebem interfaces, no espírito de dependency injection sem framework.

Perguntas frequentes

O que é middleware em Go?

Middleware em Go é uma função que envolve um http.Handler e devolve outro http.Handler. Ele intercepta a requisição antes (e/ou a resposta depois) do handler final: log, autenticação, recovery de panic, CORS, rate limit e request ID são os casos clássicos. O padrão idiomático é func(http.Handler) http.Handler.

Como encadear middleware com net/http puro?

Compose funções do tipo func(http.Handler) http.Handler. A forma mais clara é aplicar de dentro para fora ou usar um helper Chain. Com Chi, Use e With montam a chain de forma declarativa. Em Gin e Echo a API é similar, mas o tipo do handler muda (gin.HandlerFunc em vez de http.Handler).

Middleware de recovery é obrigatório em produção?

Sim. Um panic não capturado em um handler derruba o processo inteiro se não houver recovery. O middleware de recovery deve recuperar o panic, logar o stack com slog ou equivalente, devolver 500 e deixar o processo vivo. Em Kubernetes isso evita restart em cascata por um bug pontual.

Onde colocar autenticação: middleware ou handler?

Validação do token e extração de identidade costumam ficar no middleware de autenticação. Autorização fina (este usuário pode editar este recurso?) fica no handler ou em um middleware de autorização por rota/grupo. Separe autenticação (quem é) de autorização (o que pode). Detalhes em autenticação e autorização em APIs Go.

Chi, Gin ou net/http: qual middleware usar?

Se você usa net/http ou Chi, escreva middleware no padrão http.Handler — reutilizável em qualquer router compatível. Em Gin, o tipo é gin.HandlerFunc e a chain é c.Next(). Fiber não usa net/http; o middleware dele não é portável para o ecossistema padrão. Prefira o padrão da standard library quando puder. O comparativo de frameworks ajuda a decidir o router; este guia define o padrão de middleware em cima dessa escolha.

Conclusão

Middleware em Go é composição de http.Handler, não mágica de framework. Domine o padrão func(http.Handler) http.Handler, monte uma chain com request ID, recovery, logging, CORS, rate limit e auth, e use grupos de rota para não autenticar health checks. Com Chi você reutiliza o ecossistema net/http; com Gin você adapta o mesmo desenho à gin.HandlerFunc.

Próximos passos naturais na malha de produção do site:

Se você está montando portfólio ou se preparando para a primeira vaga, implemente essa chain do zero em um serviço pequeno — é um dos projetos que mais conversam bem em entrevista de backend Go.