Read my new blog post on my work on Test Cases Strategies for CNCF - LitmusChaos! Read more

Read my new blog post on my work with Kubernetes during the Google Summer of code! Read more

GoGolangBackendAPI

Building Production-Ready REST APIs in Go

2025-03-10

A complete walkthrough of building a REST API in Go using the standard library — routing, middleware, error handling, and the patterns that keep large codebases maintainable.

Go is an excellent language for HTTP services. The standard library net/http is production-capable out of the box, and the ecosystem has a few well-maintained routers that add the missing pieces without ballooning your dependency tree. This article builds up a real API step by step.

The Standard Library Is Enough to Start

A lot of Go tutorials immediately reach for a framework. You usually do not need one. Here is a complete HTTP server:

package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", handleHealth)
    mux.HandleFunc("/articles", handleArticles)

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

    log.Println("starting server on :8080")
    log.Fatal(server.ListenAndServe())
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"ok"}`))
}

Note the explicit timeouts on the server. The zero-value http.Server has no timeouts — in production, a slow client can hold a connection open forever.

Structuring JSON Responses

Consistent response envelopes make your API predictable for clients:

type Response struct {
    Data  any    `json:"data,omitempty"`
    Error string `json:"error,omitempty"`
}

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

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

Using any (alias for interface{}) lets you pass any struct as the data payload. The omitempty tag ensures that on success, error is not included, and on failure, data is not included.

Routing with Path Parameters

The standard ServeMux does not support path parameters like /articles/{id}. For anything beyond simple routes, chi is my preferred choice — it is lightweight, standard-library compatible, and has no dependencies:

go get github.com/go-chi/chi/v5
import "github.com/go-chi/chi/v5"

r := chi.NewRouter()
r.Get("/articles", listArticles)
r.Post("/articles", createArticle)
r.Get("/articles/{id}", getArticle)
r.Put("/articles/{id}", updateArticle)
r.Delete("/articles/{id}", deleteArticle)

Inside a handler, read the path param:

func getArticle(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    article, err := db.GetArticle(r.Context(), id)
    if err != nil {
        writeError(w, http.StatusNotFound, "article not found")
        return
    }
    writeJSON(w, http.StatusOK, Response{Data: article})
}

Middleware

Middleware wraps handlers to add cross-cutting concerns — logging, authentication, rate limiting. In Go, middleware is just a function that takes an http.Handler and returns one:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !isValidToken(token) {
            writeError(w, http.StatusUnauthorized, "invalid token")
            return
        }
        next.ServeHTTP(w, r)
    })
}

Apply them with chi:

r.Use(loggingMiddleware)
r.Group(func(r chi.Router) {
    r.Use(authMiddleware)
    r.Get("/me", getProfile)
    r.Post("/articles", createArticle)
})

r.Group creates a sub-router so you can apply middleware only to certain routes.

Passing Data Through Context

Middleware often extracts data (the authenticated user, a request ID) that downstream handlers need. The idiomatic way is context.WithValue:

type contextKey string

const userKey contextKey = "user"

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user, err := getUserFromToken(r.Header.Get("Authorization"))
        if err != nil {
            writeError(w, http.StatusUnauthorized, "unauthorized")
            return
        }
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func getProfile(w http.ResponseWriter, r *http.Request) {
    user := r.Context().Value(userKey).(*User)
    writeJSON(w, http.StatusOK, Response{Data: user})
}

Use a typed key (contextKey) instead of a raw string to avoid collisions with other packages that might set values on the same context.

Error Handling Patterns

Go has no exceptions. Every function that can fail returns an error as its last return value. At the handler level, you want to avoid duplicating the error-writing boilerplate. A common pattern is a custom handler type:

type AppError struct {
    Code    int
    Message string
}

func (e *AppError) Error() string { return e.Message }

type AppHandler func(http.ResponseWriter, *http.Request) *AppError

func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        writeError(w, err.Code, err.Message)
    }
}

Now handlers can return errors instead of calling writeError everywhere:

func getArticle(w http.ResponseWriter, r *http.Request) *AppError {
    id := chi.URLParam(r, "id")
    article, err := db.GetArticle(r.Context(), id)
    if err != nil {
        return &AppError{Code: 404, Message: "article not found"}
    }
    writeJSON(w, http.StatusOK, Response{Data: article})
    return nil
}

r.Get("/articles/{id}", AppHandler(getArticle))

Graceful Shutdown

When a container stops (SIGTERM), you want in-flight requests to finish before the process exits:

server := &http.Server{Addr: ":8080", Handler: r}

go func() {
    if err := server.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

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

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

if err := server.Shutdown(ctx); err != nil {
    log.Fatal("server shutdown failed:", err)
}
log.Println("server stopped")

server.Shutdown stops accepting new connections and waits for existing ones to complete, up to the timeout.

Project Structure

For a service with a handful of routes this is fine:

cmd/api/main.go       — server setup, wiring
internal/handler/     — HTTP handlers
internal/store/       — database queries
internal/model/       — shared types

Keep handlers thin — they parse the request, call a store or service function, and write the response. Business logic lives in the layer below. This makes handlers trivially testable and keeps the HTTP concerns separate from the domain logic.

Go's net/http and a minimal router like chi give you everything you need for a production API. Reach for a framework only when you need something specific it provides — not out of habit.