Skip to content

Go Chi Service Layout

First PublishedByAtif Alam

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

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

Why internal/: Go enforces it — packages under internal/ can’t be imported by anything outside the module. This is your architectural firewall, no linter required.

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)
}
}
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
}
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 envelope

Sentinel errors + typed wrappers + one place that maps to HTTP.

internal/domain/errors.go
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 extract
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string { return "validation failed" }
func (e *ValidationError) Is(target error) bool {
return target == ErrValidation
}
internal/transport/http/render/render.go
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:

internal/transport/http/v1/users.go
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 (/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
github.com/go-chi/chi/v5
github.com/jackc/pgx/v5 # postgres driver + pool
github.com/golang-migrate/migrate/v4
github.com/go-playground/validator/v10
github.com/golang-jwt/jwt/v5
github.com/redis/go-redis/v9
github.com/prometheus/client_golang
go.opentelemetry.io/otel
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
github.com/oapi-codegen/oapi-codegen/v2 # if going spec-first
github.com/stretchr/testify
github.com/testcontainers/testcontainers-go

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,
}