Skip to content
· 9 min read · 0 views

Building RESTful APIs with Go and Chi

A practical guide to building production RESTful APIs with Go and Chi router — routing, middleware, request validation, error handling, and clean architecture.

// table of contents (11 sections)

I have built REST APIs with various Go frameworks — Gin, Echo, Fiber, and even the standard library. After several production services, Chi has become my go-to choice. It is lightweight, idiomatic, and composable — exactly what you want when building APIs that scale.

This post covers the patterns I use for production REST APIs with Chi: project structure, routing, middleware composition, request validation, error handling, and testing.

Why Chi?

Chi is a lightweight HTTP router built on context.Context. It gives you:

  • Zero allocations for common routes — fast enough for high-throughput services
  • Composable middleware — easy to build reusable request handling layers
  • Standard library compatible — works with http.Handler and http.HandlerFunc
  • URL parameters and wildcards — clean RESTful route definitions
  • No magic — just Go, no complex framework abstractions
go get github.com/go-chi/chi/v5

Project Structure

A clean structure keeps your API maintainable as it grows:

cmd/api/main.go          # Application entry point
internal/
├── api/
│   ├── handler.go       # HTTP handlers
│   ├── middleware.go    # Custom middleware
│   └── response.go      # Response helpers
├── service/             # Business logic
├── repository/          # Data access
├── model/               # Domain models
└── validator/           # Request validation
pkg/
├── router/              # Router setup
└── config/              # Configuration
go.mod
go.sum

The internal/ directory cannot be imported by external modules, keeping your business logic private. pkg/ contains reusable packages.

Basic Setup

// cmd/api/main.go
package main

import (
    "log"
    "net/http"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

func main() {
    r := chi.NewRouter()

    // Chi middleware stack
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(60 * time.Second))

    // Routes
    r.Get("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Welcome"))
    })

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", r))
}

Composable Middleware

Chi middleware is just func(http.Handler) http.Handler. This simple interface makes composition trivial:

// internal/api/middleware.go
package api

import (
    "context"
    "net/http"
    "strings"
    "time"

    "github.com/go-chi/chi/v5/middleware"
)

// RequestLogger structured logging middleware
func RequestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap response writer to capture status code
        ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

        // Call next handler
        next.ServeHTTP(ww, r)

        // Log request details
        log.Printf(
            "method=%s path=%s status=%d duration=%s remote_addr=%s",
            r.Method,
            r.URL.Path,
            ww.Status(),
            time.Since(start),
            r.RemoteAddr,
        )
    })
}

// Auth middleware validates JWT tokens
func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Authorization header required", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")
        if token == authHeader {
            http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
            return
        }

        // Validate token and extract user ID
        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }

        // Store user ID in context
        ctx := context.WithValue(r.Context(), "userID", userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// ContentType enforces JSON content type
func ContentType(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ct := r.Header.Get("Content-Type")
        if !strings.Contains(ct, "application/json") && r.Method != "GET" {
            http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Apply middleware selectively:

// Public routes
r.Group(func(r chi.Router) {
    r.Use(RequestLogger)
    r.Post("/auth/login", h.Login)
    r.Post("/auth/register", h.Register)
})

// Protected routes
r.Group(func(r chi.Router) {
    r.Use(RequestLogger)
    r.Use(Auth)
    r.Use(ContentType)

    r.Get("/users", h.ListUsers)
    r.Get("/users/{id}", h.GetUser)
    r.Put("/users/{id}", h.UpdateUser)
    r.Delete("/users/{id}", h.DeleteUser)
})

RESTful Routing

Chi supports URL parameters, wildcards, and RESTful route patterns:

// pkg/router/router.go
package router

import (
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/yourproject/internal/api"
)

func New(h *api.Handler) chi.Router {
    r := chi.NewRouter()

    // Global middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.Recoverer)
    r.Use(h.RequestLogger)
    r.Use(middleware.AllowContentType("application/json"))
    r.Use(middleware.Timeout(60 * time.Second))

    // API v1 routes
    r.Route("/api/v1", func(r chi.Router) {
        // Auth routes (public)
        r.Post("/auth/login", h.Login)
        r.Post("/auth/register", h.Register)
        r.Post("/auth/refresh", h.RefreshToken)

        // Users routes (protected)
        r.Route("/users", func(r chi.Router) {
            r.Use(h.Auth)
            r.Get("/", h.ListUsers)        // GET /api/v1/users?page=1&limit=10
            r.Post("/", h.CreateUser)       // POST /api/v1/users
            r.Get("/{id}", h.GetUser)       // GET /api/v1/users/123
            r.Put("/{id}", h.UpdateUser)    // PUT /api/v1/users/123
            r.Patch("/{id}", h.PatchUser)   // PATCH /api/v1/users/123
            r.Delete("/{id}", h.DeleteUser) // DELETE /api/v1/users/123

            // Nested routes
            r.Get("/{id}/posts", h.ListUserPosts)      // GET /api/v1/users/123/posts
            r.Post("/{id}/posts", h.CreateUserPost)    // POST /api/v1/users/123/posts
        })

        // Posts routes (protected)
        r.Route("/posts", func(r chi.Router) {
            r.Use(h.Auth)
            r.Get("/", h.ListPosts)        // GET /api/v1/posts
            r.Post("/", h.CreatePost)      // POST /api/v1/posts
            r.Get("/{id}", h.GetPost)      // GET /api/v1/posts/123
            r.Put("/{id}", h.UpdatePost)   // PUT /api/v1/posts/123
            r.Delete("/{id}", h.DeletePost) // DELETE /api/v1/posts/123

            // Comments nested under posts
            r.Route("/{postID}/comments", func(r chi.Router) {
                r.Get("/", h.ListComments)   // GET /api/v1/posts/123/comments
                r.Post("/", h.CreateComment) // POST /api/v1/posts/123/comments
            })
        })

        // Health check (no auth required)
        r.Get("/health", h.HealthCheck)
    })

    return r
}

Handlers With Clean Architecture

A good handler should be thin — just HTTP concerns. Business logic belongs in the service layer:

// internal/api/handler.go
package api

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

    "github.com/go-chi/chi/v5"
    "github.com/yourproject/internal/model"
    "github.com/yourproject/internal/service"
    "github.com/yourproject/internal/validator"
)

type Handler struct {
    userService *service.UserService
    postService *service.PostService
    validator   *validator.Validator
}

func New(userService *service.UserService, postService *service.PostService) *Handler {
    return &Handler{
        userService: userService,
        postService: postService,
        validator:   validator.New(),
    }
}

// ListUsers handles GET /users
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
    // Parse query parameters
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page < 1 {
        page = 1
    }

    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    if limit < 1 || limit > 100 {
        limit = 10
    }

    // Call service layer
    users, total, err := h.userService.ListUsers(r.Context(), page, limit)
    if err != nil {
        h.Error(w, err)
        return
    }

    // Response
    h.JSON(w, http.StatusOK, map[string]any{
        "data": users,
        "meta": map[string]any{
            "page":       page,
            "limit":      limit,
            "total":      total,
            "totalPages": (total + limit - 1) / limit,
        },
    })
}

// GetUser handles GET /users/{id}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    // Extract URL parameter
    id := chi.URLParam(r, "id")
    if id == "" {
        h.Error(w, ErrInvalidRequest("user ID is required"))
        return
    }

    // Call service
    user, err := h.userService.GetUser(r.Context(), id)
    if err != nil {
        h.Error(w, err)
        return
    }

    h.JSON(w, http.StatusOK, user)
}

// CreateUser handles POST /users
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req model.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.Error(w, ErrInvalidRequest("invalid JSON body"))
        return
    }
    defer r.Body.Close()

    // Validate request
    if err := h.validator.Validate(&req); err != nil {
        h.Error(w, ErrValidation(err))
        return
    }

    // Call service
    user, err := h.userService.CreateUser(r.Context(), &req)
    if err != nil {
        h.Error(w, err)
        return
    }

    h.JSON(w, http.StatusCreated, user)
}

Response Helpers

Consistent response formatting makes your API predictable:

// internal/api/response.go
package api

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

type Response struct {
    Success bool        `json:"success"`
    Data    any         `json:"data,omitempty"`
    Error   *ErrorResp  `json:"error,omitempty"`
    Meta    any         `json:"meta,omitempty"`
}

type ErrorResp struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Details any    `json:"details,omitempty"`
}

// JSON writes a JSON response
func (h *Handler) JSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)

    resp := Response{
        Success: status < 400,
        Data:    data,
    }

    json.NewEncoder(w).Encode(resp)
}

// Error writes an error response
func (h *Handler) Error(w http.ResponseWriter, err error) {
    // Handle domain errors
    if apiErr, ok := err.(*APIError); ok {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(apiErr.Status)

        json.NewEncoder(w).Encode(Response{
            Success: false,
            Error: &ErrorResp{
                Code:    apiErr.Code,
                Message: apiErr.Message,
                Details: apiErr.Details,
            },
        })
        return
    }

    // Unknown error — internal server error
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusInternalServerError)

    json.NewEncoder(w).Encode(Response{
        Success: false,
        Error: &ErrorResp{
            Code:    "INTERNAL_ERROR",
            Message: "An internal error occurred",
        },
    })
}

Error Types

Define error types for different scenarios:

// internal/api/errors.go
package api

import "net/http"

type APIError struct {
    Status  int
    Code    string
    Message string
    Details any
}

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

// Error constructors
func ErrValidation(details any) *APIError {
    return &APIError{
        Status:  http.StatusUnprocessableEntity,
        Code:    "VALIDATION_ERROR",
        Message: "Request validation failed",
        Details: details,
    }
}

func ErrNotFound(resource string) *APIError {
    return &APIError{
        Status:  http.StatusNotFound,
        Code:    "NOT_FOUND",
        Message: resource + " not found",
    }
}

func ErrUnauthorized(msg string) *APIError {
    return &APIError{
        Status:  http.StatusUnauthorized,
        Code:    "UNAUTHORIZED",
        Message: msg,
    }
}

func ErrForbidden(msg string) *APIError {
    return &APIError{
        Status:  http.StatusForbidden,
        Code:    "FORBIDDEN",
        Message: msg,
    }
}

func ErrInvalidRequest(msg string) *APIError {
    return &APIError{
        Status:  http.StatusBadRequest,
        Code:    "INVALID_REQUEST",
        Message: msg,
    }
}

Request Validation

Use a validation library like go-playground/validator:

// internal/validator/validator.go
package validator

import (
    "github.com/go-playground/validator/v10"
)

type Validator struct {
    validate *validator.Validate
}

func New() *Validator {
    v := validator.New()

    // Register custom validations
    v.RegisterValidation("slug", validateSlug)
    v.RegisterValidation("username", validateUsername)

    return &Validator{validate: v}
}

func (v *Validator) Validate(s any) error {
    if err := v.validate.Struct(s); err != nil {
        return formatValidationErrors(err)
    }
    return nil
}

func formatValidationErrors(err error) map[string]string {
    errors := make(map[string]string)

    for _, err := range err.(validator.ValidationErrors) {
        field := err.Field()
        tag := err.Tag()

        var msg string
        switch tag {
        case "required":
            msg = field + " is required"
        case "email":
            msg = field + " must be a valid email"
        case "min":
            msg = field + " must be at least " + err.Param() + " characters"
        case "max":
            msg = field + " must be at most " + err.Param() + " characters"
        case "slug":
            msg = field + " must contain only lowercase letters, numbers, and hyphens"
        default:
            msg = field + " is invalid"
        }

        errors[field] = msg
    }

    return errors
}

Define request models with validation tags:

// internal/model/user.go
package model

type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
    Username string `json:"username" validate:"required,username,min=3,max=30"`
}

type UpdateUserRequest struct {
    Name  string `json:"name" validate:"omitempty,min=2,max=100"`
    Email string `json:"email" validate:"omitempty,email"`
}

Testing Handlers

Test handlers with httptest:

// internal/api/handler_test.go
package api_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/yourproject/internal/api"
    "github.com/yourproject/internal/service"
    "github.com/yourproject/internal/model"
)

func TestCreateUser(t *testing.T) {
    // Mock service
    mockService := &service.MockUserService{
        CreateUserFunc: func(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
            return &model.User{ID: "123", Name: req.Name, Email: req.Email}, nil
        },
    }

    handler := api.New(mockService, nil)
    router := chi.NewRouter()
    router.Post("/users", handler.CreateUser)

    // Request body
    body := map[string]any{
        "name":     "John Doe",
        "email":    "john@example.com",
        "password": "password123",
    }
    jsonBody, _ := json.Marshal(body)

    // Create request
    req := httptest.NewRequest("POST", "/users", bytes.NewReader(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    // Execute
    router.ServeHTTP(w, req)

    // Assert
    if w.Code != http.StatusCreated {
        t.Errorf("expected status 201, got %d", w.Code)
    }

    var resp api.Response
    json.Unmarshal(w.Body.Bytes(), &resp)

    if !resp.Success {
        t.Errorf("expected success=true")
    }
}

Key Takeaways

  1. Use Chi for composable routing — middleware and routes compose naturally
  2. Keep handlers thin — business logic belongs in the service layer
  3. Consistent response format — wrap all responses in a standard structure
  4. Typed error handling — define API errors with status codes and error codes
  5. Validate input early — use validator at the handler boundary
  6. Context propagation — pass context through to service and repository layers
  7. Test with httptest — mock services and test HTTP layer in isolation
  8. Version your API — use /api/v1/ pattern for future evolution

Chi gives you the building blocks for production APIs without the overhead of a full framework. Combined with clean architecture patterns, you get APIs that are fast, maintainable, and testable.

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion