Go's standard library HTTP server is capable enough to build production services with. The routing, however, is not. http.ServeMux gained method-based routing and path parameters in Go 1.22, but it still lacks route grouping, middleware scoping and the compositional patterns that make a growing API maintainable. When you are building one service, you work around those gaps. When you are building several services that need to evolve independently but follow the same conventions, you need a router that earns its place.
We evaluated a few options. Gin is the most popular and carries significant abstraction overhead. Echo is cleaner but still diverges from the standard library in ways that create friction when composing with non-Echo middleware. Chi sits at the right level. It is a thin layer over net/http. Every handler is a standard http.HandlerFunc. Every middleware is a standard func(http.Handler) http.Handler. Chi adds routing structure, middleware scoping and route grouping without replacing any of the standard library's primitives.
Over time, across multiple Go services in production, we landed on a pattern for using Chi that is consistent enough that a new engineer can navigate any of our services without a tour. This article describes that pattern.
Why Chi Over the Alternatives
Chi's design contract is that it works with the standard library rather than against it. A middleware written for Chi works with net/http. A handler written for net/http works with Chi. There is no wrapper type to satisfy, no framework-specific context to thread through, no custom response writer to adapt to.
// This is a valid Chi handler
func GetAccount(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// ...
}
// The same function works with net/http directly
mux := http.NewServeMux()
mux.HandleFunc("GET /accounts/{id}", GetAccount)
// And with Chi
r := chi.NewRouter()
r.Get("/accounts/{id}", GetAccount)
chi.URLParam is the only Chi-specific function a handler needs to call, and only when it uses path parameters. Everything else is standard library. This matters for testing: handlers are testable with httptest.NewRequest and httptest.NewRecorder without any Chi-specific setup.
The other property that earned Chi its place in our stack is sub-router composition. Route groups with scoped middleware are the mechanism that keeps a growing API from becoming a flat list of routes with repeated middleware application.
Project Structure
Every service follows the same layout:
payments-service/
cmd/
api/
main.go
internal/
api/
router.go # Route registration
middleware/ # Service-specific middleware
handler/ # Request handlers
request/ # Request parsing and validation
response/ # Response helpers
domain/ # Business types
service/ # Business logic
store/ # Data access
config/
config.go
Dockerfile
Makefile
The internal/api package owns everything HTTP-related. Handlers know about HTTP. The service layer does not. The store layer does not. Keeping that boundary clean means service and store logic is testable without an HTTP server.
The Router
Route registration lives in a single file. Every route the service exposes is visible in one place, with its middleware clearly associated.
// internal/api/router.go
package api
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"payments/internal/api/handler"
"payments/internal/api/middleware"
"payments/internal/service"
)
func NewRouter(
paymentService service.PaymentService,
accountService service.AccountService,
) http.Handler {
r := chi.NewRouter()
// Global middleware: applied to every route
r.Use(chimiddleware.RequestID)
r.Use(chimiddleware.RealIP)
r.Use(middleware.StructuredLogger)
r.Use(middleware.RecoverPanic)
r.Use(chimiddleware.Timeout(30 * time.Second))
// Health check: no auth, no versioning
r.Get("/health", handler.Health)
r.Get("/ready", handler.Ready)
// Versioned API: all routes under /v1 require authentication
r.Route("/v1", func(r chi.Router) {
r.Use(middleware.Authenticate)
// Payments
r.Route("/payments", func(r chi.Router) {
ph := handler.NewPaymentHandler(paymentService)
r.Get("/", ph.List)
r.Post("/", ph.Create)
r.Route("/{paymentID}", func(r chi.Router) {
r.Use(middleware.LoadPayment(paymentService))
r.Get("/", ph.GetByID)
r.Post("/refund", ph.Refund)
r.Post("/capture", ph.Capture)
})
})
// Accounts
r.Route("/accounts", func(r chi.Router) {
ah := handler.NewAccountHandler(accountService)
r.Get("/", ah.List)
r.Post("/", ah.Create)
r.Route("/{accountID}", func(r chi.Router) {
r.Use(middleware.LoadAccount(accountService))
r.Get("/", ah.GetByID)
r.Get("/payments", ah.ListPayments)
})
})
})
return r
}
Several things are deliberate here. Health and readiness endpoints sit outside the versioned group with no authentication middleware. Kubernetes liveness and readiness probes must reach these endpoints without credentials. Placing them inside the auth middleware breaks health checks the moment a token expires.
middleware.Timeout is the Chi-wrapped version of Go's context timeout. Every request gets a 30-second deadline. A handler that takes longer than 30 seconds to respond will have its context cancelled. The handler must respect context cancellation for this to work, which we enforce at the database layer.
The LoadPayment and LoadAccount middleware on nested routes demonstrate Chi's most useful pattern: pre-loading a resource once and making it available to all handlers on that sub-router via the request context.
Resource Loading Middleware
Fetching a resource by ID and handling the not-found case should happen once per request, not in every handler that operates on that resource. Chi's sub-router middleware makes this clean.
// internal/api/middleware/load.go
package middleware
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"payments/internal/api/response"
"payments/internal/service"
)
type contextKey string
const (
contextKeyPayment contextKey = "payment"
contextKeyAccount contextKey = "account"
)
func LoadPayment(svc service.PaymentService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "paymentID")
payment, err := svc.GetByID(r.Context(), id)
if err != nil {
response.WriteError(w, r, err)
return
}
ctx := context.WithValue(r.Context(), contextKeyPayment, payment)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func PaymentFromContext(ctx context.Context) *domain.Payment {
payment, _ := ctx.Value(contextKeyPayment).(*domain.Payment)
return payment
}
The middleware fetches the payment, handles the not-found or error case, and sets the result in the context. Every handler on the /{paymentID} sub-router calls middleware.PaymentFromContext(r.Context()) to retrieve it. The ID parsing and the not-found response are written once.
// internal/api/handler/payment.go
func (h *PaymentHandler) GetByID(w http.ResponseWriter, r *http.Request) {
payment := middleware.PaymentFromContext(r.Context())
response.WriteJSON(w, http.StatusOK, payment)
}
func (h *PaymentHandler) Refund(w http.ResponseWriter, r *http.Request) {
payment := middleware.PaymentFromContext(r.Context())
var req RefundRequest
if err := request.Decode(r, &req); err != nil {
response.WriteError(w, r, err)
return
}
result, err := h.service.Refund(r.Context(), payment, req.Reason)
if err != nil {
response.WriteError(w, r, err)
return
}
response.WriteJSON(w, http.StatusAccepted, result)
}
GetByID is two lines. Refund is eight lines of meaningful logic with no noise. Neither handler checks for a missing payment. That concern is fully handled upstream.
Request Parsing and Validation
Request parsing lives in its own package with a consistent interface. Every handler uses the same function to decode a request body, which means every handler gets the same validation, the same size limit, and the same error on malformed JSON.
// internal/api/request/decode.go
package request
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"payments/internal/apierr"
)
const maxBodyBytes = 1 << 20 // 1MB
func Decode(r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var unmarshalErr *json.UnmarshalTypeError
var maxBytesErr *http.MaxBytesError
switch {
case errors.As(err, &syntaxErr):
return apierr.InvalidInput(
fmt.Sprintf("malformed JSON at position %d", syntaxErr.Offset), nil,
)
case errors.As(err, &unmarshalErr):
return apierr.InvalidInput(
fmt.Sprintf("invalid type for field %q", unmarshalErr.Field), nil,
)
case errors.As(err, &maxBytesErr):
return apierr.InvalidInput("request body exceeds 1MB limit", nil)
case strings.Contains(err.Error(), "unknown field"):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return apierr.InvalidInput(fmt.Sprintf("unknown field %s", field), nil)
case errors.Is(err, io.EOF):
return apierr.InvalidInput("request body is empty", nil)
default:
return apierr.InvalidInput("invalid request body", err)
}
}
return nil
}
DisallowUnknownFields prevents clients from sending fields the API does not expect. A client sending {"amount": 100, "currency": "EUR", "internalFlag": true} gets a 400 response, not silent acceptance of a field that could mask a misconfigured client or a probing attempt.
MaxBytesReader caps the request body at 1MB. Without it, a client sending a multi-gigabyte body holds the connection open until the server reads it all or times out. The limit is enforced before Decode reads a single byte.
Structured Logging Middleware
Every request produces one log line with the fields that matter for debugging and monitoring. The logger reads the request ID set by Chi's RequestID middleware and the response status captured by a wrapped response writer.
// internal/api/middleware/logger.go
package middleware
import (
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
)
func StructuredLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
slog.InfoContext(r.Context(), "request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
"remote_addr", r.RemoteAddr,
)
}()
next.ServeHTTP(ww, r)
})
}
Chi's middleware.WrapResponseWriter captures the status code written by the handler without requiring a custom implementation. middleware.GetReqID retrieves the request ID set by chimiddleware.RequestID earlier in the middleware chain. Both are standard Chi utilities that remove the need to write infrastructure that already exists.
Authentication Middleware
Authentication middleware validates the JWT, extracts the claims, and sets a typed principal in the context. Handlers retrieve the principal by type without a string key.
// internal/api/middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"payments/internal/api/response"
"payments/internal/apierr"
"payments/internal/domain"
)
type contextKeyPrincipal struct{}
func Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token == "" {
response.WriteError(w, r, apierr.Unauthorized("missing authorization token"))
return
}
claims, err := parseJWT(token)
if err != nil {
response.WriteError(w, r, apierr.Unauthorized("invalid authorization token"))
return
}
principal := &domain.Principal{
UserID: claims.Subject,
Role: claims.Role,
}
ctx := context.WithValue(r.Context(), contextKeyPrincipal{}, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func PrincipalFromContext(ctx context.Context) *domain.Principal {
p, _ := ctx.Value(contextKeyPrincipal{}).(*domain.Principal)
return p
}
contextKeyPrincipal{} is an unexported struct type used as a context key. Unexported struct types as context keys prevent key collisions across packages. Using a string like "principal" as a context key means any package that guesses the string can read or overwrite the value. A private struct type provides a guarantee that only this package can access the value.
Testing Handlers
Handlers are tested with httptest directly. Chi adds no friction to standard library testing patterns.
// internal/api/handler/payment_test.go
package handler_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"payments/internal/api/handler"
"payments/internal/api/middleware"
"payments/internal/domain"
)
func TestPaymentHandler_GetByID(t *testing.T) {
payment := &domain.Payment{ID: "pay-001", AmountCents: 1099, Currency: "EUR"}
// Inject the payment into context as the LoadPayment middleware would
ctx := context.WithValue(context.Background(), middleware.ExportedPaymentKey, payment)
req := httptest.NewRequest(http.MethodGet, "/v1/payments/pay-001", nil).WithContext(ctx)
w := httptest.NewRecorder()
svc := &mockPaymentService{}
h := handler.NewPaymentHandler(svc)
h.GetByID(w, req)
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var got domain.Payment
json.NewDecoder(res.Body).Decode(&got)
if got.ID != payment.ID {
t.Errorf("expected payment ID %s, got %s", payment.ID, got.ID)
}
}
func TestGetPayment_WithRouter(t *testing.T) {
payment := &domain.Payment{ID: "pay-001", AmountCents: 1099, Currency: "EUR"}
svc := &mockPaymentService{payment: payment}
r := chi.NewRouter()
r.Route("/{paymentID}", func(r chi.Router) {
r.Use(middleware.LoadPayment(svc))
r.Get("/", handler.NewPaymentHandler(svc).GetByID)
})
req := httptest.NewRequest(http.MethodGet, "/pay-001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
Two testing approaches serve different needs. The first injects the pre-loaded resource directly into the context, bypassing the middleware. It tests the handler in isolation. The second sets up a minimal Chi router with the middleware in place and tests the full middleware-to-handler path. Use both: isolation tests are faster and more precise, integration tests catch middleware integration bugs.
Wiring It Together in main
// cmd/api/main.go
package main
import (
"log/slog"
"net/http"
"os"
"payments/config"
internalapi "payments/internal/api"
"payments/internal/service"
"payments/internal/store"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
logger.Error("config load failed", "err", err)
os.Exit(1)
}
db, err := store.Connect(cfg.DatabaseURL)
if err != nil {
logger.Error("database connection failed", "err", err)
os.Exit(1)
}
defer db.Close()
paymentStore := store.NewPaymentStore(db)
accountStore := store.NewAccountStore(db)
paymentService := service.NewPaymentService(paymentStore, logger)
accountService := service.NewAccountService(accountStore, logger)
router := internalapi.NewRouter(paymentService, accountService)
srv := newServer(cfg.Port, router)
runWithGracefulShutdown(srv, logger)
}
main.go wires dependencies and starts the server. It contains no business logic, no route definitions, no middleware configuration. Those concerns belong in the packages that own them. main assembles the pieces.
Conclusion
Chi earns its place in a Go microservices stack not because it does a lot, but because what it does is precisely the right amount. Route grouping, middleware scoping and sub-router composition give you the structure to keep a growing API organised without leaving the standard library's model.
The pattern we landed on kept router.go as the single source of truth for every route and its middleware. Resource loading middleware eliminated the not-found handling from every handler that needed a pre-fetched resource. A consistent request decoding package made malformed input handling uniform across every endpoint. Typed context keys made inter-middleware communication safe against collision.
None of these decisions are unique to Chi. They are patterns that apply to any Go HTTP service. Chi makes them easier to express cleanly and consistently across multiple services, which is ultimately what a router package should do.