Back to Blog
· 11 min read · EN

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.

BackendTutorial #go#golang#api#rest#microservices#production#clean-architecture
Building Production-Ready Go APIs: The Complete Guide

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:

Clean Architecture Layers

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:

Featurenet/http (stdlib)ChiGin
URL parametersManual parsingBuilt-inBuilt-in
Middleware chainingManualBuilt-inBuilt-in
stdlib compatibleYesYesNo
External deps009+
Route groupsNoYesYes
PerformanceBaseline~SameSlightly 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:

  1. Stops accepting new connections
  2. Waits for in-flight requests to complete (up to timeout)
  3. Closes database connections cleanly
  4. 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:

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.