Skip to main content

Command Palette

Search for a command to run...

Building Production-Ready REST APIs in Go: A Complete Guide

Published
8 min read
E

Backend Developer | Golang & Python I enjoy building reliable APIs, distributed systems, and automation tools. Writing here about backend engineering, system design, and real-world dev experiences.

Introduction

I've built a lot of REST APIs. Some were garbage. Some actually worked in production.

The difference? The working ones followed patterns. They had structure, error handling, validation, observability, and graceful shutdown.

In this post, I'll show you how to build a production-ready REST API in Go. Not a toy example—real code that can handle production traffic.

Project Structure

First, structure matters:

/api-project
├── cmd/
│   └── server/
│       └── main.go          # Entry point
├── internal/
│   ├── handler/             # HTTP handlers
│   ├── service/             # Business logic
│   ├── repository/          # Data access
│   ├── model/               # Domain models
│   ├── middleware/          # HTTP middleware
│   └── config/              # Configuration
├── pkg/
│   └── response/            # Reusable response types
├── go.mod
└── go.sum

Why this structure?

  • cmd/: Application entry points

  • internal/: Private application code (can't be imported)

  • pkg/: Public libraries (can be imported)

Basic HTTP Server Setup

// cmd/server/main.go
package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gorilla/mux"
    "go.uber.org/zap"
)

func main() {
    // Logger
    logger, _ := zap.NewProduction()
    defer logger.Sync()

    // Router
    router := mux.NewRouter()

    // Health check
    router.HandleFunc("/health", healthHandler).Methods("GET")

    // API routes
    api := router.PathPrefix("/api/v1").Subrouter()
    setupRoutes(api, logger)

    // Server
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      router,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Start server
    go func() {
        logger.Info("starting server", zap.String("addr", srv.Addr))
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            logger.Fatal("server failed", zap.Error(err))
        }
    }()

    // Graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit

    logger.Info("shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        logger.Fatal("server forced to shutdown", zap.Error(err))
    }

    logger.Info("server exited")
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

Key features:

  • Structured logging with Zap

  • Request timeouts

  • Graceful shutdown

Dependency Injection

Don't use global variables. Inject dependencies:

// internal/handler/user.go
package handler

import (
    "encoding/json"
    "net/http"

    "github.com/gorilla/mux"
    "go.uber.org/zap"
)

type UserHandler struct {
    service UserService
    logger  *zap.Logger
}

func NewUserHandler(service UserService, logger *zap.Logger) *UserHandler {
    return &UserHandler{
        service: service,
        logger:  logger,
    }
}

func (h *UserHandler) RegisterRoutes(router *mux.Router) {
    router.HandleFunc("/users", h.CreateUser).Methods("POST")
    router.HandleFunc("/users/{id}", h.GetUser).Methods("GET")
    router.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
    router.HandleFunc("/users/{id}", h.DeleteUser).Methods("DELETE")
    router.HandleFunc("/users", h.ListUsers).Methods("GET")
}

Request/Response Models

Define clear contracts:

// internal/model/user.go
package model

import "time"

type User struct {
    ID        string    `json:"id" bson:"_id"`
    Email     string    `json:"email" bson:"email"`
    Name      string    `json:"name" bson:"name"`
    CreatedAt time.Time `json:"created_at" bson:"created_at"`
    UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
}

// Request DTOs
type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Name  string `json:"name" validate:"required,min=2,max=100"`
}

type UpdateUserRequest struct {
    Email string `json:"email" validate:"omitempty,email"`
    Name  string `json:"name" validate:"omitempty,min=2,max=100"`
}

// Response DTOs
type UserResponse struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
}

func (u *User) ToResponse() *UserResponse {
    return &UserResponse{
        ID:        u.ID,
        Email:     u.Email,
        Name:      u.Name,
        CreatedAt: u.CreatedAt,
    }
}

Separate internal models from API responses. Never expose database schemas directly.

Validation

Use go-playground/validator:

// internal/handler/validation.go
package handler

import (
    "encoding/json"
    "net/http"

    "github.com/go-playground/validator/v10"
)

var validate = validator.New()

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest

    // Decode JSON
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "Invalid JSON")
        return
    }

    // Validate
    if err := validate.Struct(req); err != nil {
        validationErrors := err.(validator.ValidationErrors)
        respondError(w, http.StatusBadRequest, formatValidationErrors(validationErrors))
        return
    }

    // Process request
    user, err := h.service.CreateUser(r.Context(), req)
    if err != nil {
        h.logger.Error("failed to create user", zap.Error(err))
        respondError(w, http.StatusInternalServerError, "Failed to create user")
        return
    }

    respondJSON(w, http.StatusCreated, user.ToResponse())
}

func formatValidationErrors(errs validator.ValidationErrors) string {
    var messages []string
    for _, err := range errs {
        messages = append(messages, fmt.Sprintf("%s: %s", err.Field(), err.Tag()))
    }
    return strings.Join(messages, ", ")
}

Standardized Response Format

// pkg/response/response.go
package response

type Response struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   *Error      `json:"error,omitempty"`
}

type Error struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)

    resp := Response{
        Success: status >= 200 && status < 300,
        Data:    data,
    }

    json.NewEncoder(w).Encode(resp)
}

func respondError(w http.ResponseWriter, status int, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)

    resp := Response{
        Success: false,
        Error: &Error{
            Code:    http.StatusText(status),
            Message: message,
        },
    }

    json.NewEncoder(w).Encode(resp)
}

Responses:

// Success
{
    "success": true,
    "data": {
        "id": "123",
        "email": "user@example.com",
        "name": "John Doe"
    }
}

// Error
{
    "success": false,
    "error": {
        "code": "Bad Request",
        "message": "Email: required"
    }
}

Middleware Stack

Essential middleware:

// internal/middleware/middleware.go
package middleware

import (
    "net/http"
    "time"

    "github.com/google/uuid"
    "go.uber.org/zap"
)

// Request ID
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
        }

        w.Header().Set("X-Request-ID", requestID)
        ctx := context.WithValue(r.Context(), "request_id", requestID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Logging
func Logging(logger *zap.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()

            // Wrap response writer to capture status code
            wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

            next.ServeHTTP(wrapped, r)

            logger.Info("request",
                zap.String("method", r.Method),
                zap.String("path", r.URL.Path),
                zap.Int("status", wrapped.statusCode),
                zap.Duration("duration", time.Since(start)),
                zap.String("request_id", getRequestID(r.Context())),
            )
        })
    }
}

// CORS
func CORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusOK)
            return
        }

        next.ServeHTTP(w, r)
    })
}

// Recovery
func Recovery(logger *zap.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if err := recover(); err != nil {
                    logger.Error("panic recovered",
                        zap.Any("error", err),
                        zap.String("path", r.URL.Path),
                    )

                    http.Error(w, "Internal Server Error", http.StatusInternalServerError)
                }
            }()

            next.ServeHTTP(w, r)
        })
    }
}

// Rate Limiting
func RateLimit(rps int) func(http.Handler) http.Handler {
    limiter := rate.NewLimiter(rate.Limit(rps), rps)

    return func(next http.Handler) http.Handler {
        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)
        })
    }
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

Apply middleware:

router := mux.NewRouter()

// Global middleware
router.Use(middleware.RequestID)
router.Use(middleware.Recovery(logger))
router.Use(middleware.Logging(logger))
router.Use(middleware.CORS)

// API-specific middleware
api := router.PathPrefix("/api/v1").Subrouter()
api.Use(middleware.RateLimit(100)) // 100 requests/second

Authentication Middleware

// internal/middleware/auth.go
package middleware

import (
    "context"
    "net/http"
    "strings"

    "github.com/golang-jwt/jwt/v5"
)

func Auth(jwtSecret string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, "Missing authorization header", http.StatusUnauthorized)
                return
            }

            parts := strings.Split(authHeader, " ")
            if len(parts) != 2 || parts[0] != "Bearer" {
                http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
                return
            }

            tokenString := parts[1]

            token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
                return []byte(jwtSecret), nil
            })

            if err != nil || !token.Valid {
                http.Error(w, "Invalid token", http.StatusUnauthorized)
                return
            }

            claims := token.Claims.(jwt.MapClaims)
            userID := claims["user_id"].(string)

            ctx := context.WithValue(r.Context(), "user_id", userID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// Apply to specific routes
protectedRoutes := api.PathPrefix("").Subrouter()
protectedRoutes.Use(middleware.Auth(jwtSecret))
protectedRoutes.HandleFunc("/profile", handler.GetProfile).Methods("GET")

Service Layer

Business logic goes here:

// internal/service/user.go
package service

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
)

type UserService struct {
    repo   UserRepository
    logger *zap.Logger
}

func NewUserService(repo UserRepository, logger *zap.Logger) *UserService {
    return &UserService{
        repo:   repo,
        logger: logger,
    }
}

func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
    // Check if user exists
    existing, err := s.repo.FindByEmail(ctx, req.Email)
    if err == nil && existing != nil {
        return nil, ErrUserAlreadyExists
    }

    // Create user
    user := &User{
        ID:        uuid.New().String(),
        Email:     req.Email,
        Name:      req.Name,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    if err := s.repo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("failed to create user: %w", err)
    }

    s.logger.Info("user created", zap.String("user_id", user.ID))

    return user, nil
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("failed to get user: %w", err)
    }

    return user, nil
}

Repository Pattern

Data access layer:

// internal/repository/user.go
package repository

import (
    "context"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
)

type UserRepository struct {
    collection *mongo.Collection
}

func NewUserRepository(db *mongo.Database) *UserRepository {
    return &UserRepository{
        collection: db.Collection("users"),
    }
}

func (r *UserRepository) Create(ctx context.Context, user *User) error {
    _, err := r.collection.InsertOne(ctx, user)
    if err != nil {
        return fmt.Errorf("insert failed: %w", err)
    }
    return nil
}

func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    var user User
    err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("query failed: %w", err)
    }
    return &user, nil
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var user User
    err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("query failed: %w", err)
    }
    return &user, nil
}

Configuration

// internal/config/config.go
package config

import (
    "github.com/spf13/viper"
)

type Config struct {
    Server   ServerConfig
    Database DatabaseConfig
    JWT      JWTConfig
}

type ServerConfig struct {
    Port         string
    ReadTimeout  int
    WriteTimeout int
}

type DatabaseConfig struct {
    URI      string
    Database string
}

type JWTConfig struct {
    Secret string
    Expiry int
}

func Load() (*Config, error) {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath(".")
    viper.AddConfigPath("./config")

    viper.AutomaticEnv()

    if err := viper.ReadInConfig(); err != nil {
        return nil, err
    }

    var config Config
    if err := viper.Unmarshal(&config); err != nil {
        return nil, err
    }

    return &config, nil
}

config.yaml:

server:
  port: ":8080"
  read_timeout: 15
  write_timeout: 15

database:
  uri: "mongodb://localhost:27017"
  database: "myapp"

jwt:
  secret: "your-secret-key"
  expiry: 3600

Testing

// internal/handler/user_test.go
package handler

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gorilla/mux"
    "github.com/stretchr/testify/assert"
    "go.uber.org/zap"
)

func TestUserHandler_CreateUser(t *testing.T) {
    mockService := &MockUserService{}
    handler := NewUserHandler(mockService, zap.NewNop())

    reqBody := CreateUserRequest{
        Email: "test@example.com",
        Name:  "Test User",
    }
    body, _ := json.Marshal(reqBody)

    req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
    rec := httptest.NewRecorder()

    router := mux.NewRouter()
    handler.RegisterRoutes(router)
    router.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusCreated, rec.Code)

    var resp Response
    json.NewDecoder(rec.Body).Decode(&resp)
    assert.True(t, resp.Success)
}

Conclusion

Production-ready REST APIs need:

✅ Structured project layout ✅ Dependency injection ✅ Request validation ✅ Standardized responses ✅ Comprehensive middleware ✅ Error handling ✅ Logging ✅ Graceful shutdown ✅ Tests

Start with this foundation and scale from there.

Questions? Comment below!