Go Chi Service Layout
This page is a folder and responsibility map for a typical service, not a step-by-step tutorial. Pair it with your team’s templates or onboarding guides. For HTTP request basics from an operator angle, see HTTP for operators.
Production-grade structure for both public-facing APIs and internal microservices. Follows the standard Go project layout with cmd/ + internal/.
Folder structure
Section titled “Folder structure”service/├── go.mod├── go.sum├── Dockerfile├── docker-compose.yml├── Makefile├── .env.example├── api/│ └── openapi.yaml # spec-first; oapi-codegen generates types├── deploy/│ ├── helm/│ └── k8s/├── migrations/ # golang-migrate sql files│ ├── 0001_users.up.sql│ └── 0001_users.down.sql│├── cmd/│ └── api/│ └── main.go # entrypoint: config, deps, server│├── internal/ # not importable by other modules│ ├── config/│ │ └── config.go # env-driven config struct│ ││ ├── server/│ │ ├── server.go # http.Server setup, graceful shutdown│ │ └── routes.go # chi router wiring│ ││ ├── transport/ # HTTP layer — thin│ │ └── http/│ │ ├── v1/│ │ │ ├── users.go│ │ │ ├── orders.go│ │ │ └── health.go│ │ ├── internalapi/ # service-to-service handlers│ │ │ └── routes.go│ │ ├── dto/ # request/response structs + validation│ │ │ ├── user.go│ │ │ └── order.go│ │ └── render/ # JSON helpers, error envelope│ │ └── render.go│ ││ ├── domain/ # business logic — no net/http, no sql│ │ ├── user.go # User entity│ │ ├── order.go│ │ ├── errors.go # domain error types│ │ └── service/│ │ ├── user_service.go│ │ └── order_service.go│ ││ ├── repository/ # data access│ │ ├── postgres/│ │ │ ├── db.go # pgxpool setup│ │ │ ├── user_repo.go│ │ │ └── order_repo.go│ │ └── repository.go # interfaces│ ││ ├── middleware/│ │ ├── request_id.go│ │ ├── logging.go│ │ ├── recoverer.go│ │ ├── auth.go│ │ ├── ratelimit.go│ │ └── timeout.go│ ││ ├── observability/│ │ ├── log/ # slog wrappers│ │ ├── metrics/ # prometheus│ │ └── tracing/ # otel│ ││ └── clients/ # outbound│ ├── httpclient.go│ └── redis.go│├── pkg/ # only if you have code other modules should import│ └── (usually empty for a service)│└── test/ ├── integration/ # spins up testcontainers └── contract/ # validates against openapi.yamlWhy internal/: Go enforces it — packages under internal/ can’t be imported by anything outside the module. This is your architectural firewall, no linter required.
main.go — wiring
Section titled “main.go — wiring”package main
import ( "context" "log/slog" "os" "os/signal" "syscall" "time"
"example.com/service/internal/config" "example.com/service/internal/server")
func main() { cfg := config.Load() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
srv, err := server.New(cfg, logger) if err != nil { logger.Error("server init failed", "err", err) os.Exit(1) }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop()
go func() { if err := srv.Start(); err != nil { logger.Error("server stopped", "err", err) } }()
<-ctx.Done() logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("forced shutdown", "err", err) }}routes.go — chi wiring
Section titled “routes.go — chi wiring”package server
import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "example.com/service/internal/middleware" "example.com/service/internal/transport/http/v1" "example.com/service/internal/transport/http/internalapi")
func (s *Server) routes() http.Handler { r := chi.NewRouter()
// Outermost middleware — applies to everything r.Use(chimw.RealIP) r.Use(middleware.RequestID) r.Use(middleware.Tracing(s.tracer)) r.Use(middleware.Logging(s.logger)) r.Use(middleware.Recoverer(s.logger)) r.Use(chimw.Timeout(30 * time.Second))
// Health endpoints — no auth, no rate limit r.Get("/healthz", v1.Liveness) r.Get("/readyz", v1.Readiness(s.deps)) r.Handle("/metrics", promhttp.Handler())
// Public v1 — JWT + rate limit r.Route("/v1", func(r chi.Router) { r.Use(middleware.JWTAuth(s.cfg.JWTKeySet)) r.Use(middleware.RateLimit(s.redis, 100, time.Minute)) v1.Mount(r, s.deps) })
// Internal — mTLS verified at ingress, header-checked here r.Route("/internal", func(r chi.Router) { r.Use(middleware.RequireMTLSHeader) r.Use(middleware.RateLimit(s.redis, 10000, time.Minute)) internalapi.Mount(r, s.deps) })
return r}Middleware chain (outside → inside)
Section titled “Middleware chain (outside → inside)”Request │ ├─ RealIP # chi: trust X-Forwarded-For from gateway ├─ RequestID # generate/propagate X-Request-ID ├─ Tracing # OTel span injected into ctx ├─ Logging # access log: method, path, status, duration ├─ Recoverer # converts panic → 500 + structured log ├─ Timeout # context deadline ├─ Auth (per route group) ├─ RateLimit ├─ Handler └─ render.Error # converts domain error → JSON envelopeError handling pattern
Section titled “Error handling pattern”Sentinel errors + typed wrappers + one place that maps to HTTP.
package domain
import "errors"
var ( ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict") ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") ErrValidation = errors.New("validation failed") ErrRateLimited = errors.New("rate limited"))
// Rich error with details — use errors.As to extracttype ValidationError struct { Fields map[string]string}
func (e *ValidationError) Error() string { return "validation failed" }func (e *ValidationError) Is(target error) bool { return target == ErrValidation}package render
import ( "encoding/json" "errors" "log/slog" "net/http"
"example.com/service/internal/domain")
type ErrorBody struct { Code string `json:"code"` Message string `json:"message"` Details map[string]any `json:"details,omitempty"` TraceID string `json:"trace_id,omitempty"`}
func JSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v)}
func Error(w http.ResponseWriter, r *http.Request, err error) { traceID, _ := r.Context().Value(ctxKeyTraceID).(string) log := slog.With("trace_id", traceID, "path", r.URL.Path)
var ve *domain.ValidationError switch { case errors.As(err, &ve): JSON(w, http.StatusUnprocessableEntity, errorEnvelope("VALIDATION_FAILED", ve.Error(), map[string]any{"fields": ve.Fields}, traceID)) case errors.Is(err, domain.ErrNotFound): JSON(w, http.StatusNotFound, errorEnvelope("NOT_FOUND", err.Error(), nil, traceID)) case errors.Is(err, domain.ErrConflict): JSON(w, http.StatusConflict, errorEnvelope("CONFLICT", err.Error(), nil, traceID)) case errors.Is(err, domain.ErrUnauthorized): JSON(w, http.StatusUnauthorized, errorEnvelope("UNAUTHORIZED", err.Error(), nil, traceID)) case errors.Is(err, domain.ErrForbidden): JSON(w, http.StatusForbidden, errorEnvelope("FORBIDDEN", err.Error(), nil, traceID)) case errors.Is(err, domain.ErrRateLimited): JSON(w, http.StatusTooManyRequests, errorEnvelope("RATE_LIMITED", err.Error(), nil, traceID)) default: log.Error("unhandled error", "err", err) JSON(w, http.StatusInternalServerError, errorEnvelope( "INTERNAL_ERROR", "Internal server error", nil, traceID)) }}
func errorEnvelope(code, msg string, details map[string]any, traceID string) map[string]any { return map[string]any{ "error": ErrorBody{Code: code, Message: msg, Details: details, TraceID: traceID}, }}Handlers stay simple:
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") user, err := h.svc.Get(r.Context(), id) if err != nil { render.Error(w, r, err) return } render.JSON(w, http.StatusOK, dto.UserFromDomain(user))}Public vs internal split
Section titled “Public vs internal split”- Public (
/v1/*): JWT/OAuth2 auth, rate-limited per principal, strict input validation, hide internal fields in DTO - Internal (
/internal/*): mTLS verified by Istio/Linkerd at ingress; the service double-checks the header. Higher rate limits. Separate router subtree makes gateway policy trivial. - Same binary, same observability — middleware differs by route group
Key dependencies
Section titled “Key dependencies”github.com/go-chi/chi/v5github.com/jackc/pgx/v5 # postgres driver + poolgithub.com/golang-migrate/migrate/v4github.com/go-playground/validator/v10github.com/golang-jwt/jwt/v5github.com/redis/go-redis/v9github.com/prometheus/client_golanggo.opentelemetry.io/otelgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttpgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttpgithub.com/oapi-codegen/oapi-codegen/v2 # if going spec-firstgithub.com/stretchr/testifygithub.com/testcontainers/testcontainers-goServer setup with timeouts
Section titled “Server setup with timeouts”Don’t ship a Go API without these — defaults are unbounded:
srv := &http.Server{ Addr: cfg.Addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20,}