Building Production Ready REST APIs in Go: The Complete Gold Standard Guide

You've built APIs before. They work on your machine. They pass your tests. Then they hit production and everything falls apart. Users complain about slow responses. Your logs are a mess. Someone bypasses authentication. The database gets hammered. Sound familiar?
Building an API that works is easy. Building an API that survives production is an entirely different game. This guide will transform you from someone who builds APIs to someone who builds production grade systems that handle millions of requests, recover from failures gracefully, and let you sleep peacefully at night.

Table of Contents

  1. The Anatomy of a Production API
  2. Project Structure That Scales
  3. HTTP Methods: When and Why
  4. HTTP Status Codes: Speaking the Right Language
  5. Routing Done Right
  6. Middleware: The Unsung Heroes
  7. Request Validation: Trust Nobody
  8. Error Handling: Failing Gracefully
  9. Authentication: Who Are You?
  10. Authorization: What Can You Do?
  11. Database Patterns for APIs
  12. Caching Strategies
  13. Rate Limiting: Protecting Your Service
  14. Logging: Your Production Lifeline
  15. Monitoring and Observability
  16. HATEOAS: Self Documenting APIs
  17. API Versioning
  18. Testing Production APIs
  19. Deployment Considerations
  20. Complete Working Example

The Anatomy of a Production API

💡 Think of it like this: A production API is like a well run hospital emergency room. Patients (requests) arrive constantly, some critical, some routine. There's a triage system (load balancer), specialized doctors (handlers), medical records (database), security (authentication), visiting hours policy (rate limiting), and detailed charts (logging). Everything works together, and if one system fails, others compensate.
Before we write a single line of code, let's understand what separates a hobby project from a production system.

What Makes an API "Production Ready"?

REST API architecture diagram 1

REST API architecture diagram 1

A production API must handle:
ConcernWhy It MattersConsequence of Ignoring
High AvailabilityUsers expect 99.9%+ uptimeLost revenue, angry customers
ScalabilityTraffic spikes are inevitableCrashed servers, timeouts
SecurityAttackers are always probingData breaches, lawsuits
ObservabilityYou can't fix what you can't seeBlind debugging for hours
ResilienceDependencies failCascading failures
PerformanceEvery millisecond countsPoor user experience

Project Structure That Scales

One of the biggest mistakes developers make is treating project structure as an afterthought. A well organized codebase is not just about aesthetics. It's about maintainability, testability, and team productivity.

The Gold Standard: Domain Driven Structure

myapi/ ├── cmd/ │ └── api/ │ └── main.go # Application entry point ├── internal/ │ ├── config/ │ │ └── config.go # Configuration management │ ├── domain/ │ │ ├── user/ │ │ │ ├── entity.go # User domain entity │ │ │ ├── repository.go # Repository interface │ │ │ └── service.go # Business logic │ │ └── order/ │ │ ├── entity.go │ │ ├── repository.go │ │ └── service.go │ ├── handler/ │ │ ├── user_handler.go # HTTP handlers for users │ │ ├── order_handler.go │ │ └── health_handler.go │ ├── middleware/ │ │ ├── auth.go # Authentication middleware │ │ ├── logging.go # Request logging │ │ ├── ratelimit.go # Rate limiting │ │ ├── recovery.go # Panic recovery │ │ └── cors.go # CORS handling │ ├── repository/ │ │ ├── postgres/ │ │ │ ├── user_repo.go # PostgreSQL implementation │ │ │ └── order_repo.go │ │ └── redis/ │ │ └── cache.go # Redis cache implementation │ ├── router/ │ │ └── router.go # Route definitions │ └── pkg/ │ ├── validator/ │ │ └── validator.go # Request validation │ ├── response/ │ │ └── response.go # Standardized responses │ ├── errors/ │ │ └── errors.go # Custom error types │ └── logger/ │ └── logger.go # Structured logging ├── migrations/ │ ├── 001_create_users.up.sql │ └── 001_create_users.down.sql ├── api/ │ └── openapi.yaml # API specification ├── scripts/ │ └── setup.sh # Development scripts ├── Dockerfile ├── docker-compose.yml ├── Makefile ├── go.mod └── go.sum

Why This Structure?

1. cmd/ directory: Contains application entry points. If you have multiple binaries (API server, worker, CLI), each gets its own subdirectory.
2. internal/ directory: Go's special directory. Code here cannot be imported by external packages. This enforces encapsulation at the package level.
3. Domain driven packages: Each business domain (user, order, product) is self contained. This makes it easy to understand, test, and eventually extract into microservices if needed.
4. Separation of concerns:
  • entity.go: Pure data structures, no dependencies
  • repository.go: Interface defining data access contracts
  • service.go: Business logic, depends on repository interface (not implementation)
5. handler/ vs domain/: Handlers deal with HTTP concerns (parsing requests, formatting responses). Domain services deal with business logic. Never mix them.
Let's see this in code:
// Filename: internal/domain/user/entity.go package user import ( "time" ) // User represents a user in our system // Why: This is a pure domain entity with no external dependencies. // It represents the core business concept of a "user" and can be used // across all layers without importing HTTP or database packages. type User struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role Role `json:"role"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Role represents user roles in the system // Why: Using a custom type instead of string provides type safety // and prevents invalid role assignments at compile time. type Role string const ( RoleAdmin Role = "admin" RoleUser Role = "user" RoleGuest Role = "guest" ) // IsValid checks if the role is a valid system role // Why: Validation at the domain level ensures business rules // are enforced regardless of how the data enters the system. func (r Role) IsValid() bool { switch r { case RoleAdmin, RoleUser, RoleGuest: return true default: return false } }

HTTP Methods: When and Why

HTTP methods are not just conventions. They carry semantic meaning that clients, caches, and proxies rely on. Using them correctly makes your API predictable and cacheable.
REST API architecture diagram 2

REST API architecture diagram 2

The Complete HTTP Methods Guide

MethodPurposeIdempotentSafeCacheableRequest Body
GETRetrieve resourceYesYesYesNo
POSTCreate resourceNoNoRarelyYes
PUTReplace resourceYesNoNoYes
PATCHPartial updateNoNoNoYes
DELETERemove resourceYesNoNoOptional
HEADGet headers onlyYesYesYesNo
OPTIONSGet capabilitiesYesYesNoNo

Understanding Idempotency

💡 Think of it like this: Pressing an elevator button once or ten times has the same effect. The elevator comes. That's idempotency. But putting a letter in a mailbox is not idempotent. Each letter you put in is a new letter.
Idempotent means making the same request multiple times produces the same result. This is crucial for:
  • Retry logic (network failures)
  • Caching
  • Distributed systems
go
// Filename: internal/handler/user_handler.go package handler import ( "encoding/json" "net/http" "myapi/internal/domain/user" "myapi/internal/pkg/response" ) type UserHandler struct { service *user.Service } func NewUserHandler(service *user.Service) *UserHandler { return &UserHandler{service: service} } // GET /users/{id} // Why GET: We're retrieving data without modifying anything. // GET is safe (no side effects) and cacheable. func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { // Why: Extract ID from URL path id := r.PathValue("id") // Go 1.22+ built-in routing user, err := h.service.GetUser(r.Context(), id) if err != nil { response.Error(w, http.StatusNotFound, "User not found") return } response.JSON(w, http.StatusOK, user) } // POST /users // Why POST: We're creating a new resource. Each request creates // a new user, so it's not idempotent. POST is the correct choice. func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "Invalid request body") return } // Why: Service layer handles business logic user, err := h.service.CreateUser( r.Context(), req.Email, req.Name, user.Role(req.Role), ) if err != nil { response.Error(w, http.StatusBadRequest, err.Error()) return } // Why 201: Resource was created. Include Location header // pointing to the new resource. w.Header().Set("Location", "/users/"+user.ID) response.JSON(w, http.StatusCreated, user) } // PUT /users/{id} // Why PUT: We're replacing the entire resource. PUT is idempotent. // Making the same PUT request multiple times results in the same state. func (h *UserHandler) ReplaceUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req ReplaceUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "Invalid request body") return } // Why: PUT replaces the entire resource, so all fields are required user, err := h.service.ReplaceUser(r.Context(), id, req.Email, req.Name, user.Role(req.Role)) if err != nil { response.Error(w, http.StatusBadRequest, err.Error()) return } response.JSON(w, http.StatusOK, user) } // PATCH /users/{id} // Why PATCH: We're making a partial update. Only the fields // provided in the request will be updated. func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "Invalid request body") return } // Why: PATCH allows partial updates user, err := h.service.UpdateUser(r.Context(), id, req.Name, user.Role(req.Role)) if err != nil { response.Error(w, http.StatusBadRequest, err.Error()) return } response.JSON(w, http.StatusOK, user) } // DELETE /users/{id} // Why DELETE: We're removing a resource. DELETE is idempotent. // Deleting the same resource twice results in the same state (deleted). func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := h.service.DeleteUser(r.Context(), id); err != nil { response.Error(w, http.StatusNotFound, "User not found") return } // Why 204: Successful deletion with no content to return w.WriteHeader(http.StatusNoContent) } // Request DTOs type CreateUserRequest struct { Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` } type ReplaceUserRequest struct { Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` } type UpdateUserRequest struct { Name string `json:"name,omitempty"` Role string `json:"role,omitempty"` }

PUT vs PATCH: The Critical Difference

This confuses many developers. Here's the definitive answer:
PUT = "Here's the complete new version of this resource"
json
PUT /users/123 { "email": "new@example.com", "name": "New Name", "role": "admin" }
If you omit a field, it should be set to null or default.
PATCH = "Here are the fields I want to change"
json
PATCH /users/123 { "name": "New Name" }
Only the name changes. Email and role stay the same.

When to Use POST for Actions

Sometimes you need to perform actions that don't fit CRUD operations:
POST /users/123/suspend # Suspend a user POST /orders/456/cancel # Cancel an order POST /payments/process # Process payment POST /reports/generate # Generate a report
Why POST for actions?
  • They're not idempotent (suspending twice might have different effects)
  • They don't map cleanly to resource creation/modification
  • They often trigger side effects (emails, notifications)

HTTP Status Codes: Speaking the Right Language

Status codes tell clients what happened without parsing the response body. Using them correctly enables proper error handling on the client side.
REST API architecture diagram 3

REST API architecture diagram 3

The Complete Status Code Guide for APIs

Success Codes (2xx)

CodeNameWhen to UseExample
200OKRequest succeeded, returning dataGET /users/123
201CreatedResource createdPOST /users
202AcceptedRequest accepted, processing asyncPOST /reports/generate
204No ContentSuccess, nothing to returnDELETE /users/123

Client Error Codes (4xx)

CodeNameWhen to UseExample
400Bad RequestMalformed request syntaxInvalid JSON
401UnauthorizedMissing/invalid authenticationNo token provided
403ForbiddenAuthenticated but not authorizedUser accessing admin route
404Not FoundResource doesn't existGET /users/nonexistent
405Method Not AllowedWrong HTTP methodPOST to GET-only endpoint
409ConflictResource state conflictDuplicate email
422Unprocessable EntityValid syntax, invalid semanticsEmail format invalid
429Too Many RequestsRate limit exceededMore than 100 req/min

Server Error Codes (5xx)

CodeNameWhen to UseExample
500Internal Server ErrorUnexpected server errorUnhandled exception
502Bad GatewayUpstream service failedDatabase unreachable
503Service UnavailableServer overloaded/maintenanceShutting down
504Gateway TimeoutUpstream service timeoutSlow database query

400 vs 422: The Subtle Difference

400 Bad Request: The request is syntactically incorrect
  • Malformed JSON
  • Missing required headers
  • Invalid content type
422 Unprocessable Entity: The request is syntactically correct but semantically invalid
  • Email format is invalid
  • Password too short
  • Date in the past when future required
go
// Filename: internal/pkg/response/response.go package response import ( "encoding/json" "net/http" ) // APIResponse is the standard response wrapper // Why: Consistent response format makes client implementation easier // and allows for metadata like pagination info. type APIResponse struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error *APIError `json:"error,omitempty"` Meta *Meta `json:"meta,omitempty"` } // APIError represents an error response // Why: Structured errors allow clients to handle errors programmatically type APIError struct { Code string `json:"code"` // Machine-readable code Message string `json:"message"` // Human-readable message Details map[string]string `json:"details,omitempty"` // Field-level errors } // Meta contains response metadata type Meta struct { Page int `json:"page,omitempty"` PageSize int `json:"page_size,omitempty"` TotalItems int `json:"total_items,omitempty"` TotalPages int `json:"total_pages,omitempty"` } // JSON sends a successful JSON response // Why: Centralizing response handling ensures consistent Content-Type // headers and JSON encoding across all handlers. func JSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) resp := APIResponse{ Success: true, Data: data, } json.NewEncoder(w).Encode(resp) } // JSONWithMeta sends a JSON response with metadata // Why: Useful for paginated responses func JSONWithMeta(w http.ResponseWriter, status int, data interface{}, meta *Meta) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) resp := APIResponse{ Success: true, Data: data, Meta: meta, } json.NewEncoder(w).Encode(resp) } // Error sends an error response // Why: Consistent error format helps clients handle errors uniformly func Error(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) resp := APIResponse{ Success: false, Error: &APIError{ Code: httpStatusToCode(status), Message: message, }, } json.NewEncoder(w).Encode(resp) } // ValidationError sends a 422 with field-level errors // Why: Helps clients display errors next to form fields func ValidationError(w http.ResponseWriter, errors map[string]string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnprocessableEntity) resp := APIResponse{ Success: false, Error: &APIError{ Code: "VALIDATION_ERROR", Message: "Validation failed", Details: errors, }, } json.NewEncoder(w).Encode(resp) } // httpStatusToCode converts HTTP status to error code // Why: Machine-readable codes are easier to handle than status numbers func httpStatusToCode(status int) string { codes := map[int]string{ 400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN", 404: "NOT_FOUND", 409: "CONFLICT", 422: "VALIDATION_ERROR", 429: "RATE_LIMITED", 500: "INTERNAL_ERROR", 502: "BAD_GATEWAY", 503: "SERVICE_UNAVAILABLE", } if code, ok := codes[status]; ok { return code } return "UNKNOWN_ERROR" }
Expected Response Formats:
Success Response:
json
{ "success": true, "data": { "id": "123", "email": "user@example.com", "name": "John Doe", "role": "user" } }
Paginated Response:
json
{ "success": true, "data": [ {"id": "1", "name": "User 1"}, {"id": "2", "name": "User 2"} ], "meta": { "page": 1, "page_size": 10, "total_items": 100, "total_pages": 10 } }
Error Response:
json
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": { "email": "Invalid email format", "password": "Must be at least 8 characters" } } }

Routing Done Right

Routing is the backbone of your API. Good URL design makes your API intuitive and self documenting.

RESTful URL Design Principles

REST API architecture diagram 4

REST API architecture diagram 4

URL Design Rules

1. Use nouns, not verbs
✅ GET /users (get all users) ✅ POST /users (create user) ❌ GET /getUsers ❌ POST /createUser
2. Use plural nouns
✅ /users ✅ /orders ✅ /products ❌ /user ❌ /order
3. Use hyphens for readability
✅ /user-profiles ✅ /order-items ❌ /user_profiles ❌ /orderItems
4. Nest resources logically
✅ /users/123/orders (orders belonging to user 123) ✅ /orders/456/items (items in order 456) ❌ /getUserOrders?userId=123
5. Use query parameters for filtering, sorting, pagination
✅ /users?role=admin&sort=created_at&order=desc&page=2&limit=10 ❌ /users/admin/sorted-by-date/page-2

Complete Router Implementation

go
// Filename: internal/router/router.go package router import ( "net/http" "myapi/internal/handler" "myapi/internal/middleware" ) // Config holds router dependencies type Config struct { UserHandler *handler.UserHandler OrderHandler *handler.OrderHandler HealthHandler *handler.HealthHandler AuthMW *middleware.AuthMiddleware RateLimitMW *middleware.RateLimitMiddleware LoggingMW *middleware.LoggingMiddleware RecoveryMW *middleware.RecoveryMiddleware CORSMW *middleware.CORSMiddleware } // New creates and configures the router // Why: Centralizing route definitions makes the API structure clear // and ensures consistent middleware application. func New(cfg Config) http.Handler { mux := http.NewServeMux() // Health check endpoints (no auth required) // Why: Load balancers and orchestrators need to check health mux.HandleFunc("GET /health", cfg.HealthHandler.Health) mux.HandleFunc("GET /ready", cfg.HealthHandler.Ready) // API v1 routes // Why: Version prefix allows breaking changes in future versions // Public routes (no authentication) mux.HandleFunc("POST /api/v1/auth/login", cfg.UserHandler.Login) mux.HandleFunc("POST /api/v1/auth/register", cfg.UserHandler.Register) mux.HandleFunc("POST /api/v1/auth/refresh", cfg.UserHandler.RefreshToken) // Protected routes (authentication required) // Why: Using separate handler registration allows middleware chaining mux.Handle("GET /api/v1/users", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.UserHandler.ListUsers))) mux.Handle("POST /api/v1/users", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.UserHandler.CreateUser))) mux.Handle("GET /api/v1/users/{id}", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.UserHandler.GetUser))) mux.Handle("PUT /api/v1/users/{id}", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.UserHandler.ReplaceUser))) mux.Handle("PATCH /api/v1/users/{id}", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.UserHandler.UpdateUser))) mux.Handle("DELETE /api/v1/users/{id}", cfg.AuthMW.Authenticate( cfg.AuthMW.RequireRole("admin")( http.HandlerFunc(cfg.UserHandler.DeleteUser)))) // Nested resources mux.Handle("GET /api/v1/users/{id}/orders", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.OrderHandler.GetUserOrders))) mux.Handle("POST /api/v1/users/{id}/orders", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.OrderHandler.CreateOrder))) // Order routes mux.Handle("GET /api/v1/orders", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.OrderHandler.ListOrders))) mux.Handle("GET /api/v1/orders/{id}", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.OrderHandler.GetOrder))) mux.Handle("POST /api/v1/orders/{id}/cancel", cfg.AuthMW.Authenticate(http.HandlerFunc(cfg.OrderHandler.CancelOrder))) // Apply global middleware chain // Why: Middleware order matters! // 1. Recovery: Catch panics first (outermost) // 2. CORS: Handle preflight before other processing // 3. Logging: Log all requests // 4. RateLimit: Protect before expensive operations var handler http.Handler = mux handler = cfg.RateLimitMW.Limit(handler) handler = cfg.LoggingMW.Log(handler) handler = cfg.CORSMW.Handle(handler) handler = cfg.RecoveryMW.Recover(handler) return handler }

Middleware: The Unsung Heroes

Middleware is code that runs before and/or after your handlers. It's perfect for cross cutting concerns like logging, authentication, and rate limiting.
REST API architecture diagram 5

REST API architecture diagram 5

Recovery Middleware: Catching Panics

go
// Filename: internal/middleware/recovery.go package middleware import ( "log/slog" "net/http" "runtime/debug" "myapi/internal/pkg/response" ) // RecoveryMiddleware recovers from panics // Why: Panics in handlers shouldn't crash the entire server. // This middleware catches panics, logs them, and returns a proper error. type RecoveryMiddleware struct { logger *slog.Logger } func NewRecoveryMiddleware(logger *slog.Logger) *RecoveryMiddleware { return &RecoveryMiddleware{logger: logger} } // Recover wraps a handler with panic recovery // Why: Without this, a panic in any handler would crash the server // and leave the client hanging with no response. func (m *RecoveryMiddleware) Recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { // Why: Log the full stack trace for debugging m.logger.Error("panic recovered", "error", err, "stack", string(debug.Stack()), "path", r.URL.Path, "method", r.Method, ) // Why: Return 500 to client without exposing internal details response.Error(w, http.StatusInternalServerError, "An unexpected error occurred") } }() next.ServeHTTP(w, r) }) }

Logging Middleware: Your Production Lifeline

go
// Filename: internal/middleware/logging.go package middleware import ( "log/slog" "net/http" "time" ) // LoggingMiddleware logs HTTP requests // Why: Request logs are essential for debugging, auditing, // and understanding traffic patterns. type LoggingMiddleware struct { logger *slog.Logger } func NewLoggingMiddleware(logger *slog.Logger) *LoggingMiddleware { return &LoggingMiddleware{logger: logger} } // responseWriter wraps http.ResponseWriter to capture status code // Why: The standard ResponseWriter doesn't expose the status code // after it's been written, so we need to capture it. type responseWriter struct { http.ResponseWriter status int wroteHeader bool size int } func wrapResponseWriter(w http.ResponseWriter) *responseWriter { return &responseWriter{ResponseWriter: w, status: http.StatusOK} } func (rw *responseWriter) WriteHeader(code int) { if rw.wroteHeader { return } rw.status = code rw.wroteHeader = true rw.ResponseWriter.WriteHeader(code) } func (rw *responseWriter) Write(b []byte) (int, error) { rw.size += len(b) return rw.ResponseWriter.Write(b) } // Log wraps a handler with request logging // Why: Every request should be logged for: // - Debugging issues // - Auditing access // - Performance monitoring // - Security analysis func (m *LoggingMiddleware) Log(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() // Why: Wrap response writer to capture status code and size wrapped := wrapResponseWriter(w) // Why: Extract request ID for correlation requestID := r.Header.Get("X-Request-ID") if requestID == "" { requestID = generateRequestID() } // Why: Add request ID to response for client correlation wrapped.Header().Set("X-Request-ID", requestID) // Process request next.ServeHTTP(wrapped, r) // Why: Calculate duration after request completes duration := time.Since(start) // Why: Log with structured fields for easy parsing m.logger.Info("http request", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "query", r.URL.RawQuery, "status", wrapped.status, "duration_ms", duration.Milliseconds(), "size", wrapped.size, "user_agent", r.UserAgent(), "remote_addr", r.RemoteAddr, ) }) } func generateRequestID() string { // In production, use a proper UUID library return time.Now().UnixNano() }

CORS Middleware: Handling Cross Origin Requests

go
// Filename: internal/middleware/cors.go package middleware import ( "net/http" "strings" ) // CORSConfig holds CORS configuration type CORSConfig struct { AllowedOrigins []string AllowedMethods []string AllowedHeaders []string ExposedHeaders []string AllowCredentials bool MaxAge int } // CORSMiddleware handles CORS // Why: Browsers enforce CORS for security. Without proper CORS headers, // frontend applications on different domains can't call your API. type CORSMiddleware struct { config CORSConfig } func NewCORSMiddleware(config CORSConfig) *CORSMiddleware { return &CORSMiddleware{config: config} } // Handle adds CORS headers to responses // Why: CORS involves two types of requests: // 1. Simple requests: GET/POST with standard headers // 2. Preflight requests: OPTIONS before complex requests func (m *CORSMiddleware) Handle(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") // Why: Check if origin is allowed if m.isOriginAllowed(origin) { w.Header().Set("Access-Control-Allow-Origin", origin) if m.config.AllowCredentials { // Why: Required for cookies/auth headers to be sent w.Header().Set("Access-Control-Allow-Credentials", "true") } // Why: Tell browser which headers it can access if len(m.config.ExposedHeaders) > 0 { w.Header().Set("Access-Control-Expose-Headers", strings.Join(m.config.ExposedHeaders, ", ")) } } // Why: Handle preflight OPTIONS request if r.Method == http.MethodOptions { // Why: Tell browser what methods are allowed w.Header().Set("Access-Control-Allow-Methods", strings.Join(m.config.AllowedMethods, ", ")) // Why: Tell browser what headers can be sent w.Header().Set("Access-Control-Allow-Headers", strings.Join(m.config.AllowedHeaders, ", ")) // Why: Cache preflight response to reduce OPTIONS requests if m.config.MaxAge > 0 { w.Header().Set("Access-Control-Max-Age", string(m.config.MaxAge)) } // Why: 204 for successful preflight, no body needed w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func (m *CORSMiddleware) isOriginAllowed(origin string) bool { // Why: "*" means allow all origins (not recommended for production) for _, allowed := range m.config.AllowedOrigins { if allowed == "*" || allowed == origin { return true } } return false }

Request Validation: Trust Nobody

💡 Think of it like this: A bank doesn't trust you just because you walked through the door. They verify your ID, check your signature, confirm your account number. Your API should do the same with every request.
Never trust user input. Validate everything. This prevents security vulnerabilities, data corruption, and confusing errors.
REST API architecture diagram 6

REST API architecture diagram 6

Comprehensive Validation Implementation

go
// Filename: internal/pkg/validator/validator.go package validator import ( "fmt" "net/mail" "regexp" "strings" "unicode" ) // ValidationErrors holds field-level errors // Why: Returning all errors at once is better UX than one at a time type ValidationErrors map[string]string func (v ValidationErrors) HasErrors() bool { return len(v) > 0 } func (v ValidationErrors) Add(field, message string) { v[field] = message } // Validator provides validation methods // Why: Centralizing validation logic ensures consistent rules // across the application and makes testing easier. type Validator struct { errors ValidationErrors } func New() *Validator { return &Validator{ errors: make(ValidationErrors), } } func (v *Validator) Errors() ValidationErrors { return v.errors } // Required checks if a string is not empty // Why: Most form fields require a value func (v *Validator) Required(field, value, message string) *Validator { if strings.TrimSpace(value) == "" { v.errors.Add(field, message) } return v } // Email validates email format // Why: Invalid emails cause delivery failures func (v *Validator) Email(field, value string) *Validator { if value == "" { return v // Let Required handle empty check } _, err := mail.ParseAddress(value) if err != nil { v.errors.Add(field, "Invalid email format") } return v } // MinLength checks minimum string length // Why: Passwords, usernames often have minimum lengths func (v *Validator) MinLength(field, value string, min int) *Validator { if len(value) < min { v.errors.Add(field, fmt.Sprintf("Must be at least %d characters", min)) } return v } // MaxLength checks maximum string length // Why: Prevent database overflow and DoS via large inputs func (v *Validator) MaxLength(field, value string, max int) *Validator { if len(value) > max { v.errors.Add(field, fmt.Sprintf("Must be at most %d characters", max)) } return v } // Password validates password strength // Why: Weak passwords are a security risk func (v *Validator) Password(field, value string) *Validator { if len(value) < 8 { v.errors.Add(field, "Password must be at least 8 characters") return v } var hasUpper, hasLower, hasNumber, hasSpecial bool for _, char := range value { switch { case unicode.IsUpper(char): hasUpper = true case unicode.IsLower(char): hasLower = true case unicode.IsNumber(char): hasNumber = true case unicode.IsPunct(char) || unicode.IsSymbol(char): hasSpecial = true } } if !hasUpper || !hasLower || !hasNumber || !hasSpecial { v.errors.Add(field, "Password must contain uppercase, lowercase, number, and special character") } return v } // UUID validates UUID format // Why: Invalid UUIDs will fail database lookups func (v *Validator) UUID(field, value string) *Validator { uuidRegex := regexp.MustCompile( `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) if !uuidRegex.MatchString(value) { v.errors.Add(field, "Invalid UUID format") } return v } // InList checks if value is in allowed list // Why: Enum-like fields should only accept known values func (v *Validator) InList(field, value string, allowed []string) *Validator { for _, a := range allowed { if value == a { return v } } v.errors.Add(field, fmt.Sprintf("Must be one of: %s", strings.Join(allowed, ", "))) return v } // Range checks if a number is within range // Why: Quantities, prices, ages have valid ranges func (v *Validator) Range(field string, value, min, max int) *Validator { if value < min || value > max { v.errors.Add(field, fmt.Sprintf("Must be between %d and %d", min, max)) } return v } // Positive checks if number is positive // Why: Prices, quantities can't be negative func (v *Validator) Positive(field string, value int) *Validator { if value <= 0 { v.errors.Add(field, "Must be a positive number") } return v } // URL validates URL format // Why: Invalid URLs break links and integrations func (v *Validator) URL(field, value string) *Validator { if value == "" { return v } urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`) if !urlRegex.MatchString(value) { v.errors.Add(field, "Invalid URL format") } return v } // Phone validates phone number (basic validation) // Why: Invalid phone numbers can't receive SMS func (v *Validator) Phone(field, value string) *Validator { if value == "" { return v } // Remove common formatting characters cleaned := regexp.MustCompile(`[\s\-\(\)\+]`).ReplaceAllString(value, "") if len(cleaned) < 10 || len(cleaned) > 15 { v.errors.Add(field, "Invalid phone number") } return v }

Using Validation in Handlers

go
// Filename: internal/handler/user_handler.go (continued) package handler import ( "encoding/json" "net/http" "myapi/internal/pkg/response" "myapi/internal/pkg/validator" ) // CreateUser with full validation func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { // Step 1: Parse request body // Why: Decode JSON first, then validate fields var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "Invalid JSON") return } // Step 2: Validate all fields // Why: Chain validations for clean code, collect all errors v := validator.New() v.Required("email", req.Email, "Email is required"). Email("email", req.Email) v.Required("name", req.Name, "Name is required"). MinLength("name", req.Name, 2). MaxLength("name", req.Name, 100) v.Required("password", req.Password, "Password is required"). Password("password", req.Password) v.Required("role", req.Role, "Role is required"). InList("role", req.Role, []string{"admin", "user", "guest"}) // Step 3: Return validation errors if any // Why: Return all errors at once for better UX if v.Errors().HasErrors() { response.ValidationError(w, v.Errors()) return } // Step 4: Process valid request // Why: Now we can trust the input user, err := h.service.CreateUser( r.Context(), req.Email, req.Name, req.Password, req.Role, ) if err != nil { // Why: Handle business logic errors (e.g., duplicate email) response.Error(w, http.StatusConflict, err.Error()) return } w.Header().Set("Location", "/api/v1/users/"+user.ID) response.JSON(w, http.StatusCreated, user) } type CreateUserRequest struct { Email string `json:"email"` Name string `json:"name"` Password string `json:"password"` Role string `json:"role"` }

Error Handling: Failing Gracefully

Errors are inevitable. How you handle them determines whether your API is a joy or a nightmare to work with.
REST API architecture diagram 7

REST API architecture diagram 7

Custom Error Types

go
// Filename: internal/pkg/errors/errors.go package errors import ( "errors" "fmt" "net/http" ) // AppError represents an application error // Why: Custom error type allows us to: // 1. Include HTTP status code // 2. Separate internal and external messages // 3. Add context like error codes type AppError struct { Code string // Machine-readable code Message string // User-friendly message Internal string // Internal details (logged, not returned) StatusCode int // HTTP status code Err error // Wrapped error for error chain } func (e *AppError) Error() string { if e.Err != nil { return fmt.Sprintf("%s: %v", e.Message, e.Err) } return e.Message } // Unwrap allows errors.Is and errors.As to work func (e *AppError) Unwrap() error { return e.Err } // Error constructors for common cases // Why: Consistent error creation across the codebase func NotFound(resource string) *AppError { return &AppError{ Code: "NOT_FOUND", Message: fmt.Sprintf("%s not found", resource), StatusCode: http.StatusNotFound, } } func BadRequest(message string) *AppError { return &AppError{ Code: "BAD_REQUEST", Message: message, StatusCode: http.StatusBadRequest, } } func Unauthorized(message string) *AppError { return &AppError{ Code: "UNAUTHORIZED", Message: message, StatusCode: http.StatusUnauthorized, } } func Forbidden(message string) *AppError { return &AppError{ Code: "FORBIDDEN", Message: message, StatusCode: http.StatusForbidden, } } func Conflict(message string) *AppError { return &AppError{ Code: "CONFLICT", Message: message, StatusCode: http.StatusConflict, } } func InternalError(internal string, err error) *AppError { return &AppError{ Code: "INTERNAL_ERROR", Message: "An internal error occurred", Internal: internal, StatusCode: http.StatusInternalServerError, Err: err, } } func DatabaseError(operation string, err error) *AppError { return &AppError{ Code: "DATABASE_ERROR", Message: "A database error occurred", Internal: fmt.Sprintf("database %s failed", operation), StatusCode: http.StatusInternalServerError, Err: err, } } func ServiceUnavailable(service string) *AppError { return &AppError{ Code: "SERVICE_UNAVAILABLE", Message: fmt.Sprintf("%s is currently unavailable", service), StatusCode: http.StatusServiceUnavailable, } } func RateLimited() *AppError { return &AppError{ Code: "RATE_LIMITED", Message: "Too many requests. Please try again later.", StatusCode: http.StatusTooManyRequests, } } // IsNotFound checks if error is a not found error // Why: Allow callers to check error type without type assertion func IsNotFound(err error) bool { var appErr *AppError if errors.As(err, &appErr) { return appErr.Code == "NOT_FOUND" } return false } // IsConflict checks if error is a conflict error func IsConflict(err error) bool { var appErr *AppError if errors.As(err, &appErr) { return appErr.Code == "CONFLICT" } return false }

Error Handling Middleware

go
// Filename: internal/middleware/error_handler.go package middleware import ( "errors" "log/slog" "net/http" apperrors "myapi/internal/pkg/errors" "myapi/internal/pkg/response" ) // ErrorHandler is a middleware that handles errors returned by handlers // Why: Centralizes error response formatting and logging type ErrorHandler struct { logger *slog.Logger } func NewErrorHandler(logger *slog.Logger) *ErrorHandler { return &ErrorHandler{logger: logger} } // HandleError processes an error and sends appropriate response // Why: Single point for error response logic func (h *ErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) { // Why: Check if it's our custom AppError var appErr *apperrors.AppError if errors.As(err, &appErr) { // Log internal details if present if appErr.Internal != "" { h.logger.Error("application error", "code", appErr.Code, "message", appErr.Message, "internal", appErr.Internal, "path", r.URL.Path, "method", r.Method, ) } // Return sanitized error to client response.Error(w, appErr.StatusCode, appErr.Message) return } // Why: Unknown errors get logged with full details h.logger.Error("unexpected error", "error", err.Error(), "path", r.URL.Path, "method", r.Method, ) // Why: Don't expose internal error details to client response.Error(w, http.StatusInternalServerError, "An unexpected error occurred") }

Authentication: Who Are You?

Authentication verifies the identity of the requester. Without it, anyone can access your API.
REST API architecture diagram 8

REST API architecture diagram 8

JWT Authentication Implementation

go
// Filename: internal/auth/jwt.go package auth import ( "errors" "time" "github.com/golang-jwt/jwt/v5" ) // TokenConfig holds JWT configuration type TokenConfig struct { SecretKey string AccessTokenExpiry time.Duration RefreshTokenExpiry time.Duration Issuer string } // Claims represents JWT claims // Why: Custom claims allow us to include user info in the token, // avoiding database lookups for every authenticated request. type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` Role string `json:"role"` jwt.RegisteredClaims } // TokenPair holds access and refresh tokens type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` // Seconds until access token expires } // JWTService handles JWT operations // Why: Encapsulating JWT logic makes it easy to: // 1. Test token generation/validation // 2. Switch to different token libraries // 3. Change token configuration type JWTService struct { config TokenConfig } func NewJWTService(config TokenConfig) *JWTService { return &JWTService{config: config} } // GenerateTokenPair creates access and refresh tokens // Why: Access tokens are short-lived for security. // Refresh tokens allow getting new access tokens without re-login. func (s *JWTService) GenerateTokenPair(userID, email, role string) (*TokenPair, error) { now := time.Now() // Create access token // Why: Short expiry (15 min) limits damage if token is stolen accessClaims := Claims{ UserID: userID, Email: email, Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(s.config.AccessTokenExpiry)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: s.config.Issuer, Subject: userID, }, } accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessTokenString, err := accessToken.SignedString([]byte(s.config.SecretKey)) if err != nil { return nil, err } // Create refresh token // Why: Longer expiry (7 days) for convenience // Contains minimal claims for security refreshClaims := jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(s.config.RefreshTokenExpiry)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: s.config.Issuer, Subject: userID, } refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) refreshTokenString, err := refreshToken.SignedString([]byte(s.config.SecretKey)) if err != nil { return nil, err } return &TokenPair{ AccessToken: accessTokenString, RefreshToken: refreshTokenString, ExpiresIn: int64(s.config.AccessTokenExpiry.Seconds()), }, nil } // ValidateAccessToken validates and parses an access token // Why: Every authenticated request needs token validation func (s *JWTService) ValidateAccessToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { // Why: Verify signing method to prevent algorithm confusion attacks if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("unexpected signing method") } return []byte(s.config.SecretKey), nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, errors.New("invalid token") } return claims, nil } // ValidateRefreshToken validates a refresh token and returns the user ID // Why: Refresh tokens have different claims structure func (s *JWTService) ValidateRefreshToken(tokenString string) (string, error) { token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("unexpected signing method") } return []byte(s.config.SecretKey), nil }) if err != nil { return "", err } claims, ok := token.Claims.(*jwt.RegisteredClaims) if !ok || !token.Valid { return "", errors.New("invalid token") } return claims.Subject, nil }

Authentication Middleware

go
// Filename: internal/middleware/auth.go package middleware import ( "context" "net/http" "strings" "myapi/internal/auth" "myapi/internal/pkg/response" ) // Context keys for user data type contextKey string const ( UserIDKey contextKey = "user_id" UserEmail contextKey = "user_email" UserRole contextKey = "user_role" ) // AuthMiddleware handles authentication type AuthMiddleware struct { jwtService *auth.JWTService } func NewAuthMiddleware(jwtService *auth.JWTService) *AuthMiddleware { return &AuthMiddleware{jwtService: jwtService} } // Authenticate validates JWT token and adds user to context // Why: Separating auth from handlers allows: // 1. Reuse across many endpoints // 2. Consistent auth behavior // 3. Easy testing of handlers without auth func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Step 1: Extract token from Authorization header // Why: Bearer token is the standard for JWT auth authHeader := r.Header.Get("Authorization") if authHeader == "" { response.Error(w, http.StatusUnauthorized, "Missing authorization header") return } // Why: Format is "Bearer <token>" parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { response.Error(w, http.StatusUnauthorized, "Invalid authorization format") return } tokenString := parts[1] // Step 2: Validate token // Why: Verifies signature and expiry claims, err := m.jwtService.ValidateAccessToken(tokenString) if err != nil { response.Error(w, http.StatusUnauthorized, "Invalid or expired token") return } // Step 3: Add user info to request context // Why: Allows handlers to access user info without parsing token again ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) ctx = context.WithValue(ctx, UserEmail, claims.Email) ctx = context.WithValue(ctx, UserRole, claims.Role) next.ServeHTTP(w, r.WithContext(ctx)) }) } // RequireRole checks if user has required role // Why: Some endpoints need specific roles (admin only, etc.) func (m *AuthMiddleware) RequireRole(roles ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userRole, ok := r.Context().Value(UserRole).(string) if !ok { response.Error(w, http.StatusForbidden, "Access denied") return } // Why: Check if user's role is in allowed roles for _, role := range roles { if userRole == role { next.ServeHTTP(w, r) return } } response.Error(w, http.StatusForbidden, "You don't have permission to access this resource") }) } } // Helper functions to extract user info from context // Why: Type-safe context value extraction func GetUserID(ctx context.Context) string { if id, ok := ctx.Value(UserIDKey).(string); ok { return id } return "" } func GetUserEmail(ctx context.Context) string { if email, ok := ctx.Value(UserEmail).(string); ok { return email } return "" } func GetUserRole(ctx context.Context) string { if role, ok := ctx.Value(UserRole).(string); ok { return role } return "" }

Password Hashing

go
// Filename: internal/auth/password.go package auth import ( "golang.org/x/crypto/bcrypt" ) // PasswordService handles password hashing and verification // Why: Never store plain text passwords. bcrypt is the gold standard // for password hashing because it: // 1. Is slow by design (prevents brute force) // 2. Includes salt automatically // 3. Has configurable cost factor type PasswordService struct { cost int // bcrypt cost factor } func NewPasswordService() *PasswordService { return &PasswordService{ cost: bcrypt.DefaultCost, // 10 - good balance of security and speed } } // HashPassword creates a bcrypt hash of the password // Why: The hash includes salt and cost, so we only store this hash func (s *PasswordService) HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), s.cost) if err != nil { return "", err } return string(bytes), nil } // VerifyPassword checks if password matches hash // Why: bcrypt.CompareHashAndPassword is timing-safe, // preventing timing attacks that could reveal password info func (s *PasswordService) VerifyPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil }

Authorization: What Can You Do?

Authentication tells you who someone is. Authorization tells you what they can do. These are different concerns.
REST API architecture diagram 9

REST API architecture diagram 9

Role Based Access Control (RBAC)

go
// Filename: internal/auth/rbac.go package auth import ( "context" ) // Permission represents a single permission type Permission string const ( PermUserCreate Permission = "user:create" PermUserRead Permission = "user:read" PermUserUpdate Permission = "user:update" PermUserDelete Permission = "user:delete" PermUserList Permission = "user:list" PermOrderCreate Permission = "order:create" PermOrderRead Permission = "order:read" PermOrderUpdate Permission = "order:update" PermOrderDelete Permission = "order:delete" PermOrderList Permission = "order:list" PermAdminAccess Permission = "admin:access" ) // Role represents a user role with permissions type Role string const ( RoleAdmin Role = "admin" RoleUser Role = "user" RoleGuest Role = "guest" ) // RolePermissions maps roles to their permissions // Why: Centralizing permissions makes it easy to: // 1. Audit who can do what // 2. Modify permissions without code changes // 3. Test authorization logic var RolePermissions = map[Role][]Permission{ RoleAdmin: { PermUserCreate, PermUserRead, PermUserUpdate, PermUserDelete, PermUserList, PermOrderCreate, PermOrderRead, PermOrderUpdate, PermOrderDelete, PermOrderList, PermAdminAccess, }, RoleUser: { PermUserRead, PermUserUpdate, // Own profile only PermOrderCreate, PermOrderRead, PermOrderList, // Own orders only }, RoleGuest: { PermUserRead, // Public profiles only }, } // Authorizer handles authorization checks type Authorizer struct{} func NewAuthorizer() *Authorizer { return &Authorizer{} } // HasPermission checks if a role has a specific permission // Why: Simple permission check for endpoint-level authorization func (a *Authorizer) HasPermission(role Role, permission Permission) bool { permissions, ok := RolePermissions[role] if !ok { return false } for _, p := range permissions { if p == permission { return true } } return false } // CanAccessResource checks if user can access a specific resource // Why: Resource-level authorization (can user X access resource Y?) func (a *Authorizer) CanAccessResource(ctx context.Context, resourceOwnerID string) bool { userID := GetUserID(ctx) userRole := Role(GetUserRole(ctx)) // Admins can access everything if userRole == RoleAdmin { return true } // Users can only access their own resources return userID == resourceOwnerID }

Authorization Middleware

go
// Filename: internal/middleware/authorization.go package middleware import ( "net/http" "myapi/internal/auth" "myapi/internal/pkg/response" ) // AuthorizationMiddleware handles authorization type AuthorizationMiddleware struct { authorizer *auth.Authorizer } func NewAuthorizationMiddleware(authorizer *auth.Authorizer) *AuthorizationMiddleware { return &AuthorizationMiddleware{authorizer: authorizer} } // RequirePermission checks if user has required permission // Why: Declarative permission checking at the route level func (m *AuthorizationMiddleware) RequirePermission(permission auth.Permission) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userRole := auth.Role(GetUserRole(r.Context())) if !m.authorizer.HasPermission(userRole, permission) { response.Error(w, http.StatusForbidden, "You don't have permission to perform this action") return } next.ServeHTTP(w, r) }) } } // RequireResourceOwnership ensures user owns the resource // Why: Users should only modify their own resources func (m *AuthorizationMiddleware) RequireResourceOwnership(getOwnerID func(*http.Request) string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ownerID := getOwnerID(r) if !m.authorizer.CanAccessResource(r.Context(), ownerID) { response.Error(w, http.StatusForbidden, "You don't have access to this resource") return } next.ServeHTTP(w, r) }) } }

Database Patterns for APIs

The database layer is where many APIs fail under load. Proper patterns here make the difference between an API that scales and one that crashes.
REST API architecture diagram 10

REST API architecture diagram 10

Repository Pattern Implementation

go
// Filename: internal/repository/postgres/user_repo.go package postgres import ( "context" "database/sql" "errors" "time" "myapi/internal/domain/user" apperrors "myapi/internal/pkg/errors" ) // UserRepository implements user.Repository for PostgreSQL // Why: Concrete implementation of the repository interface. // This separation allows: // 1. Switching databases without changing business logic // 2. Easy mocking for tests // 3. Different implementations for different environments type UserRepository struct { db *sql.DB } func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } // Create inserts a new user // Why: Uses parameterized queries to prevent SQL injection func (r *UserRepository) Create(ctx context.Context, u *user.User) (*user.User, error) { query := ` INSERT INTO users (id, email, name, role, password_hash, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, email, name, role, created_at, updated_at` // Why: QueryRowContext with timeout ensures we don't hang forever err := r.db.QueryRowContext(ctx, query, u.ID, u.Email, u.Name, u.Role, u.PasswordHash, u.CreatedAt, u.UpdatedAt, ).Scan(&u.ID, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt) if err != nil { // Why: Check for unique constraint violation (duplicate email) if isDuplicateKeyError(err) { return nil, apperrors.Conflict("Email already exists") } return nil, apperrors.DatabaseError("insert user", err) } return u, nil } // GetByID retrieves a user by ID // Why: Single record lookup with proper error handling func (r *UserRepository) GetByID(ctx context.Context, id string) (*user.User, error) { query := ` SELECT id, email, name, role, created_at, updated_at FROM users WHERE id = $1 AND deleted_at IS NULL` u := &user.User{} err := r.db.QueryRowContext(ctx, query, id).Scan( &u.ID, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, apperrors.NotFound("User") } return nil, apperrors.DatabaseError("get user", err) } return u, nil } // GetByEmail retrieves a user by email // Why: Needed for authentication (login by email) func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) { query := ` SELECT id, email, name, role, password_hash, created_at, updated_at FROM users WHERE email = $1 AND deleted_at IS NULL` u := &user.User{} err := r.db.QueryRowContext(ctx, query, email).Scan( &u.ID, &u.Email, &u.Name, &u.Role, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, apperrors.NotFound("User") } return nil, apperrors.DatabaseError("get user by email", err) } return u, nil } // Update modifies an existing user // Why: Only update non-null fields, use optimistic locking func (r *UserRepository) Update(ctx context.Context, u *user.User) (*user.User, error) { query := ` UPDATE users SET name = $2, role = $3, updated_at = $4 WHERE id = $1 AND deleted_at IS NULL RETURNING id, email, name, role, created_at, updated_at` err := r.db.QueryRowContext(ctx, query, u.ID, u.Name, u.Role, time.Now().UTC(), ).Scan(&u.ID, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, apperrors.NotFound("User") } return nil, apperrors.DatabaseError("update user", err) } return u, nil } // Delete soft-deletes a user // Why: Soft delete preserves data for audit and recovery func (r *UserRepository) Delete(ctx context.Context, id string) error { query := ` UPDATE users SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL` result, err := r.db.ExecContext(ctx, query, id, time.Now().UTC()) if err != nil { return apperrors.DatabaseError("delete user", err) } rows, err := result.RowsAffected() if err != nil { return apperrors.DatabaseError("delete user", err) } if rows == 0 { return apperrors.NotFound("User") } return nil } // List returns paginated users // Why: Never return all records. Always paginate. func (r *UserRepository) List(ctx context.Context, offset, limit int) ([]*user.User, int, error) { // Why: Get total count for pagination metadata var total int countQuery := `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL` if err := r.db.QueryRowContext(ctx, countQuery).Scan(&total); err != nil { return nil, 0, apperrors.DatabaseError("count users", err) } // Why: Order by created_at for consistent pagination query := ` SELECT id, email, name, role, created_at, updated_at FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2` rows, err := r.db.QueryContext(ctx, query, limit, offset) if err != nil { return nil, 0, apperrors.DatabaseError("list users", err) } defer rows.Close() users := make([]*user.User, 0) for rows.Next() { u := &user.User{} if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, 0, apperrors.DatabaseError("scan user", err) } users = append(users, u) } if err := rows.Err(); err != nil { return nil, 0, apperrors.DatabaseError("iterate users", err) } return users, total, nil } // isDuplicateKeyError checks for PostgreSQL unique constraint violation func isDuplicateKeyError(err error) bool { // PostgreSQL error code 23505 is unique_violation return err != nil && (err.Error() == "23505" || contains(err.Error(), "duplicate key")) } func contains(s, substr string) bool { return len(s) >= len(substr) && s[:len(substr)] == substr }

Database Connection Pooling

go
// Filename: internal/config/database.go package config import ( "context" "database/sql" "fmt" "time" _ "github.com/lib/pq" ) // DatabaseConfig holds database configuration type DatabaseConfig struct { Host string Port int User string Password string Database string SSLMode string MaxOpenConns int // Maximum open connections MaxIdleConns int // Maximum idle connections ConnMaxLifetime time.Duration // Maximum connection lifetime ConnMaxIdleTime time.Duration // Maximum idle time } // NewDatabaseConnection creates a configured database connection pool // Why: Connection pooling is essential for production APIs. // Without it, each request opens a new connection, which is: // 1. Slow (connection overhead) // 2. Resource intensive (file descriptors) // 3. Can exhaust database connection limits func NewDatabaseConnection(cfg DatabaseConfig) (*sql.DB, error) { dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Database, cfg.SSLMode, ) db, err := sql.Open("postgres", dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Why: Configure connection pool for production workloads // These settings prevent resource exhaustion // MaxOpenConns: Upper limit on concurrent connections // Too high: exhausts database resources // Too low: requests queue waiting for connections // Rule of thumb: 2-4x CPU cores for write-heavy, more for read-heavy db.SetMaxOpenConns(cfg.MaxOpenConns) // e.g., 25 // MaxIdleConns: Connections kept open when not in use // Should be less than MaxOpenConns // Higher = faster response (reuses connections) // Lower = less resource usage when idle db.SetMaxIdleConns(cfg.MaxIdleConns) // e.g., 10 // ConnMaxLifetime: How long a connection can be reused // Prevents using stale connections (load balancer changes, etc.) // Should be less than database's wait_timeout db.SetConnMaxLifetime(cfg.ConnMaxLifetime) // e.g., 5 minutes // ConnMaxIdleTime: How long idle connections are kept // Releases resources when traffic is low db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) // e.g., 1 minute // Why: Verify connection works ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } return db, nil }

Transaction Handling

go
// Filename: internal/repository/postgres/transaction.go package postgres import ( "context" "database/sql" "fmt" ) // TxManager handles database transactions // Why: Proper transaction management is critical for data integrity type TxManager struct { db *sql.DB } func NewTxManager(db *sql.DB) *TxManager { return &TxManager{db: db} } // WithTransaction executes a function within a transaction // Why: This pattern ensures: // 1. Automatic rollback on error or panic // 2. Proper commit on success // 3. Clean, reusable transaction code func (m *TxManager) WithTransaction(ctx context.Context, fn func(tx *sql.Tx) error) error { tx, err := m.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin transaction: %w", err) } // Why: Defer ensures rollback happens even if fn panics defer func() { if p := recover(); p != nil { tx.Rollback() panic(p) // Re-throw panic after rollback } }() // Execute the function if err := fn(tx); err != nil { // Why: Rollback on any error if rbErr := tx.Rollback(); rbErr != nil { return fmt.Errorf("rollback failed: %v (original error: %w)", rbErr, err) } return err } // Why: Commit only if everything succeeded if err := tx.Commit(); err != nil { return fmt.Errorf("commit transaction: %w", err) } return nil } // Usage example in service layer: // // func (s *OrderService) CreateOrderWithItems(ctx context.Context, order *Order, items []*Item) error { // return s.txManager.WithTransaction(ctx, func(tx *sql.Tx) error { // // Create order // if err := s.orderRepo.CreateWithTx(ctx, tx, order); err != nil { // return err // } // // // Create items // for _, item := range items { // if err := s.itemRepo.CreateWithTx(ctx, tx, item); err != nil { // return err // Will trigger rollback // } // } // // return nil // Success - will commit // }) // }

Caching Strategies

Caching reduces database load and improves response times. But improper caching leads to stale data and bugs.
REST API architecture diagram 11

REST API architecture diagram 11

Cache Aside Pattern (Most Common)

💡 Think of it like this: Imagine a librarian (your API) and a desk (cache). When someone asks for a book (data), check the desk first. If it's there, hand it over immediately. If not, go to the storage room (database), get the book, leave a copy on the desk, and hand it to the person. Next time someone asks, it's right on the desk.
go
// Filename: internal/repository/redis/cache.go package redis import ( "context" "encoding/json" "fmt" "time" "github.com/redis/go-redis/v9" ) // CacheConfig holds cache configuration type CacheConfig struct { DefaultTTL time.Duration Prefix string // e.g., "myapi:" } // Cache provides caching operations // Why: Abstracting cache operations allows: // 1. Switching cache backends (Redis, Memcached, in-memory) // 2. Consistent serialization // 3. Centralized TTL management type Cache struct { client *redis.Client config CacheConfig } func NewCache(client *redis.Client, config CacheConfig) *Cache { return &Cache{ client: client, config: config, } } // Get retrieves a value from cache // Why: Returns found flag to distinguish "not found" from "found nil" func (c *Cache) Get(ctx context.Context, key string, dest interface{}) (bool, error) { fullKey := c.config.Prefix + key val, err := c.client.Get(ctx, fullKey).Result() if err == redis.Nil { // Why: Cache miss is not an error return false, nil } if err != nil { return false, fmt.Errorf("cache get: %w", err) } // Why: Deserialize JSON into destination if err := json.Unmarshal([]byte(val), dest); err != nil { return false, fmt.Errorf("cache unmarshal: %w", err) } return true, nil } // Set stores a value in cache with TTL // Why: TTL ensures stale data eventually expires func (c *Cache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { fullKey := c.config.Prefix + key if ttl == 0 { ttl = c.config.DefaultTTL } // Why: Serialize to JSON for storage data, err := json.Marshal(value) if err != nil { return fmt.Errorf("cache marshal: %w", err) } if err := c.client.Set(ctx, fullKey, data, ttl).Err(); err != nil { return fmt.Errorf("cache set: %w", err) } return nil } // Delete removes a value from cache // Why: Called when data is modified to prevent stale reads func (c *Cache) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } fullKeys := make([]string, len(keys)) for i, key := range keys { fullKeys[i] = c.config.Prefix + key } if err := c.client.Del(ctx, fullKeys...).Err(); err != nil { return fmt.Errorf("cache delete: %w", err) } return nil } // DeletePattern removes all keys matching a pattern // Why: Useful for invalidating related cache entries // e.g., delete all user-related cache when user is deleted func (c *Cache) DeletePattern(ctx context.Context, pattern string) error { fullPattern := c.config.Prefix + pattern // Why: SCAN is safer than KEYS for production // KEYS blocks Redis, SCAN iterates incrementally iter := c.client.Scan(ctx, 0, fullPattern, 100).Iterator() var keysToDelete []string for iter.Next(ctx) { keysToDelete = append(keysToDelete, iter.Val()) } if err := iter.Err(); err != nil { return fmt.Errorf("cache scan: %w", err) } if len(keysToDelete) > 0 { if err := c.client.Del(ctx, keysToDelete...).Err(); err != nil { return fmt.Errorf("cache delete pattern: %w", err) } } return nil } // GetOrSet tries cache first, falls back to loader function // Why: Common pattern - check cache, if miss, load and cache func (c *Cache) GetOrSet(ctx context.Context, key string, dest interface{}, ttl time.Duration, loader func() (interface{}, error)) error { // Try cache first found, err := c.Get(ctx, key, dest) if err != nil { // Why: Log cache errors but don't fail - just go to loader // Cache is optimization, not requirement } if found { return nil } // Cache miss - load from source value, err := loader() if err != nil { return err } // Why: Store in cache for next time // Ignore cache set errors - data is still valid _ = c.Set(ctx, key, value, ttl) // Why: Copy loaded value to destination data, _ := json.Marshal(value) return json.Unmarshal(data, dest) }

Cache Keys Best Practices

go
// Filename: internal/repository/redis/keys.go package redis import ( "fmt" ) // CacheKeys generates consistent cache keys // Why: Consistent key format prevents: // 1. Key collisions between different data types // 2. Difficulty debugging cache issues // 3. Problems with cache invalidation type CacheKeys struct{} func NewCacheKeys() *CacheKeys { return &CacheKeys{} } // User returns cache key for a user // Format: user:{id} func (k *CacheKeys) User(id string) string { return fmt.Sprintf("user:%s", id) } // UserByEmail returns cache key for user by email lookup // Format: user:email:{email} func (k *CacheKeys) UserByEmail(email string) string { return fmt.Sprintf("user:email:%s", email) } // UserList returns cache key for user list // Format: users:page:{page}:size:{size} func (k *CacheKeys) UserList(page, size int) string { return fmt.Sprintf("users:page:%d:size:%d", page, size) } // UserPattern returns pattern for all user cache keys // Why: Used for bulk invalidation when user data changes func (k *CacheKeys) UserPattern(id string) string { return fmt.Sprintf("user:%s*", id) } // Order returns cache key for an order func (k *CacheKeys) Order(id string) string { return fmt.Sprintf("order:%s", id) } // UserOrders returns cache key for user's orders func (k *CacheKeys) UserOrders(userID string, page, size int) string { return fmt.Sprintf("user:%s:orders:page:%d:size:%d", userID, page, size) } // RateLimit returns cache key for rate limiting func (k *CacheKeys) RateLimit(identifier string) string { return fmt.Sprintf("ratelimit:%s", identifier) } // Session returns cache key for user session func (k *CacheKeys) Session(sessionID string) string { return fmt.Sprintf("session:%s", sessionID) }

HTTP Caching Headers

go
// Filename: internal/middleware/cache.go package middleware import ( "fmt" "net/http" "time" ) // CacheControl middleware adds cache headers // Why: HTTP caching reduces server load and improves client performance type CacheControl struct{} func NewCacheControl() *CacheControl { return &CacheControl{} } // Public caches response for specified duration // Why: Use for public data that can be cached by CDNs and browsers func (c *CacheControl) Public(maxAge time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Why: Only cache GET requests if r.Method == http.MethodGet { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds()))) } next.ServeHTTP(w, r) }) } } // Private caches response only in browser // Why: Use for user-specific data that shouldn't be cached by CDNs func (c *CacheControl) Private(maxAge time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { w.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d", int(maxAge.Seconds()))) } next.ServeHTTP(w, r) }) } } // NoCache prevents caching // Why: Use for sensitive or frequently changing data func (c *CacheControl) NoCache() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") next.ServeHTTP(w, r) }) } } // ETag adds ETag support for conditional requests // Why: ETag allows "304 Not Modified" responses, saving bandwidth func (c *CacheControl) WithETag(generator func(*http.Request) string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { etag := generator(r) // Check if client has current version if match := r.Header.Get("If-None-Match"); match == etag { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("ETag", etag) next.ServeHTTP(w, r) }) } }

Rate Limiting: Protecting Your Service

Rate limiting prevents abuse, ensures fair usage, and protects your service from being overwhelmed.
REST API architecture diagram 12

REST API architecture diagram 12

Token Bucket Rate Limiter

💡 Think of it like this: Imagine a bucket with tokens. Each request takes a token. Tokens are added at a fixed rate. If the bucket is empty, requests are rejected. If the bucket has tokens, requests proceed. The bucket has a maximum capacity, allowing bursts up to that limit.
go
// Filename: internal/middleware/ratelimit.go package middleware import ( "context" "net/http" "sync" "time" "myapi/internal/pkg/response" ) // RateLimitConfig holds rate limiter configuration type RateLimitConfig struct { RequestsPerSecond float64 // Rate at which tokens are added BurstSize int // Maximum tokens (allows bursts) CleanupInterval time.Duration // How often to clean up old entries } // RateLimiter implements token bucket rate limiting // Why: Token bucket allows bursts while maintaining average rate type RateLimiter struct { config RateLimitConfig buckets map[string]*bucket mu sync.RWMutex stopCh chan struct{} } type bucket struct { tokens float64 lastUpdate time.Time } func NewRateLimiter(config RateLimitConfig) *RateLimiter { rl := &RateLimiter{ config: config, buckets: make(map[string]*bucket), stopCh: make(chan struct{}), } // Why: Periodic cleanup prevents memory leak from old entries go rl.cleanup() return rl } // Allow checks if request is allowed // Why: Returns true if under rate limit, false if exceeded func (rl *RateLimiter) Allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() b, exists := rl.buckets[key] if !exists { // New client - start with full bucket rl.buckets[key] = &bucket{ tokens: float64(rl.config.BurstSize) - 1, // -1 for this request lastUpdate: now, } return true } // Why: Calculate tokens to add based on time elapsed elapsed := now.Sub(b.lastUpdate).Seconds() b.tokens += elapsed * rl.config.RequestsPerSecond // Why: Cap at burst size if b.tokens > float64(rl.config.BurstSize) { b.tokens = float64(rl.config.BurstSize) } b.lastUpdate = now // Why: Check if we have a token available if b.tokens < 1 { return false } b.tokens-- return true } // cleanup removes old bucket entries func (rl *RateLimiter) cleanup() { ticker := time.NewTicker(rl.config.CleanupInterval) defer ticker.Stop() for { select { case <-ticker.C: rl.mu.Lock() now := time.Now() for key, b := range rl.buckets { // Why: Remove entries not updated in 2x cleanup interval if now.Sub(b.lastUpdate) > rl.config.CleanupInterval*2 { delete(rl.buckets, key) } } rl.mu.Unlock() case <-rl.stopCh: return } } } // Stop stops the cleanup goroutine func (rl *RateLimiter) Stop() { close(rl.stopCh) } // RateLimitMiddleware is HTTP middleware for rate limiting type RateLimitMiddleware struct { limiter *RateLimiter keyFunc func(*http.Request) string config RateLimitConfig } func NewRateLimitMiddleware(config RateLimitConfig, keyFunc func(*http.Request) string) *RateLimitMiddleware { return &RateLimitMiddleware{ limiter: NewRateLimiter(config), keyFunc: keyFunc, config: config, } } // Limit applies rate limiting to requests // Why: Protects the service from abuse and ensures fair usage func (m *RateLimitMiddleware) Limit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := m.keyFunc(r) if !m.limiter.Allow(key) { // Why: Set standard rate limit headers w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", m.config.BurstSize)) w.Header().Set("X-RateLimit-Remaining", "0") w.Header().Set("Retry-After", "1") response.Error(w, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.") return } next.ServeHTTP(w, r) }) } // Common key functions // KeyByIP extracts client IP for rate limiting // Why: Rate limit by IP for unauthenticated endpoints func KeyByIP(r *http.Request) string { // Why: Check X-Forwarded-For for proxied requests if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // First IP in the list is the original client return strings.Split(xff, ",")[0] } if xri := r.Header.Get("X-Real-IP"); xri != "" { return xri } // Why: RemoteAddr includes port, we only want IP host, _, _ := net.SplitHostPort(r.RemoteAddr) return host } // KeyByUserID extracts user ID for rate limiting // Why: Rate limit by user for authenticated endpoints func KeyByUserID(r *http.Request) string { if userID := GetUserID(r.Context()); userID != "" { return "user:" + userID } // Fallback to IP if not authenticated return "ip:" + KeyByIP(r) } // KeyByAPIKey extracts API key for rate limiting // Why: Different API keys may have different rate limits func KeyByAPIKey(r *http.Request) string { if apiKey := r.Header.Get("X-API-Key"); apiKey != "" { return "apikey:" + apiKey } return "ip:" + KeyByIP(r) }

Redis Based Distributed Rate Limiting

go
// Filename: internal/middleware/ratelimit_redis.go package middleware import ( "context" "fmt" "net/http" "strconv" "time" "github.com/redis/go-redis/v9" "myapi/internal/pkg/response" ) // RedisRateLimiter uses Redis for distributed rate limiting // Why: In a multi-instance deployment, in-memory rate limiting // doesn't work because each instance has its own counter. // Redis provides a shared counter across all instances. type RedisRateLimiter struct { client *redis.Client config RateLimitConfig keyFunc func(*http.Request) string } func NewRedisRateLimiter(client *redis.Client, config RateLimitConfig, keyFunc func(*http.Request) string) *RedisRateLimiter { return &RedisRateLimiter{ client: client, config: config, keyFunc: keyFunc, } } // Limit applies distributed rate limiting // Why: Uses Redis INCR with expiry for atomic rate limiting func (m *RedisRateLimiter) Limit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() key := "ratelimit:" + m.keyFunc(r) // Why: Use sliding window approach // Key format: ratelimit:{identifier}:{window} window := time.Now().Unix() / int64(m.config.Window.Seconds()) windowKey := fmt.Sprintf("%s:%d", key, window) // Why: INCR is atomic, prevents race conditions count, err := m.client.Incr(ctx, windowKey).Result() if err != nil { // Why: On Redis error, allow request (fail open) // Alternative: fail closed (deny request) next.ServeHTTP(w, r) return } // Why: Set expiry only on first request in window if count == 1 { m.client.Expire(ctx, windowKey, m.config.Window*2) } // Why: Check if over limit remaining := m.config.BurstSize - int(count) if remaining < 0 { remaining = 0 } // Why: Always set rate limit headers w.Header().Set("X-RateLimit-Limit", strconv.Itoa(m.config.BurstSize)) w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) w.Header().Set("X-RateLimit-Reset", strconv.FormatInt((window+1)*int64(m.config.Window.Seconds()), 10)) if count > int64(m.config.BurstSize) { w.Header().Set("Retry-After", strconv.FormatInt(int64(m.config.Window.Seconds()), 10)) response.Error(w, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.") return } next.ServeHTTP(w, r) }) }

Logging: Your Production Lifeline

When something goes wrong in production, logs are your only window into what happened. Good logging is the difference between a 5 minute fix and a 5 hour investigation.
REST API architecture diagram 13

REST API architecture diagram 13

Production Logging Setup

go
// Filename: internal/pkg/logger/logger.go package logger import ( "context" "io" "log/slog" "os" "runtime" "time" ) // LogConfig holds logger configuration type LogConfig struct { Level string // debug, info, warn, error Format string // json, text Output string // stdout, stderr, file path AddSource bool // Include source file and line TimeFormat string // Time format for text output } // NewLogger creates a configured slog.Logger // Why: slog (Go 1.21+) is the standard structured logging library. // It's fast, structured, and integrates well with observability tools. func NewLogger(config LogConfig) (*slog.Logger, error) { // Parse log level var level slog.Level switch config.Level { case "debug": level = slog.LevelDebug case "info": level = slog.LevelInfo case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } // Configure output var output io.Writer switch config.Output { case "stdout", "": output = os.Stdout case "stderr": output = os.Stderr default: file, err := os.OpenFile(config.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { return nil, err } output = file } // Configure handler options opts := &slog.HandlerOptions{ Level: level, AddSource: config.AddSource, // Why: Replace time format for readability in text mode ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.TimeKey && config.Format == "text" { return slog.String(slog.TimeKey, a.Value.Time().Format(time.RFC3339)) } return a }, } // Create handler based on format var handler slog.Handler switch config.Format { case "json": // Why: JSON is machine-parseable, ideal for log aggregation handler = slog.NewJSONHandler(output, opts) default: // Why: Text is human-readable, ideal for development handler = slog.NewTextHandler(output, opts) } return slog.New(handler), nil } // Context keys for logging type ctxKey string const ( RequestIDKey ctxKey = "request_id" UserIDKey ctxKey = "user_id" TraceIDKey ctxKey = "trace_id" SpanIDKey ctxKey = "span_id" ) // WithRequestID adds request ID to context func WithRequestID(ctx context.Context, requestID string) context.Context { return context.WithValue(ctx, RequestIDKey, requestID) } // WithUserID adds user ID to context func WithUserID(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, UserIDKey, userID) } // FromContext creates a logger with context values // Why: Automatically includes request_id, user_id, etc. in all logs func FromContext(ctx context.Context, base *slog.Logger) *slog.Logger { logger := base if requestID, ok := ctx.Value(RequestIDKey).(string); ok && requestID != "" { logger = logger.With("request_id", requestID) } if userID, ok := ctx.Value(UserIDKey).(string); ok && userID != "" { logger = logger.With("user_id", userID) } if traceID, ok := ctx.Value(TraceIDKey).(string); ok && traceID != "" { logger = logger.With("trace_id", traceID) } return logger } // LogError logs an error with stack trace // Why: Stack traces help identify where errors originate func LogError(logger *slog.Logger, msg string, err error, args ...any) { // Get caller info _, file, line, _ := runtime.Caller(1) allArgs := append([]any{ "error", err.Error(), "file", file, "line", line, }, args...) logger.Error(msg, allArgs...) }

What to Log

go
// Filename: internal/middleware/request_logger.go package middleware import ( "log/slog" "net/http" "time" "myapi/internal/pkg/logger" ) // RequestLogger logs HTTP requests with detailed information // Why: Request logs are essential for: // 1. Debugging issues // 2. Performance monitoring // 3. Security auditing // 4. Usage analytics type RequestLogger struct { logger *slog.Logger } func NewRequestLogger(l *slog.Logger) *RequestLogger { return &RequestLogger{logger: l} } func (m *RequestLogger) Log(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() // Generate request ID requestID := r.Header.Get("X-Request-ID") if requestID == "" { requestID = generateUUID() } // Add request ID to context ctx := logger.WithRequestID(r.Context(), requestID) r = r.WithContext(ctx) // Set request ID header for client correlation w.Header().Set("X-Request-ID", requestID) // Wrap response writer to capture status and size wrapped := wrapResponseWriter(w) // Why: Log request start for long-running request debugging m.logger.Debug("request started", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "query", r.URL.RawQuery, "remote_addr", r.RemoteAddr, "user_agent", r.UserAgent(), ) // Process request next.ServeHTTP(wrapped, r) // Calculate duration duration := time.Since(start) // Why: Log based on status code // Errors get ERROR level for alerting logLevel := slog.LevelInfo if wrapped.status >= 500 { logLevel = slog.LevelError } else if wrapped.status >= 400 { logLevel = slog.LevelWarn } // Why: Include all relevant fields for debugging and monitoring m.logger.Log(r.Context(), logLevel, "request completed", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", wrapped.status, "duration_ms", duration.Milliseconds(), "size_bytes", wrapped.size, "remote_addr", r.RemoteAddr, ) }) }

Log Sampling for High Traffic

go
// Filename: internal/pkg/logger/sampler.go package logger import ( "log/slog" "math/rand" "sync/atomic" "time" ) // SampledLogger logs only a sample of messages // Why: High-traffic systems can generate millions of logs per minute. // Sampling reduces log volume while maintaining visibility. type SampledLogger struct { base *slog.Logger sampleRate float64 // 0.0 to 1.0 counter uint64 everyN uint64 // Log every N messages } func NewSampledLogger(base *slog.Logger, sampleRate float64) *SampledLogger { return &SampledLogger{ base: base, sampleRate: sampleRate, everyN: uint64(1.0 / sampleRate), } } // ShouldLog determines if this message should be logged // Why: Uses deterministic sampling (every N) for consistent behavior func (s *SampledLogger) ShouldLog() bool { count := atomic.AddUint64(&s.counter, 1) return count%s.everyN == 0 } // Info logs info message with sampling func (s *SampledLogger) Info(msg string, args ...any) { if s.ShouldLog() { s.base.Info(msg, append(args, "sampled", true)...) } } // Debug logs debug message with sampling func (s *SampledLogger) Debug(msg string, args ...any) { if s.ShouldLog() { s.base.Debug(msg, append(args, "sampled", true)...) } } // Error always logs (no sampling for errors) // Why: Errors are critical and should never be sampled func (s *SampledLogger) Error(msg string, args ...any) { s.base.Error(msg, args...) } // Warn always logs (no sampling for warnings) func (s *SampledLogger) Warn(msg string, args ...any) { s.base.Warn(msg, args...) }

Monitoring and Observability

Observability is the ability to understand what's happening inside your system by looking at its outputs. It's built on three pillars: metrics, logs, and traces.
REST API architecture diagram 14

REST API architecture diagram 14

Prometheus Metrics

go
// Filename: internal/metrics/metrics.go package metrics import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) // Metrics holds all application metrics // Why: Centralizing metrics makes them easy to find and manage type Metrics struct { // Request metrics RequestsTotal *prometheus.CounterVec RequestDuration *prometheus.HistogramVec RequestsInFlight prometheus.Gauge // Business metrics UsersCreated prometheus.Counter OrdersCreated prometheus.Counter OrdersTotal *prometheus.CounterVec // Database metrics DBQueryDuration *prometheus.HistogramVec DBConnectionsOpen prometheus.Gauge DBConnectionsIdle prometheus.Gauge // Cache metrics CacheHits *prometheus.CounterVec CacheMisses *prometheus.CounterVec } // NewMetrics creates and registers all metrics // Why: promauto automatically registers metrics with the default registry func NewMetrics() *Metrics { return &Metrics{ // Why: Count total requests by method, path, and status RequestsTotal: promauto.NewCounterVec( prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total number of HTTP requests", }, []string{"method", "path", "status"}, ), // Why: Histogram for request duration with percentiles // Buckets are in seconds: 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s RequestDuration: promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration in seconds", Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}, }, []string{"method", "path"}, ), // Why: Gauge for current in-flight requests (concurrency) RequestsInFlight: promauto.NewGauge( prometheus.GaugeOpts{ Name: "http_requests_in_flight", Help: "Number of HTTP requests currently being processed", }, ), // Why: Business metrics help understand application usage UsersCreated: promauto.NewCounter( prometheus.CounterOpts{ Name: "users_created_total", Help: "Total number of users created", }, ), OrdersCreated: promauto.NewCounter( prometheus.CounterOpts{ Name: "orders_created_total", Help: "Total number of orders created", }, ), OrdersTotal: promauto.NewCounterVec( prometheus.CounterOpts{ Name: "orders_total", Help: "Total orders by status", }, []string{"status"}, ), // Why: Database metrics identify bottlenecks DBQueryDuration: promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "db_query_duration_seconds", Help: "Database query duration in seconds", Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1}, }, []string{"query_type"}, ), DBConnectionsOpen: promauto.NewGauge( prometheus.GaugeOpts{ Name: "db_connections_open", Help: "Number of open database connections", }, ), DBConnectionsIdle: promauto.NewGauge( prometheus.GaugeOpts{ Name: "db_connections_idle", Help: "Number of idle database connections", }, ), // Why: Cache metrics show cache effectiveness CacheHits: promauto.NewCounterVec( prometheus.CounterOpts{ Name: "cache_hits_total", Help: "Total cache hits", }, []string{"cache"}, ), CacheMisses: promauto.NewCounterVec( prometheus.CounterOpts{ Name: "cache_misses_total", Help: "Total cache misses", }, []string{"cache"}, ), } }

Metrics Middleware

go
// Filename: internal/middleware/metrics.go package middleware import ( "net/http" "strconv" "time" "myapi/internal/metrics" ) // MetricsMiddleware collects HTTP metrics type MetricsMiddleware struct { metrics *metrics.Metrics } func NewMetricsMiddleware(m *metrics.Metrics) *MetricsMiddleware { return &MetricsMiddleware{metrics: m} } func (m *MetricsMiddleware) Collect(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() // Track in-flight requests m.metrics.RequestsInFlight.Inc() defer m.metrics.RequestsInFlight.Dec() // Wrap response writer to capture status wrapped := wrapResponseWriter(w) // Process request next.ServeHTTP(wrapped, r) // Record metrics duration := time.Since(start).Seconds() status := strconv.Itoa(wrapped.status) // Why: Use path template, not actual path, to avoid high cardinality // /users/123 becomes /users/{id} path := r.URL.Path // In production, normalize this m.metrics.RequestsTotal.WithLabelValues(r.Method, path, status).Inc() m.metrics.RequestDuration.WithLabelValues(r.Method, path).Observe(duration) }) }

Health Check Endpoints

go
// Filename: internal/handler/health_handler.go package handler import ( "context" "database/sql" "encoding/json" "net/http" "time" "github.com/redis/go-redis/v9" ) // HealthHandler provides health check endpoints type HealthHandler struct { db *sql.DB redis *redis.Client } func NewHealthHandler(db *sql.DB, redis *redis.Client) *HealthHandler { return &HealthHandler{db: db, redis: redis} } // HealthResponse represents health check response type HealthResponse struct { Status string `json:"status"` Timestamp string `json:"timestamp"` Checks map[string]Check `json:"checks,omitempty"` } type Check struct { Status string `json:"status"` Message string `json:"message,omitempty"` Duration string `json:"duration,omitempty"` } // Health is the basic health check (liveness probe) // Why: Kubernetes uses this to know if the container is alive // Should be fast and always succeed if the process is running func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(HealthResponse{ Status: "healthy", Timestamp: time.Now().UTC().Format(time.RFC3339), }) } // Ready is the detailed health check (readiness probe) // Why: Kubernetes uses this to know if the container can accept traffic // Checks all dependencies (database, cache, etc.) func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() checks := make(map[string]Check) allHealthy := true // Check database dbCheck := h.checkDatabase(ctx) checks["database"] = dbCheck if dbCheck.Status != "healthy" { allHealthy = false } // Check Redis redisCheck := h.checkRedis(ctx) checks["redis"] = redisCheck if redisCheck.Status != "healthy" { allHealthy = false } // Build response resp := HealthResponse{ Timestamp: time.Now().UTC().Format(time.RFC3339), Checks: checks, } w.Header().Set("Content-Type", "application/json") if allHealthy { resp.Status = "healthy" w.WriteHeader(http.StatusOK) } else { resp.Status = "unhealthy" w.WriteHeader(http.StatusServiceUnavailable) } json.NewEncoder(w).Encode(resp) } func (h *HealthHandler) checkDatabase(ctx context.Context) Check { start := time.Now() if err := h.db.PingContext(ctx); err != nil { return Check{ Status: "unhealthy", Message: err.Error(), } } return Check{ Status: "healthy", Duration: time.Since(start).String(), } } func (h *HealthHandler) checkRedis(ctx context.Context) Check { start := time.Now() if err := h.redis.Ping(ctx).Err(); err != nil { return Check{ Status: "unhealthy", Message: err.Error(), } } return Check{ Status: "healthy", Duration: time.Since(start).String(), } }

HATEOAS: Self Documenting APIs

HATEOAS (Hypermedia As The Engine Of Application State) makes your API self-documenting by including links to related resources and available actions.
REST API architecture diagram 15

REST API architecture diagram 15

go
// Filename: internal/pkg/response/hateoas.go package response import ( "fmt" "net/http" ) // Link represents a HATEOAS link type Link struct { Href string `json:"href"` Rel string `json:"rel"` Method string `json:"method"` } // Resource wraps a resource with HATEOAS links // Why: Adding links makes the API self-documenting // Clients can discover available actions without hardcoding URLs type Resource struct { Data interface{} `json:"data"` Links []Link `json:"_links"` } // LinkBuilder helps construct HATEOAS links type LinkBuilder struct { baseURL string } func NewLinkBuilder(baseURL string) *LinkBuilder { return &LinkBuilder{baseURL: baseURL} } // UserLinks generates links for a user resource // Why: Centralize link generation for consistency func (b *LinkBuilder) UserLinks(userID string) []Link { return []Link{ { Href: fmt.Sprintf("%s/api/v1/users/%s", b.baseURL, userID), Rel: "self", Method: http.MethodGet, }, { Href: fmt.Sprintf("%s/api/v1/users/%s", b.baseURL, userID), Rel: "update", Method: http.MethodPatch, }, { Href: fmt.Sprintf("%s/api/v1/users/%s", b.baseURL, userID), Rel: "delete", Method: http.MethodDelete, }, { Href: fmt.Sprintf("%s/api/v1/users/%s/orders", b.baseURL, userID), Rel: "orders", Method: http.MethodGet, }, } } // CollectionLinks generates links for a collection func (b *LinkBuilder) CollectionLinks(resource string, page, pageSize, totalPages int) []Link { links := []Link{ { Href: fmt.Sprintf("%s/api/v1/%s?page=%d&size=%d", b.baseURL, resource, page, pageSize), Rel: "self", Method: http.MethodGet, }, } if page > 1 { links = append(links, Link{ Href: fmt.Sprintf("%s/api/v1/%s?page=%d&size=%d", b.baseURL, resource, page-1, pageSize), Rel: "prev", Method: http.MethodGet, }) links = append(links, Link{ Href: fmt.Sprintf("%s/api/v1/%s?page=1&size=%d", b.baseURL, resource, pageSize), Rel: "first", Method: http.MethodGet, }) } if page < totalPages { links = append(links, Link{ Href: fmt.Sprintf("%s/api/v1/%s?page=%d&size=%d", b.baseURL, resource, page+1, pageSize), Rel: "next", Method: http.MethodGet, }) links = append(links, Link{ Href: fmt.Sprintf("%s/api/v1/%s?page=%d&size=%d", b.baseURL, resource, totalPages, pageSize), Rel: "last", Method: http.MethodGet, }) } return links } // OrderLinks generates links for an order resource func (b *LinkBuilder) OrderLinks(orderID, userID string, status string) []Link { links := []Link{ { Href: fmt.Sprintf("%s/api/v1/orders/%s", b.baseURL, orderID), Rel: "self", Method: http.MethodGet, }, { Href: fmt.Sprintf("%s/api/v1/users/%s", b.baseURL, userID), Rel: "customer", Method: http.MethodGet, }, { Href: fmt.Sprintf("%s/api/v1/orders/%s/items", b.baseURL, orderID), Rel: "items", Method: http.MethodGet, }, } // Why: Only include cancel link if order can be cancelled if status == "pending" || status == "processing" { links = append(links, Link{ Href: fmt.Sprintf("%s/api/v1/orders/%s/cancel", b.baseURL, orderID), Rel: "cancel", Method: http.MethodPost, }) } return links }
Example HATEOAS Response:
json
{ "success": true, "data": { "id": "123", "email": "john@example.com", "name": "John Doe", "role": "user" }, "_links": [ {"href": "/api/v1/users/123", "rel": "self", "method": "GET"}, {"href": "/api/v1/users/123", "rel": "update", "method": "PATCH"}, {"href": "/api/v1/users/123", "rel": "delete", "method": "DELETE"}, {"href": "/api/v1/users/123/orders", "rel": "orders", "method": "GET"} ] }

API Versioning

APIs evolve. Versioning allows you to make breaking changes without breaking existing clients.
REST API architecture diagram 16

REST API architecture diagram 16

Version Handling

go
// Filename: internal/router/versioned_router.go package router import ( "net/http" "strings" ) // VersionedRouter routes requests to version-specific handlers // Why: Allows maintaining multiple API versions simultaneously type VersionedRouter struct { handlers map[string]http.Handler default http.Handler } func NewVersionedRouter() *VersionedRouter { return &VersionedRouter{ handlers: make(map[string]http.Handler), } } // Register registers a handler for a specific version func (r *VersionedRouter) Register(version string, handler http.Handler) { r.handlers[version] = handler } // SetDefault sets the default handler for unknown versions func (r *VersionedRouter) SetDefault(handler http.Handler) { r.default = handler } // ServeHTTP routes to the appropriate version func (r *VersionedRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Extract version from URL path // /api/v1/users -> v1 parts := strings.Split(req.URL.Path, "/") version := "" for _, part := range parts { if strings.HasPrefix(part, "v") && len(part) >= 2 { version = part break } } // Find handler for version if handler, ok := r.handlers[version]; ok { handler.ServeHTTP(w, req) return } // Use default handler if r.default != nil { r.default.ServeHTTP(w, req) return } http.NotFound(w, req) }

Deprecation Headers

go
// Filename: internal/middleware/deprecation.go package middleware import ( "net/http" "time" ) // DeprecationMiddleware adds deprecation headers // Why: Warn clients about deprecated API versions type DeprecationMiddleware struct { deprecatedVersions map[string]time.Time // version -> sunset date } func NewDeprecationMiddleware() *DeprecationMiddleware { return &DeprecationMiddleware{ deprecatedVersions: make(map[string]time.Time), } } // Deprecate marks a version as deprecated func (m *DeprecationMiddleware) Deprecate(version string, sunsetDate time.Time) { m.deprecatedVersions[version] = sunsetDate } func (m *DeprecationMiddleware) Handle(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Extract version from path version := extractVersion(r.URL.Path) if sunsetDate, deprecated := m.deprecatedVersions[version]; deprecated { // Why: Standard deprecation headers w.Header().Set("Deprecation", "true") w.Header().Set("Sunset", sunsetDate.Format(time.RFC1123)) w.Header().Set("Link", "</api/v2>; rel=\"successor-version\"") } next.ServeHTTP(w, r) }) } func extractVersion(path string) string { // Simple extraction - in production use proper parsing if strings.Contains(path, "/v1/") { return "v1" } if strings.Contains(path, "/v2/") { return "v2" } return "" }

Testing Production APIs

Testing is not optional for production APIs. A comprehensive test suite gives you confidence to deploy.
REST API architecture diagram 17

REST API architecture diagram 17

Handler Testing

go
// Filename: internal/handler/user_handler_test.go package handler_test import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "myapi/internal/domain/user" "myapi/internal/handler" ) // MockUserService is a mock implementation of user.Service // Why: Mocks isolate handler tests from service logic type MockUserService struct { CreateUserFunc func(ctx context.Context, email, name, password, role string) (*user.User, error) GetUserFunc func(ctx context.Context, id string) (*user.User, error) } func (m *MockUserService) CreateUser(ctx context.Context, email, name, password, role string) (*user.User, error) { return m.CreateUserFunc(ctx, email, name, password, role) } func (m *MockUserService) GetUser(ctx context.Context, id string) (*user.User, error) { return m.GetUserFunc(ctx, id) } func TestCreateUser_Success(t *testing.T) { // Arrange mockService := &MockUserService{ CreateUserFunc: func(ctx context.Context, email, name, password, role string) (*user.User, error) { return &user.User{ ID: "123", Email: email, Name: name, Role: user.Role(role), }, nil }, } h := handler.NewUserHandler(mockService) body := `{"email":"test@example.com","name":"Test User","password":"Password123!","role":"user"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Act h.CreateUser(rec, req) // Assert if rec.Code != http.StatusCreated { t.Errorf("expected status %d, got %d", http.StatusCreated, rec.Code) } var resp map[string]interface{} json.NewDecoder(rec.Body).Decode(&resp) if resp["success"] != true { t.Error("expected success to be true") } data := resp["data"].(map[string]interface{}) if data["email"] != "test@example.com" { t.Errorf("expected email test@example.com, got %s", data["email"]) } } func TestCreateUser_ValidationError(t *testing.T) { // Arrange h := handler.NewUserHandler(&MockUserService{}) // Missing required fields body := `{"email":"invalid-email"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Act h.CreateUser(rec, req) // Assert if rec.Code != http.StatusUnprocessableEntity { t.Errorf("expected status %d, got %d", http.StatusUnprocessableEntity, rec.Code) } } func TestGetUser_NotFound(t *testing.T) { // Arrange mockService := &MockUserService{ GetUserFunc: func(ctx context.Context, id string) (*user.User, error) { return nil, apperrors.NotFound("User") }, } h := handler.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodGet, "/api/v1/users/nonexistent", nil) req.SetPathValue("id", "nonexistent") // Go 1.22+ rec := httptest.NewRecorder() // Act h.GetUser(rec, req) // Assert if rec.Code != http.StatusNotFound { t.Errorf("expected status %d, got %d", http.StatusNotFound, rec.Code) } }

Integration Testing

go
// Filename: internal/handler/integration_test.go package handler_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "myapi/internal/config" "myapi/internal/router" ) // TestIntegration_UserCRUD tests the complete user CRUD flow // Why: Integration tests verify all components work together func TestIntegration_UserCRUD(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } // Setup cfg := config.LoadTestConfig() app, cleanup := setupTestApp(cfg) defer cleanup() server := httptest.NewServer(app.Router) defer server.Close() var userID string // Test Create t.Run("Create User", func(t *testing.T) { body := `{"email":"integration@test.com","name":"Integration Test","password":"Password123!","role":"user"}` resp, err := http.Post(server.URL+"/api/v1/users", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("failed to create user: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Errorf("expected status %d, got %d", http.StatusCreated, resp.StatusCode) } var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) data := result["data"].(map[string]interface{}) userID = data["id"].(string) }) // Test Read t.Run("Get User", func(t *testing.T) { resp, err := http.Get(server.URL + "/api/v1/users/" + userID) if err != nil { t.Fatalf("failed to get user: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) } }) // Test Update t.Run("Update User", func(t *testing.T) { body := `{"name":"Updated Name"}` req, _ := http.NewRequest(http.MethodPatch, server.URL+"/api/v1/users/"+userID, bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("failed to update user: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) } }) // Test Delete t.Run("Delete User", func(t *testing.T) { req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/users/"+userID, nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("failed to delete user: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Errorf("expected status %d, got %d", http.StatusNoContent, resp.StatusCode) } }) }

Deployment Considerations

The final piece is getting your API to production safely.

Graceful Shutdown

go
// Filename: cmd/api/main.go package main import ( "context" "log/slog" "net/http" "os" "os/signal" "syscall" "time" ) func main() { // ... setup code ... // Create server server := &http.Server{ Addr: cfg.Server.Address, Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, } // Why: Run server in goroutine so we can handle shutdown go func() { logger.Info("server starting", "address", cfg.Server.Address) if err := server.ListenAndServe(); err != http.ErrServerClosed { logger.Error("server error", "error", err) os.Exit(1) } }() // Why: Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit logger.Info("shutting down server...") // Why: Give ongoing requests time to complete ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Why: Graceful shutdown stops accepting new requests // and waits for existing requests to complete if err := server.Shutdown(ctx); err != nil { logger.Error("server forced to shutdown", "error", err) } // Why: Close other resources (database, cache, etc.) if err := db.Close(); err != nil { logger.Error("database close error", "error", err) } logger.Info("server stopped") }

Configuration Management

go
// Filename: internal/config/config.go package config import ( "os" "strconv" "time" ) // Config holds all application configuration type Config struct { Server ServerConfig Database DatabaseConfig Redis RedisConfig JWT JWTConfig Logger LoggerConfig } type ServerConfig struct { Address string ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration } // Load loads configuration from environment variables // Why: Environment variables are the standard for configuration // in containerized environments (12-factor app) func Load() (*Config, error) { return &Config{ Server: ServerConfig{ Address: getEnv("SERVER_ADDRESS", ":8080"), ReadTimeout: getDuration("SERVER_READ_TIMEOUT", 5*time.Second), WriteTimeout: getDuration("SERVER_WRITE_TIMEOUT", 10*time.Second), IdleTimeout: getDuration("SERVER_IDLE_TIMEOUT", 120*time.Second), }, Database: DatabaseConfig{ Host: getEnv("DB_HOST", "localhost"), Port: getEnvInt("DB_PORT", 5432), User: getEnv("DB_USER", "postgres"), Password: getEnv("DB_PASSWORD", ""), Database: getEnv("DB_NAME", "myapi"), SSLMode: getEnv("DB_SSL_MODE", "disable"), MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10), ConnMaxLifetime: getDuration("DB_CONN_MAX_LIFETIME", 5*time.Minute), ConnMaxIdleTime: getDuration("DB_CONN_MAX_IDLE_TIME", 1*time.Minute), }, Redis: RedisConfig{ Address: getEnv("REDIS_ADDRESS", "localhost:6379"), Password: getEnv("REDIS_PASSWORD", ""), DB: getEnvInt("REDIS_DB", 0), }, JWT: JWTConfig{ SecretKey: getEnv("JWT_SECRET", "change-me-in-production"), AccessTokenExpiry: getDuration("JWT_ACCESS_EXPIRY", 15*time.Minute), RefreshTokenExpiry: getDuration("JWT_REFRESH_EXPIRY", 7*24*time.Hour), Issuer: getEnv("JWT_ISSUER", "myapi"), }, Logger: LoggerConfig{ Level: getEnv("LOG_LEVEL", "info"), Format: getEnv("LOG_FORMAT", "json"), }, }, nil } func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } func getEnvInt(key string, defaultValue int) int { if value := os.Getenv(key); value != "" { if intVal, err := strconv.Atoi(value); err == nil { return intVal } } return defaultValue } func getDuration(key string, defaultValue time.Duration) time.Duration { if value := os.Getenv(key); value != "" { if duration, err := time.ParseDuration(value); err == nil { return duration } } return defaultValue }

Summary

Building production-ready REST APIs in Go requires attention to many concerns beyond just handling requests. Let's recap the key takeaways:

Gold Standards Checklist

Architecture
  • Clean project structure with clear separation of concerns
  • Domain-driven design with interfaces for flexibility
  • Dependency injection for testability
HTTP
  • Correct HTTP methods (GET, POST, PUT, PATCH, DELETE)
  • Appropriate status codes (200, 201, 204, 400, 401, 403, 404, 422, 429, 500)
  • RESTful URL design with nouns, not verbs
  • API versioning from day one
Security
  • JWT authentication with short-lived access tokens
  • Role-based authorization
  • Input validation on all endpoints
  • Rate limiting to prevent abuse
  • CORS properly configured
Data
  • Repository pattern for database access
  • Connection pooling configured
  • Transaction support for complex operations
  • Caching strategy (Redis, HTTP caching)
Reliability
  • Graceful shutdown
  • Health check endpoints
  • Panic recovery middleware
  • Proper error handling with custom error types
Observability
  • Structured logging with correlation IDs
  • Prometheus metrics
  • Request/response logging
  • Distributed tracing ready
Developer Experience
  • HATEOAS links for discoverability
  • Consistent response format
  • Comprehensive API documentation
  • Thorough test coverage

Next Steps

  1. Practice: Build a complete API using these patterns
  2. Explore: Add OpenTelemetry for distributed tracing
  3. Scale: Deploy to Kubernetes with horizontal scaling
  4. Secure: Add OAuth2/OIDC for enterprise authentication

Resources

Building production APIs is a journey, not a destination. Each system you build teaches you something new. The patterns in this guide will serve as your foundation, but always be ready to adapt them to your specific needs.
Now go build something amazing.

Complete Working Example

Here's a complete, runnable example that ties everything together. This is the entry point that wires up all the components we've discussed.
go
// Filename: cmd/api/main.go package main import ( "context" "database/sql" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" _ "github.com/lib/pq" "myapi/internal/auth" "myapi/internal/config" "myapi/internal/domain/user" "myapi/internal/handler" "myapi/internal/metrics" "myapi/internal/middleware" "myapi/internal/pkg/logger" rediscache "myapi/internal/repository/redis" "myapi/internal/repository/postgres" "myapi/internal/router" ) func main() { // Step 1: Load configuration // Why: Configuration should be loaded first as everything depends on it cfg, err := config.Load() if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) } // Step 2: Initialize logger // Why: Logger is needed for all subsequent initialization logging log, err := logger.NewLogger(logger.LogConfig{ Level: cfg.Logger.Level, Format: cfg.Logger.Format, }) if err != nil { slog.Error("failed to create logger", "error", err) os.Exit(1) } log.Info("starting application", "version", "1.0.0") // Step 3: Initialize database // Why: Database is a core dependency for most operations db, err := config.NewDatabaseConnection(cfg.Database) if err != nil { log.Error("failed to connect to database", "error", err) os.Exit(1) } defer db.Close() log.Info("database connected") // Step 4: Initialize Redis // Why: Redis is used for caching and rate limiting redisClient := redis.NewClient(&redis.Options{ Addr: cfg.Redis.Address, Password: cfg.Redis.Password, DB: cfg.Redis.DB, }) defer redisClient.Close() if err := redisClient.Ping(context.Background()).Err(); err != nil { log.Error("failed to connect to redis", "error", err) os.Exit(1) } log.Info("redis connected") // Step 5: Initialize metrics // Why: Metrics should be ready before handling requests appMetrics := metrics.NewMetrics() // Step 6: Initialize repositories // Why: Repositories are dependencies for services userRepo := postgres.NewUserRepository(db) cache := rediscache.NewCache(redisClient, rediscache.CacheConfig{ DefaultTTL: 5 * time.Minute, Prefix: "myapi:", }) // Step 7: Initialize services // Why: Services contain business logic and depend on repositories userService := user.NewService(userRepo) jwtService := auth.NewJWTService(auth.TokenConfig{ SecretKey: cfg.JWT.SecretKey, AccessTokenExpiry: cfg.JWT.AccessTokenExpiry, RefreshTokenExpiry: cfg.JWT.RefreshTokenExpiry, Issuer: cfg.JWT.Issuer, }) passwordService := auth.NewPasswordService() authorizer := auth.NewAuthorizer() // Step 8: Initialize handlers // Why: Handlers depend on services userHandler := handler.NewUserHandler(userService, jwtService, passwordService) healthHandler := handler.NewHealthHandler(db, redisClient) // Step 9: Initialize middleware // Why: Middleware wraps handlers with cross-cutting concerns authMW := middleware.NewAuthMiddleware(jwtService) rateLimitMW := middleware.NewRedisRateLimiter( redisClient, middleware.RateLimitConfig{ BurstSize: 100, Window: time.Minute, }, middleware.KeyByIP, ) loggingMW := middleware.NewLoggingMiddleware(log) recoveryMW := middleware.NewRecoveryMiddleware(log) corsMW := middleware.NewCORSMiddleware(middleware.CORSConfig{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"}, ExposedHeaders: []string{"X-Request-ID", "X-RateLimit-Remaining"}, AllowCredentials: true, MaxAge: 86400, }) metricsMW := middleware.NewMetricsMiddleware(appMetrics) // Step 10: Initialize router // Why: Router wires handlers to HTTP paths appRouter := router.New(router.Config{ UserHandler: userHandler, HealthHandler: healthHandler, AuthMW: authMW, RateLimitMW: rateLimitMW, LoggingMW: loggingMW, RecoveryMW: recoveryMW, CORSMW: corsMW, }) // Step 11: Create main mux with metrics endpoint // Why: Metrics endpoint should be separate from main API mainMux := http.NewServeMux() mainMux.Handle("/", metricsMW.Collect(appRouter)) mainMux.Handle("/metrics", promhttp.Handler()) // Step 12: Create and configure server // Why: Server configuration affects performance and reliability server := &http.Server{ Addr: cfg.Server.Address, Handler: mainMux, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, } // Step 13: Start server in goroutine // Why: Allows main goroutine to handle shutdown signals serverErrors := make(chan error, 1) go func() { log.Info("server starting", "address", cfg.Server.Address, "read_timeout", cfg.Server.ReadTimeout, "write_timeout", cfg.Server.WriteTimeout, ) serverErrors <- server.ListenAndServe() }() // Step 14: Wait for shutdown signal // Why: Graceful shutdown allows ongoing requests to complete shutdown := make(chan os.Signal, 1) signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) select { case err := <-serverErrors: if err != http.ErrServerClosed { log.Error("server error", "error", err) } case sig := <-shutdown: log.Info("shutdown signal received", "signal", sig) // Give ongoing requests time to complete ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Gracefully shutdown server if err := server.Shutdown(ctx); err != nil { log.Error("graceful shutdown failed", "error", err) server.Close() } } log.Info("server stopped") }

Docker Deployment

dockerfile
# Filename: Dockerfile # Multi-stage build for minimal production image # Stage 1: Build FROM golang:1.22-alpine AS builder WORKDIR /app # Why: Cache dependencies separately from source code COPY go.mod go.sum ./ RUN go mod download # Why: Copy source and build COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /api ./cmd/api # Stage 2: Runtime FROM alpine:3.19 # Why: Security best practice - don't run as root RUN addgroup -g 1000 app && adduser -u 1000 -G app -s /bin/sh -D app WORKDIR /app # Why: Copy only the binary COPY --from=builder /api . # Why: Set ownership RUN chown -R app:app /app USER app # Why: Expose API port EXPOSE 8080 # Why: Health check for container orchestration HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 ENTRYPOINT ["./api"]

Docker Compose for Development

yaml
# Filename: docker-compose.yml version: '3.8' services: api: build: . ports: - "8080:8080" environment: - SERVER_ADDRESS=:8080 - DB_HOST=postgres - DB_PORT=5432 - DB_USER=myapi - DB_PASSWORD=secret - DB_NAME=myapi - DB_SSL_MODE=disable - REDIS_ADDRESS=redis:6379 - JWT_SECRET=your-secret-key-change-in-production - LOG_LEVEL=debug - LOG_FORMAT=text depends_on: postgres: condition: service_healthy redis: condition: service_healthy postgres: image: postgres:16-alpine environment: - POSTGRES_USER=myapi - POSTGRES_PASSWORD=secret - POSTGRES_DB=myapi ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./migrations:/docker-entrypoint-initdb.d healthcheck: test: ["CMD-SHELL", "pg_isready -U myapi"] interval: 5s timeout: 5s retries: 5 redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 5 prometheus: image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml command: - '--config.file=/etc/prometheus/prometheus.yml' grafana: image: grafana/grafana:latest ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana_data:/var/lib/grafana volumes: postgres_data: redis_data: grafana_data:

Makefile for Common Tasks

makefile
# Filename: Makefile .PHONY: build run test lint migrate docker-build docker-run clean # Build the application build: go build -o bin/api ./cmd/api # Run locally run: go run ./cmd/api # Run tests test: go test -v -race -cover ./... # Run tests with coverage report test-coverage: go test -v -race -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html # Run linter lint: golangci-lint run ./... # Run database migrations migrate-up: migrate -path migrations -database "postgres://myapi:secret@localhost:5432/myapi?sslmode=disable" up migrate-down: migrate -path migrations -database "postgres://myapi:secret@localhost:5432/myapi?sslmode=disable" down # Docker commands docker-build: docker build -t myapi:latest . docker-run: docker-compose up -d docker-stop: docker-compose down docker-logs: docker-compose logs -f api # Generate API documentation docs: swag init -g cmd/api/main.go -o api/docs # Clean build artifacts clean: rm -rf bin/ rm -f coverage.out coverage.html # Development setup setup: go mod download go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest go install github.com/swaggo/swag/cmd/swag@latest go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
This complete example demonstrates how all the pieces fit together. You can use this as a template for your production APIs and customize it based on your specific requirements.
All Blogs
Tags:golangrest-apiproductionmicroservicesauthenticationmiddlewarecachingrate-limitingobservability