Building Production-Ready REST APIs in Go: A Complete Guide
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 pointsinternal/: 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!