# HTTP Backend Stack — gin + slog + validator + pgx The canonical production HTTP service skeleton. Distilled from the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) codebase — a real proxy serving OpenAI / Gemini / Claude / Codex APIs in production, with SSE streaming, WebSocket upgrades, request logging, and hot-reload config. If you are tempted to pick echo or chi instead, see `libraries.md` — gin wins on ecosystem, not technical merit, and the win is large enough to matter. --- ## `go.mod` ```go module github.com/your-org/myservice go 1.23 require ( github.com/gin-gonic/gin v1.10.1 github.com/go-playground/validator/v10 v10.22.1 github.com/caarlos0/env/v11 v11.2.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 golang.org/x/sync v0.18.0 ) ``` --- ## Project structure ``` cmd/server/main.go # ≤ 50 LOC; flags → run.Execute(ctx) internal/ cmd/run.go # ~150 LOC; signal handling, config load, server.Run config/config.go # env-driven Config struct api/ server.go # gin.Engine setup, route mounting, http.Server middleware/ request_id.go request_logging.go auth.go recovery.go cors.go handlers/ users.go # one file per resource streams.go # SSE / WebSocket endpoints domain/ # smart-constructor types (Email, UserID, ...) service/ # business logic store/ # pgx + sqlc obs/ logger.go # slog setup ``` --- ## `cmd/server/main.go` ```go package main import ( "context" "log/slog" "os" "os/signal" "syscall" "github.com/your-org/myservice/internal/cmd" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() if err := cmd.Execute(ctx); err != nil { slog.Error("fatal", slog.Any("err", err)) os.Exit(1) } } ``` That is the entire `main`. Anything more is a smell. --- ## `internal/config/config.go` ```go package config import ( "time" "github.com/caarlos0/env/v11" ) type Config struct { Host string `env:"HOST" envDefault:"0.0.0.0"` Port int `env:"PORT" envDefault:"8080"` DatabaseURL string `env:"DATABASE_URL,required"` ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"15s"` WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"` ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"` LogLevel string `env:"LOG_LEVEL" envDefault:"info"` LogFormat string `env:"LOG_FORMAT" envDefault:"json"` Env string `env:"ENV" envDefault:"development"` } func Load() (Config, error) { var cfg Config if err := env.Parse(&cfg); err != nil { return Config{}, err } return cfg, nil } ``` --- ## `internal/obs/logger.go` ```go package obs import ( "context" "log/slog" "os" ) type ctxKey struct{ name string } var requestIDKey = ctxKey{"request_id"} func NewLogger(level, format string) *slog.Logger { var lvl slog.Level _ = lvl.UnmarshalText([]byte(level)) opts := &slog.HandlerOptions{Level: lvl, AddSource: true} var h slog.Handler switch format { case "text": h = slog.NewTextHandler(os.Stdout, opts) default: h = slog.NewJSONHandler(os.Stdout, opts) } return slog.New(&ctxHandler{Handler: h}) } // ctxHandler pulls request_id from ctx into every log line. type ctxHandler struct{ slog.Handler } func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error { if id, ok := ctx.Value(requestIDKey).(string); ok && id != "" { r.AddAttrs(slog.String("request_id", id)) } return h.Handler.Handle(ctx, r) } func WithRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, requestIDKey, id) } ``` --- ## `internal/api/server.go` ```go package api import ( "context" "fmt" "log/slog" "net/http" "github.com/gin-gonic/gin" "github.com/your-org/myservice/internal/api/handlers" "github.com/your-org/myservice/internal/api/middleware" "github.com/your-org/myservice/internal/config" ) type Server struct { cfg config.Config srv *http.Server logger *slog.Logger } func New(cfg config.Config, logger *slog.Logger, h *handlers.Handler) *Server { gin.SetMode(gin.ReleaseMode) r := gin.New() // Middleware order matters — see "Middleware ordering" below. r.Use( middleware.RequestID(), // 1. assign request_id first middleware.Recovery(logger), // 2. recovery wraps everything middleware.RequestLogger(logger), middleware.CORS(), ) h.Mount(r) return &Server{ cfg: cfg, logger: logger, srv: &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), Handler: r, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, }, } } func (s *Server) Run(ctx context.Context) error { errCh := make(chan error, 1) go func() { s.logger.InfoContext(ctx, "server starting", slog.String("addr", s.srv.Addr)) if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { errCh <- err } close(errCh) }() select { case <-ctx.Done(): s.logger.InfoContext(ctx, "shutdown signal received") shutdownCtx, cancel := context.WithTimeout( context.Background(), s.cfg.ShutdownTimeout) defer cancel() return s.srv.Shutdown(shutdownCtx) case err := <-errCh: return err } } ``` Notes: - `gin.New()` not `gin.Default()` — `Default()` adds `Logger()` (text format, not slog) and `Recovery()` (no logger injection). We replace both. - `gin.SetMode(gin.ReleaseMode)` silences debug output. Production assumed. - `http.Server` with explicit timeouts. The default `nil` timeouts are a DoS waiting to happen. - Graceful shutdown: SIGINT/SIGTERM cancels the ctx → `Shutdown(shutdownCtx)` gives in-flight requests up to `ShutdownTimeout` to finish. --- ## Middleware ordering — the rule that actually matters ``` RequestID → Recovery → Logger → CORS → Auth → Handler (1) (2) (3) (4) (5) ``` 1. **RequestID** is first so every subsequent middleware sees it. 2. **Recovery** wraps everything after it. Order: a panic in CORS still gets caught. 3. **Logger** sees the request_id and the recovered panic. 4. **CORS** before Auth — OPTIONS preflight must return without auth. 5. **Auth** is the last cross-cutting middleware. Per-route auth (admin-only) is mounted on a sub-router with extra middleware. ```go // Public routes — no auth api := r.Group("/api/v1") { api.POST("/auth/login", h.Login) api.GET("/healthz", h.Healthz) } // Authenticated routes authed := r.Group("/api/v1", middleware.Auth(authSvc)) { authed.GET("/users/:id", h.GetUser) authed.POST("/users", h.CreateUser) } // Admin-only routes admin := r.Group("/api/v1/admin", middleware.Auth(authSvc), middleware.RequireRole("admin")) { admin.GET("/users", h.ListAllUsers) } ``` --- ## Middleware examples ### `middleware/request_id.go` ```go package middleware import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/your-org/myservice/internal/obs" ) func RequestID() gin.HandlerFunc { return func(c *gin.Context) { id := c.GetHeader("X-Request-ID") if id == "" { id = uuid.Must(uuid.NewV7()).String() } c.Request = c.Request.WithContext(obs.WithRequestID(c.Request.Context(), id)) c.Header("X-Request-ID", id) c.Next() } } ``` ### `middleware/recovery.go` ```go package middleware import ( "log/slog" "net/http" "runtime/debug" "github.com/gin-gonic/gin" ) func Recovery(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if r := recover(); r != nil { logger.ErrorContext(c.Request.Context(), "panic recovered", slog.Any("panic", r), slog.String("stack", string(debug.Stack())), ) if !c.Writer.Written() { c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"}) } c.Abort() } }() c.Next() } } ``` ### `middleware/request_logging.go` ```go func RequestLogger(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() logger.InfoContext(c.Request.Context(), "http request", slog.String("method", c.Request.Method), slog.String("path", c.Request.URL.Path), slog.Int("status", c.Writer.Status()), slog.Int("bytes", c.Writer.Size()), slog.Duration("elapsed", time.Since(start)), slog.String("ip", c.ClientIP()), ) } } ``` The `sloglint` linter enforces typed attrs (`slog.String(...)`) over `slog.Any("path", ...)`. Keep the form. ### `middleware/cors.go` ```go func CORS() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "*") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } ``` Note the explicit OPTIONS short-circuit — preflight must NOT go through Auth. --- ## Handlers — the canonical shape ```go package handlers import ( "errors" "net/http" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/your-org/myservice/internal/domain" "github.com/your-org/myservice/internal/httperr" "github.com/your-org/myservice/internal/service" ) type Handler struct { Users *service.UserService } func (h *Handler) Mount(r gin.IRouter) { api := r.Group("/api/v1") api.POST("/users", h.CreateUser) api.GET("/users/:id", h.GetUser) } type createUserReq struct { Email string `json:"email" binding:"required,email"` Username string `json:"username" binding:"required,alphanum,min=3,max=32"` } func (h *Handler) CreateUser(c *gin.Context) { var req createUserReq if err := c.ShouldBindJSON(&req); err != nil { writeBindingError(c, err) return } email, err := domain.NewEmail(req.Email) if err != nil { httperr.Write(c, err) return } username, err := domain.NewUsername(req.Username) if err != nil { httperr.Write(c, err) return } user, err := h.Users.Create(c.Request.Context(), email, username) if err != nil { httperr.Write(c, err) return } c.JSON(http.StatusCreated, user) } func writeBindingError(c *gin.Context, err error) { var vErr validator.ValidationErrors if errors.As(err, &vErr) { out := make(map[string]string, len(vErr)) for _, fe := range vErr { out[fe.Field()] = fe.Tag() } c.JSON(http.StatusBadRequest, gin.H{"errors": out}) return } c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"}) } ``` See `data-modeling.md` for the validator tag reference; see `error-handling.md` for the `httperr.Write` funnel. --- ## SSE streaming — the production pattern CLIProxyAPI streams OpenAI-compatible SSE for hundreds of concurrent clients. The pattern: ```go func (h *Handler) StreamChat(c *gin.Context) { ctx, cancel := context.WithCancel(c.Request.Context()) defer cancel() // 1. Set SSE headers BEFORE writing any body c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") // disable nginx buffering // 2. Obtain the flusher — REQUIRED for streaming flusher, ok := c.Writer.(http.Flusher) if !ok { httperr.Write(c, errors.New("streaming unsupported")) return } // 3. Pull chunks from upstream chunks, errs := h.svc.StreamCompletions(ctx, req) for { select { case <-ctx.Done(): return // client disconnected, ctx cancelled case chunk, ok := <-chunks: if !ok { fmt.Fprint(c.Writer, "data: [DONE]\n\n") flusher.Flush() return } fmt.Fprintf(c.Writer, "data: %s\n\n", chunk) flusher.Flush() case err := <-errs: // Error mid-stream — emit as SSE event and bail fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", err.Error()) flusher.Flush() return } } } ``` Key facts: - **Headers MUST be set before the first `Write`.** Otherwise gin auto-sets `Content-Type: text/plain`. - **`c.Writer.(http.Flusher)` is the streaming primitive.** Without `flusher.Flush()`, the response is buffered and arrives as one blob at the end. - **Always respond to `<-ctx.Done()`.** A disconnected client must stop upstream work — otherwise you generate tokens for nothing. - **The trailing `\n\n` per event is wire-mandatory** for SSE parsing. Missing it = the client never sees the event. --- ## WebSocket upgrade ```go import "github.com/gorilla/websocket" // still the canonical WS lib in 2026 var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { // tighten in production return true }, } func (h *Handler) WebSocketEcho(c *gin.Context) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { slog.ErrorContext(c.Request.Context(), "ws upgrade failed", slog.Any("err", err)) return } defer conn.Close() for { mt, msg, err := conn.ReadMessage() if err != nil { return } if err := conn.WriteMessage(mt, msg); err != nil { return } } } ``` For long-lived connections, use `conn.SetReadDeadline` + `SetPongHandler` for keepalive. CLIProxyAPI's `wsrelay` package is a reference implementation. --- ## Database wiring — pgx pool, injected, never global ```go package store import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) { cfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("parse dsn: %w", err) } cfg.MaxConns = 25 cfg.MinConns = 5 cfg.MaxConnLifetime = time.Hour cfg.MaxConnIdleTime = 30 * time.Minute pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("connect: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("ping: %w", err) } return pool, nil } ``` See `sqlc-pgx.md` for queries. --- ## Healthcheck ```go func (h *Handler) Healthz(c *gin.Context) { if err := h.pool.Ping(c.Request.Context()); err != nil { c.JSON(503, gin.H{"db": "down", "error": err.Error()}) return } c.JSON(200, gin.H{"ok": true}) } ``` Mount BEFORE auth. Health checks must be unauthenticated. --- ## Testing the server ```go func TestCreateUser_returns_201_for_valid_input(t *testing.T) { // Given h := newTestHandler(t) r := gin.New() h.Mount(r) body := `{"email":"a@b.com","username":"alice"}` req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // When r.ServeHTTP(rec, req) // Then require.Equal(t, http.StatusCreated, rec.Code) var got struct{ ID string `json:"id"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) require.NotEmpty(t, got.ID) } ``` See `testing.md` for full patterns (testcontainers integration, table-driven, goleak). --- ## Sources - gin docs: https://gin-gonic.com/docs/ - CLIProxyAPI (reference impl): https://github.com/router-for-me/CLIProxyAPI - pgx pool: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool - SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html - Go's `http.Server` graceful shutdown: https://pkg.go.dev/net/http#Server.Shutdown