How We Used Chi Router to Structure Clean and Scalable Go Microservices in Production

软件 工程师 都柏林

软件 工程师 都柏林

工程师 精心 打造 可扩展

超越 代码 本身

他们 怎么说

一起 构建

消息 已收到

隐私 政策

使用 条款

Cookie 政策

免责声明

最新 动态

精选 作品

作品 展示

我们 提供 什么

服务 的行业

屏幕 之外

共同 构建

View View
Nben Malla
Nben Malla

Software Engineer

A software engineer who builds systems that scale, modernizes platforms that matter and ships code that holds up long after the project ends.

Based in Dublin, Ireland, with a presence across Bristol, Groningen and Kathmandu, the work spans FinTech platforms, SaaS products and open source contributions, collaborating with engineering teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices in Go, Java and Python, the focus has always been the same. Understand the problem deeply, build it right and make sure the people depending on it never have to think about it failing.

  • 阅读文章 阅读文章

    Packages 6 mins

    How We Used Chi Router to Structure Clean and Scalable Go Microservices in Production

    Nben M. 08 Mar, 2026 6 mins

    How We Used Chi Router to Structure Clean and Scalable Go Microservices in Production

    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.

    go
    // 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:

    markdown
    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.

    go
    // 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.

    go
    // 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.

    go
    // 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.

    go
    // 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.

    go
    // 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.

    go
    // 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.

    go
    // 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

    go
    // 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.