Building Production-Ready Go APIs: The Complete Guide
A comprehensive guide to building production-grade Go APIs with clean architecture, database pooling, graceful shutdown, health checks, and testing strategies.
I have been building Go APIs professionally for 5 years now. After shipping 15+ production services, I have settled on a template that works reliably regardless of project size. This guide covers everything from project structure to deployment, with code you can copy into your next project.
This is not a “hello world” tutorial. If you are here, you probably already know Go basics and want to know how to build something production-worthy. Something with proper error handling, connection pooling, graceful shutdowns, and actual tests.
Project Structure
Here is the layout I use for every Go service:
my-api/
├── cmd/
│ └── api/
│ └── main.go # Entry point
├── internal/
│ ├── config/
│ │ └── config.go # Configuration loading
│ ├── domain/
│ │ ├── user.go # Domain models
│ │ └── errors.go # Domain errors
│ ├── handler/
│ │ ├── handler.go # Handler dependencies
│ │ ├── user_handler.go # HTTP handlers
│ │ └── middleware.go # HTTP middleware
│ ├── repository/
│ │ ├── postgres/
│ │ │ └── user_repo.go # PostgreSQL implementation
│ │ └── repository.go # Repository interfaces
│ └── service/
│ └── user_service.go # Business logic
├── migrations/
│ ├── 001_create_users.up.sql
│ └── 001_create_users.down.sql
├── Dockerfile
├── Makefile
├── go.mod
└── go.sum
The key principles:
- cmd/ contains entry points. Nothing else. No business logic lives here.
- internal/ prevents external packages from importing your internal code.
- domain/ holds pure business models with zero dependencies.
- handler/ adapts HTTP to your domain. Easily swappable for gRPC or CLI.
- repository/ implements data access. Interfaces live here, implementations in subdirectories.
- service/ contains business logic that orchestrates repositories.
This structure scales from a single service to a large monorepo without restructuring.
Router Setup: Chi vs Standard Library
I use chi for every project. Here is why, compared to alternatives:
| Feature | net/http (stdlib) | Chi | Gin |
|---|---|---|---|
| URL parameters | Manual parsing | Built-in | Built-in |
| Middleware chaining | Manual | Built-in | Built-in |
| stdlib compatible | Yes | Yes | No |
| External deps | 0 | 0 | 9+ |
| Route groups | No | Yes | Yes |
| Performance | Baseline | ~Same | Slightly faster |
Chi wins because it adds URL parameters and middleware chaining while remaining 100% compatible with http.Handler. Any stdlib middleware works with chi. You never get locked in.
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func NewRouter(h *Handler) http.Handler {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(h.structuredLogger)
r.Use(middleware.Recoverer)
r.Use(h.corsMiddleware)
r.Use(middleware.Timeout(30 * time.Second))
// Health endpoints (no auth)
r.Get("/health", h.HealthCheck)
r.Get("/ready", h.ReadinessCheck)
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Use(h.rateLimiter)
r.Use(h.authenticate)
r.Route("/users", func(r chi.Router) {
r.Get("/", h.ListUsers)
r.Post("/", h.CreateUser)
r.Get("/{id}", h.GetUser)
r.Put("/{id}", h.UpdateUser)
r.Delete("/{id}", h.DeleteUser)
})
})
return r
}
Middleware Chain
The order of middleware matters. Here is my standard chain with explanations:
1. Request ID (First)
Assigns a unique ID to every request for tracing:
func (h *Handler) requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
2. Structured Logging
Logs every request with duration, status code, and request ID:
func (h *Handler) 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.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
"remote_addr", r.RemoteAddr,
"bytes_written", ww.BytesWritten(),
)
}()
next.ServeHTTP(ww, r)
})
}
3. Rate Limiting
Token bucket per IP with configurable limits:
func (h *Handler) rateLimiter(next http.Handler) http.Handler {
limiter := rate.NewLimiter(rate.Limit(100), 200) // 100 req/s, burst 200
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
For production, use per-IP limiting with a sync.Map or Redis backend.
4. CORS
func (h *Handler) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", h.config.CORSOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Database Layer: pgx with Connection Pooling
I use pgx directly, not through database/sql. It is faster, supports PostgreSQL-specific features, and has proper connection pooling built in.
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
Pool *pgxpool.Pool
}
func NewDB(ctx context.Context, cfg Config) (*DB, error) {
poolConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("parsing database config: %w", err)
}
// Connection pool settings
poolConfig.MaxConns = 20 // 2-4x CPU cores
poolConfig.MinConns = 5 // Keep warm connections
poolConfig.MaxConnLifetime = 30 * time.Minute // Prevent stale connections
poolConfig.MaxConnIdleTime = 5 * time.Minute // Release idle connections
poolConfig.HealthCheckPeriod = 30 * time.Second // Periodic health check
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("creating connection pool: %w", err)
}
// Verify connectivity
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
return &DB{Pool: pool}, nil
}
func (db *DB) Close() {
db.Pool.Close()
}
func (db *DB) Health(ctx context.Context) error {
return db.Pool.Ping(ctx)
}
Repository Implementation
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"my-api/internal/domain"
)
type UserRepo struct {
db *DB
}
func NewUserRepo(db *DB) *UserRepo {
return &UserRepo{db: db}
}
func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
var user domain.User
err := r.db.Pool.QueryRow(ctx,
`SELECT id, name, email, created_at, updated_at
FROM users WHERE id = $1 AND deleted_at IS NULL`,
id,
).Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("querying user by id: %w", err)
}
return &user, nil
}
func (r *UserRepo) Create(ctx context.Context, user *domain.User) error {
_, err := r.db.Pool.Exec(ctx,
`INSERT INTO users (id, name, email, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)`,
user.ID, user.Name, user.Email, user.CreatedAt, user.UpdatedAt,
)
if err != nil {
return fmt.Errorf("inserting user: %w", err)
}
return nil
}
Configuration Management
I use environment variables with a typed config struct. No Viper needed for most projects:
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
Auth AuthConfig
}
type ServerConfig struct {
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
type DatabaseConfig struct {
URL string
MaxConns int32
MinConns int32
MaxLifetime time.Duration
}
type AuthConfig struct {
JWTSecret string
TokenTTL time.Duration
}
func Load() (*Config, error) {
cfg := &Config{
Server: ServerConfig{
Port: envInt("PORT", 8080),
ReadTimeout: envDuration("READ_TIMEOUT", 10*time.Second),
WriteTimeout: envDuration("WRITE_TIMEOUT", 30*time.Second),
ShutdownTimeout: envDuration("SHUTDOWN_TIMEOUT", 15*time.Second),
},
Database: DatabaseConfig{
URL: envRequired("DATABASE_URL"),
MaxConns: int32(envInt("DB_MAX_CONNS", 20)),
MinConns: int32(envInt("DB_MIN_CONNS", 5)),
MaxLifetime: envDuration("DB_MAX_LIFETIME", 30*time.Minute),
},
Auth: AuthConfig{
JWTSecret: envRequired("JWT_SECRET"),
TokenTTL: envDuration("TOKEN_TTL", 24*time.Hour),
},
}
return cfg, nil
}
func envRequired(key string) string {
val := os.Getenv(key)
if val == "" {
panic(fmt.Sprintf("required environment variable %s is not set", key))
}
return val
}
func envInt(key string, defaultVal int) int {
val := os.Getenv(key)
if val == "" {
return defaultVal
}
n, err := strconv.Atoi(val)
if err != nil {
return defaultVal
}
return n
}
func envDuration(key string, defaultVal time.Duration) time.Duration {
val := os.Getenv(key)
if val == "" {
return defaultVal
}
d, err := time.ParseDuration(val)
if err != nil {
return defaultVal
}
return d
}
Graceful Shutdown
This is non-negotiable for production. Without graceful shutdown, every deployment drops in-flight requests:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
// Initialize dependencies
db, err := postgres.NewDB(context.Background(), cfg.Database)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
// Build handler and router
h := handler.New(db, cfg)
router := handler.NewRouter(h)
// Configure server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
slog.Info("server starting", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down server")
// Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
defer cancel()
// Graceful shutdown
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
os.Exit(1)
}
// Close database connections
db.Close()
slog.Info("server stopped gracefully")
}
When ECS sends SIGTERM during deployment, this code:
- Stops accepting new connections
- Waits for in-flight requests to complete (up to timeout)
- Closes database connections cleanly
- Exits with code 0
Health Checks and Readiness Probes
Two endpoints, two purposes:
// Liveness: is the process alive?
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// Readiness: can the service handle requests?
func (h *Handler) ReadinessCheck(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if err := h.db.Health(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"status": "unhealthy",
"reason": "database connection failed",
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ready",
})
}
ALB health checks hit /health. Kubernetes/ECS readiness probes hit /ready. The distinction matters: a service that is alive but cannot reach its database should stop receiving traffic without being killed.
Dockerfile: Multi-Stage Build
From 1.2GB to 12MB:
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Build binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o /app/api ./cmd/api
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/api /api
COPY --from=builder /app/migrations /migrations
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/api"]
Key decisions:
- distroless instead of scratch: includes CA certificates and timezone data
- nonroot user: security best practice, never run as root
- CGO_ENABLED=0: static binary, no C dependencies needed
- -ldflags=“-w -s”: strip debug info, reduce binary size by 20-30%
Testing Strategy
I test at three levels:
Unit Tests (domain and service layer)
func TestUserService_Create(t *testing.T) {
repo := &mockUserRepo{}
svc := service.NewUserService(repo)
user, err := svc.Create(context.Background(), "John", "john@example.com")
assert.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Equal(t, "John", user.Name)
assert.True(t, repo.createCalled)
}
Integration Tests (repository layer with real database)
func TestUserRepo_Create_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
db := setupTestDB(t) // Uses testcontainers
repo := postgres.NewUserRepo(db)
user := &domain.User{
ID: uuid.NewString(),
Name: "Test User",
Email: "test@example.com",
}
err := repo.Create(ctx, user)
assert.NoError(t, err)
found, err := repo.GetByID(ctx, user.ID)
assert.NoError(t, err)
assert.Equal(t, user.Name, found.Name)
}
API Tests (full HTTP request/response)
func TestGetUser_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/nonexistent", nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
Makefile for Common Tasks
.PHONY: build run test lint migrate
build:
go build -o bin/api ./cmd/api
run:
go run ./cmd/api
test:
go test ./... -race -coverprofile=coverage.out
test-integration:
go test ./... -race -run Integration
lint:
golangci-lint run ./...
migrate-up:
migrate -path migrations -database "$(DATABASE_URL)" up
migrate-down:
migrate -path migrations -database "$(DATABASE_URL)" down 1
docker-build:
docker build -t my-api:latest .
docker-run:
docker run -p 8080:8080 --env-file .env my-api:latest
What This Guide Does Not Cover
This is already long, so I intentionally left out topics that deserve their own articles:
- Error handling patterns - will be covered in an upcoming article on Go microservice error handling
- Deployment pipelines - see my CI/CD with GitHub Actions and ECS guide
- Infrastructure setup - covered in AWS architecture patterns for small teams
Wrapping Up
This template works. I have used it for payment APIs processing thousands of transactions per minute, for real-time messaging services, and for data ingestion pipelines. The patterns scale.
Start with this structure, customize for your domain, and iterate. Do not add complexity you do not need yet, but do not skip the fundamentals like graceful shutdown, health checks, and proper connection pooling.
If you want hands-on training for your team on building production Go services, check out my professional development sessions. We build a real service from scratch using these exact patterns.
See this architecture applied in real projects: Go gRPC backend service and telco loyalty backend.