Go is a strong choice for REST APIs. The standard library handles HTTP well, the compiled binary deploys anywhere without dependencies, and the performance is excellent out of the box. This guide walks through building a production-ready API, covering the patterns that matter beyond the basic “hello world” handler.
Project Structure
A clean layout for a Go API project:
myapi/ cmd/ server/ main.go # Entry point, wiring internal/ handler/ user.go # HTTP handlers middleware.go # Middleware functions model/ user.go # Domain types store/ user.go # Database access go.mod go.sumThe internal/ directory prevents other Go modules from importing your application code. The cmd/ directory holds entry points. This structure scales well from small services to larger applications.
Routing with chi
While Go’s net/http default mux works, chi adds URL parameters, middleware chaining, and route grouping without heavy abstractions:
package main
import ( "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware")
func main() { r := chi.NewRouter()
// Global middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.RequestID)
// Routes r.Route("/api/v1", func(r chi.Router) { r.Route("/users", func(r chi.Router) { r.Get("/", listUsers) r.Post("/", createUser) r.Route("/{userID}", func(r chi.Router) { r.Get("/", getUser) r.Put("/", updateUser) r.Delete("/", deleteUser) }) }) })
http.ListenAndServe(":8080", r)}chi is compatible with net/http — handlers are standard http.HandlerFunc, so you are not locked into a framework.
JSON Handling and Error Responses
Consistent JSON responses require a small helper:
type APIError struct { Status int `json:"-"` Code string `json:"code"` Message string `json:"message"`}
func respondJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(data); err != nil { slog.Error("failed to encode response", "error", err) }}
func respondError(w http.ResponseWriter, apiErr APIError) { respondJSON(w, apiErr.Status, apiErr)}A handler using these patterns:
func getUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID")
id, err := strconv.Atoi(userID) if err != nil { respondError(w, APIError{ Status: http.StatusBadRequest, Code: "INVALID_ID", Message: "User ID must be an integer", }) return }
user, err := store.FindUser(r.Context(), id) if err != nil { respondError(w, APIError{ Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "User not found", }) return }
respondJSON(w, http.StatusOK, user)}Writing Custom Middleware
Middleware in Go is just a function that wraps a handler. Here is a simple authentication middleware:
func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { respondError(w, APIError{ Status: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "Missing authorization header", }) return }
userID, err := validateToken(token) if err != nil { respondError(w, APIError{ Status: http.StatusUnauthorized, Code: "INVALID_TOKEN", Message: "Invalid or expired token", }) return }
ctx := context.WithValue(r.Context(), userIDKey, userID) next.ServeHTTP(w, r.WithContext(ctx)) })}Apply it to specific route groups:
r.Route("/api/v1", func(r chi.Router) { r.Route("/users", func(r chi.Router) { r.Use(authMiddleware) r.Get("/", listUsers) })})Structured Logging with slog
Go 1.21 introduced log/slog in the standard library. Use it for structured, leveled logging:
import "log/slog"
func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) slog.SetDefault(logger)
slog.Info("server starting", "port", 8080, "env", "production")}In handlers, add request context:
slog.Info("user created", "user_id", user.ID, "email", user.Email, "request_id", middleware.GetReqID(r.Context()),)JSON-structured logs integrate directly with log aggregation tools like Grafana Loki, Datadog, or the ELK stack.
Graceful Shutdown
A production server must handle shutdown signals to drain in-flight requests:
func main() { srv := &http.Server{ Addr: ":8080", Handler: r, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, }
// Start server in a goroutine go func() { slog.Info("server starting", "addr", srv.Addr) if err := srv.ListenAndServe(); err != http.ErrServerClosed { slog.Error("server error", "error", err) os.Exit(1) } }()
// Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit
slog.Info("shutting down server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()
if err := srv.Shutdown(ctx); err != nil { slog.Error("forced shutdown", "error", err) }
slog.Info("server stopped")}This pattern ensures that when Kubernetes sends SIGTERM or you hit Ctrl+C, in-flight requests complete before the process exits. The 30-second timeout prevents hanging forever on stuck connections.
Request Validation
For input validation, decode and validate in one step:
type CreateUserRequest struct { Name string `json:"name"` Email string `json:"email"`}
func (r CreateUserRequest) Validate() error { if strings.TrimSpace(r.Name) == "" { return fmt.Errorf("name is required") } if !strings.Contains(r.Email, "@") { return fmt.Errorf("invalid email address") } return nil}
func createUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, APIError{ Status: http.StatusBadRequest, Code: "INVALID_JSON", Message: "Request body must be valid JSON", }) return }
if err := req.Validate(); err != nil { respondError(w, APIError{ Status: http.StatusUnprocessableEntity, Code: "VALIDATION_ERROR", Message: err.Error(), }) return }
// Proceed with creating user...}Key Takeaways
Go APIs do not need heavy frameworks. A router like chi, the standard library’s net/http and log/slog, and a few helper functions give you a production-ready foundation. Focus on consistent error responses, structured logging, graceful shutdown, and clean project organization. These patterns will carry you from a single microservice to a fleet of them.