Skip to content

Building a REST API with Go and Chi

A hands-on tutorial for a production-ready REST API with Go and Chi: routing, middleware, JSON handling, error responses and graceful shutdown.

6 min read
Go application structure diagram showing Chi router middleware chain and handler layers

Go is a strong choice for HTTP services. The standard library's net/http package handles the fundamentals, but adding a lightweight router like Chi gives you path parameters, middleware chaining, and route grouping without pulling in a heavy framework. Chi stays close to the standard library — handlers are still http.Handler compatible, and middleware follows the standard pattern.

This tutorial builds a complete REST API for a task management service with proper error handling, middleware, and graceful shutdown.

Project Structure

Keep it flat until the project needs more structure. A premature package hierarchy adds more complexity than it solves.

task-api/
├── main.go              # Entry point, server setup
├── handler.go           # HTTP handlers
├── middleware.go         # Custom middleware
├── model.go             # Data types
├── store.go             # Data access layer
├── go.mod
└── go.sum

Setting Up the Router

Chi's router composes middleware and routes in a readable chain.

gogo
// main.go
package main
 
import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
 
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)
 
func main() {
	store := NewMemoryStore()
	handler := NewTaskHandler(store)
 
	r := chi.NewRouter()
 
	// Global middleware stack
	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)
	r.Use(middleware.Timeout(30 * time.Second))
 
	// Routes
	r.Route("/api/v1/tasks", func(r chi.Router) {
		r.Get("/", handler.ListTasks)
		r.Post("/", handler.CreateTask)
		r.Route("/{taskID}", func(r chi.Router) {
			r.Get("/", handler.GetTask)
			r.Put("/", handler.UpdateTask)
			r.Delete("/", handler.DeleteTask)
		})
	})
 
	// Health check outside the versioned API
	r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok"}`))
	})
 
	srv := &http.Server{
		Addr:         ":8080",
		Handler:      r,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}
 
	// Graceful shutdown
	go func() {
		log.Printf("Server starting on %s", srv.Addr)
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("Server error: %v", err)
		}
	}()
 
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
 
	log.Println("Shutting down server...")
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatalf("Forced shutdown: %v", err)
	}
	log.Println("Server stopped")
}

The graceful shutdown block listens for SIGINT/SIGTERM, then gives in-flight requests 10 seconds to complete before forcing a stop. Without this, deploys kill active connections.

Data Model and Store

Define the data types and a simple in-memory store. In production, replace this with a database-backed implementation behind the same interface.

gogo
// model.go
package main
 
import "time"
 
type Task struct {
	ID          string    `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description,omitempty"`
	Status      string    `json:"status"`
	CreatedAt   time.Time `json:"createdAt"`
	UpdatedAt   time.Time `json:"updatedAt"`
}
 
type CreateTaskRequest struct {
	Title       string `json:"title"`
	Description string `json:"description,omitempty"`
}
 
type UpdateTaskRequest struct {
	Title       *string `json:"title,omitempty"`
	Description *string `json:"description,omitempty"`
	Status      *string `json:"status,omitempty"`
}
gogo
// store.go
package main
 
import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"sync"
	"time"
)
 
type TaskStore interface {
	List() ([]Task, error)
	Get(id string) (Task, error)
	Create(req CreateTaskRequest) (Task, error)
	Update(id string, req UpdateTaskRequest) (Task, error)
	Delete(id string) error
}
 
type MemoryStore struct {
	mu    sync.RWMutex
	tasks map[string]Task
}
 
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{tasks: make(map[string]Task)}
}
 
func (s *MemoryStore) List() ([]Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	tasks := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		tasks = append(tasks, t)
	}
	return tasks, nil
}
 
func (s *MemoryStore) Get(id string) (Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	task, ok := s.tasks[id]
	if !ok {
		return Task{}, fmt.Errorf("task not found: %s", id)
	}
	return task, nil
}
 
func (s *MemoryStore) Create(req CreateTaskRequest) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	id := generateID()
	now := time.Now().UTC()
	task := Task{
		ID:          id,
		Title:       req.Title,
		Description: req.Description,
		Status:      "pending",
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	s.tasks[id] = task
	return task, nil
}
 
func (s *MemoryStore) Update(id string, req UpdateTaskRequest) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	task, ok := s.tasks[id]
	if !ok {
		return Task{}, fmt.Errorf("task not found: %s", id)
	}
	if req.Title != nil {
		task.Title = *req.Title
	}
	if req.Description != nil {
		task.Description = *req.Description
	}
	if req.Status != nil {
		task.Status = *req.Status
	}
	task.UpdatedAt = time.Now().UTC()
	s.tasks[id] = task
	return task, nil
}
 
func (s *MemoryStore) Delete(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tasks[id]; !ok {
		return fmt.Errorf("task not found: %s", id)
	}
	delete(s.tasks, id)
	return nil
}
 
func generateID() string {
	b := make([]byte, 8)
	rand.Read(b)
	return hex.EncodeToString(b)
}

The sync.RWMutex allows concurrent reads with exclusive writes — correct for an in-memory store accessed by multiple goroutines.

HTTP Handlers

Handlers parse requests, call the store, and format responses. Keep them thin — business logic belongs in the store or a service layer.

gogo
// handler.go
package main
 
import (
	"encoding/json"
	"net/http"
	"strings"
 
	"github.com/go-chi/chi/v5"
)
 
type TaskHandler struct {
	store TaskStore
}
 
func NewTaskHandler(store TaskStore) *TaskHandler {
	return &TaskHandler{store: store}
}
 
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
	tasks, err := h.store.List()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Failed to list tasks")
		return
	}
	writeJSON(w, http.StatusOK, tasks)
}
 
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "taskID")
	task, err := h.store.Get(id)
	if err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeError(w, http.StatusNotFound, "Task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "Failed to get task")
		return
	}
	writeJSON(w, http.StatusOK, task)
}
 
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "Invalid JSON body")
		return
	}
	if req.Title == "" {
		writeError(w, http.StatusBadRequest, "Title is required")
		return
	}
	if len(req.Title) > 200 {
		writeError(w, http.StatusBadRequest, "Title must be 200 characters or less")
		return
	}
	task, err := h.store.Create(req)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Failed to create task")
		return
	}
	writeJSON(w, http.StatusCreated, task)
}
 
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "taskID")
	var req UpdateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "Invalid JSON body")
		return
	}
	if req.Status != nil {
		valid := map[string]bool{"pending": true, "in-progress": true, "done": true}
		if !valid[*req.Status] {
			writeError(w, http.StatusBadRequest, "Status must be pending, in-progress, or done")
			return
		}
	}
	task, err := h.store.Update(id, req)
	if err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeError(w, http.StatusNotFound, "Task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "Failed to update task")
		return
	}
	writeJSON(w, http.StatusOK, task)
}
 
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "taskID")
	if err := h.store.Delete(id); err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeError(w, http.StatusNotFound, "Task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "Failed to delete task")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

Response Helpers

Consistent JSON responses make the API predictable for clients.

gogo
// ❌ Inconsistent error responses
w.WriteHeader(500)
w.Write([]byte("something went wrong"))
// Client gets plain text sometimes, JSON other times
 
// ✅ Consistent JSON error envelope
type ErrorResponse struct {
	Error   string `json:"error"`
	Status  int    `json:"status"`
}
 
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}
 
func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, ErrorResponse{
		Error:  message,
		Status: status,
	})
}

Every error response has the same shape. Clients can reliably parse errors without checking Content-Type.

Custom Middleware

Chi middleware follows the standard func(http.Handler) http.Handler pattern.

gogo
// middleware.go
package main
 
import (
	"net/http"
	"strings"
)
 
// ContentTypeJSON ensures POST/PUT requests send JSON
func ContentTypeJSON(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost || r.Method == http.MethodPut {
			ct := r.Header.Get("Content-Type")
			if !strings.HasPrefix(ct, "application/json") {
				writeError(w, http.StatusUnsupportedMediaType,
					"Content-Type must be application/json")
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}
 
// CORS middleware for development
func CORS(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)
	})
}

Apply middleware at different scopes — global middleware on the root router, route-specific middleware on sub-routers. The ContentTypeJSON middleware prevents cryptic parsing errors by rejecting non-JSON requests early.

Key Takeaways

  1. Chi stays close to net/http — handlers are standard http.Handler, middleware is func(http.Handler) http.Handler
  2. Set server timeouts explicitly — ReadTimeout, WriteTimeout, and IdleTimeout prevent resource exhaustion
  3. Implement graceful shutdown — catch SIGINT/SIGTERM and give in-flight requests time to complete
  4. Use sync.RWMutex for concurrent access — read locks for queries, write locks for mutations
  5. Validate input at the handler level — check required fields, enforce length limits, validate enum values
  6. Keep error responses consistent — a standard JSON error envelope makes clients predictable
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX