2 min read
On this page

Building a REST API

This topic ties together handlers, routing, request parsing, and response writing into a complete CRUD API. It covers the patterns you will use in every Go API: route organization, JSON serialization, error handling, middleware, graceful shutdown, and structured logging.

Project Structure

A typical small-to-medium Go API:

myapi/
  cmd/api/
    main.go          -- entry point, wiring
  internal/
    handler/
      user.go        -- HTTP handlers
    model/
      user.go        -- domain types
    store/
      user.go        -- database layer
  go.mod

The Domain Model

Start with a simple type:

// internal/model/user.go
package model

import "time"

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

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

In-Memory Store

For the example, an in-memory store. The pattern translates directly to a database-backed store:

// internal/store/user.go
package store

import (
    "fmt"
    "sync"

    "myapi/internal/model"
    "time"
)

type UserStore struct {
    mu    sync.RWMutex
    users map[int]model.User
    nextID int
}

func NewUserStore() *UserStore {
    return &UserStore{users: make(map[int]model.User), nextID: 1}
}

func (s *UserStore) Create(req model.CreateUserRequest) model.User {
    s.mu.Lock()
    defer s.mu.Unlock()
    user := model.User{
        ID: s.nextID, Name: req.Name, Email: req.Email,
        CreatedAt: time.Now(),
    }
    s.users[user.ID] = user
    s.nextID++
    return user
}

func (s *UserStore) Get(id int) (model.User, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    user, ok := s.users[id]
    if !ok {
        return model.User{}, fmt.Errorf("user %d not found", id)
    }
    return user, nil
}

func (s *UserStore) List() []model.User {
    s.mu.RLock()
    defer s.mu.RUnlock()
    users := make([]model.User, 0, len(s.users))
    for _, u := range s.users {
        users = append(users, u)
    }
    return users
}

func (s *UserStore) Delete(id int) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, ok := s.users[id]; !ok {
        return fmt.Errorf("user %d not found", id)
    }
    delete(s.users, id)
    return nil
}

HTTP Handlers

Group handlers in a struct that holds dependencies:

// internal/handler/user.go
package handler

import (
    "encoding/json"
    "log/slog"
    "net/http"
    "strconv"

    "myapi/internal/model"
    "myapi/internal/store"
)

type UserHandler struct {
    Store  *store.UserStore
    Logger *slog.Logger
}

func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
    users := h.Store.List()
    writeJSON(w, http.StatusOK, users)
}

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid user ID")
        return
    }
    user, err := h.Store.Get(id)
    if err != nil {
        writeError(w, http.StatusNotFound, err.Error())
        return
    }
    writeJSON(w, http.StatusOK, user)
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req model.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    if req.Name == "" || req.Email == "" {
        writeError(w, http.StatusBadRequest, "name and email required")
        return
    }
    user := h.Store.Create(req)
    h.Logger.Info("user created", "id", user.ID, "email", user.Email)
    writeJSON(w, http.StatusCreated, user)
}

func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid user ID")
        return
    }
    if err := h.Store.Delete(id); err != nil {
        writeError(w, http.StatusNotFound, err.Error())
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

JSON Response Helpers

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

type errorResponse struct {
    Error string `json:"error"`
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, errorResponse{Error: msg})
}

Middleware

Request Logging

func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            wrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
            next.ServeHTTP(wrapped, r)
            logger.Info("request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", wrapped.status,
                "duration", time.Since(start),
            )
        })
    }
}

type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}

CORS

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

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Authentication

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            writeError(w, http.StatusUnauthorized, "missing authorization header")
            return
        }
        // Validate token, extract user info
        userID, err := validateToken(token)
        if err != nil {
            writeError(w, http.StatusUnauthorized, "invalid token")
            return
        }
        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Wiring It Together

// cmd/api/main.go
package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "time"

    "myapi/internal/handler"
    "myapi/internal/store"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    users := store.NewUserStore()
    userHandler := &handler.UserHandler{Store: users, Logger: logger}

    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users", userHandler.List)
    mux.HandleFunc("POST /api/users", userHandler.Create)
    mux.HandleFunc("GET /api/users/{id}", userHandler.Get)
    mux.HandleFunc("DELETE /api/users/{id}", userHandler.Delete)

    // Apply middleware
    var h http.Handler = mux
    h = loggingMiddleware(logger)(h)
    h = corsMiddleware(h)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      h,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Graceful shutdown
    go func() {
        logger.Info("server starting", "addr", server.Addr)
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            logger.Error("server error", "error", err)
            os.Exit(1)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit

    logger.Info("shutting down server")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := server.Shutdown(ctx); err != nil {
        logger.Error("shutdown error", "error", err)
    }
    logger.Info("server stopped")
}

Graceful Shutdown

The shutdown pattern above is critical for production:

  1. Start the server in a goroutine
  2. Wait for os.Interrupt (Ctrl+C) or SIGTERM (container orchestrator)
  3. Call server.Shutdown(ctx) with a timeout
  4. Shutdown stops accepting new connections and waits for in-flight requests to finish
  5. If requests do not finish within the timeout, the context cancels them

Without graceful shutdown, deploying kills in-flight requests.

Structured Logging with slog

Go 1.21 added log/slog to the standard library. Use it instead of log or third-party loggers:

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

logger.Info("user created", "id", 42, "email", "alice@example.com")
{"time":"2025-01-15T10:30:00Z","level":"INFO","msg":"user created","id":42,"email":"alice@example.com"}

Structured logs are machine-parseable. They work with log aggregators like Loki, Datadog, and CloudWatch.

Common Pitfalls

  • Putting all handlers in main.go. Even small APIs benefit from separating handlers, models, and storage. It makes testing possible.
  • Not validating input. Always check required fields, ranges, and formats before processing. Return 400 Bad Request with a clear message.
  • Forgetting graceful shutdown. Without it, every deploy drops in-flight requests. The pattern is always the same: signal, shutdown with timeout, exit.
  • Using fmt.Println for logging in production. Use slog with JSON output. Your log aggregator will thank you.
  • Hardcoding CORS to allow all origins. Access-Control-Allow-Origin: * is fine for development. In production, restrict it to your actual frontend domain.
  • Not wrapping ResponseWriter for status recording. Without the statusRecorder pattern, your logging middleware cannot know what status code was sent.

Key Takeaways

  • Group handlers in a struct that holds dependencies (store, logger, config).
  • Use writeJSON and writeError helpers to standardize all responses.
  • Middleware follows func(http.Handler) http.Handler and composes with wrapping.
  • Every production API needs logging, CORS, and authentication middleware.
  • Graceful shutdown is not optional: listen for signals, call server.Shutdown, wait for in-flight requests.
  • Use slog with JSON output for structured, machine-parseable logs.
  • The patterns in this topic -- dependency injection via structs, middleware chaining, graceful shutdown -- are the same patterns used in every Go API, from small services to large platforms.