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:
- Start the server in a goroutine
- Wait for
os.Interrupt(Ctrl+C) orSIGTERM(container orchestrator) - Call
server.Shutdown(ctx)with a timeout Shutdownstops accepting new connections and waits for in-flight requests to finish- 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 Requestwith 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
slogwith 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
statusRecorderpattern, your logging middleware cannot know what status code was sent.
Key Takeaways
- Group handlers in a struct that holds dependencies (store, logger, config).
- Use
writeJSONandwriteErrorhelpers to standardize all responses. - Middleware follows
func(http.Handler) http.Handlerand 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
slogwith 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.