Building a Production-Ready REST API with Go
A clean, minimal-abstraction project structure for production Go REST APIs: routing, configuration, handlers, services, repositories, validation, error handling, logging, middleware, and health checks.
The problem
Most Go REST API guides show a single main.go with routes wired directly to
inline handlers, or the opposite extreme: five layers of interfaces for a
service that talks to one database. Neither survives contact with a real
codebase. The first becomes unmaintainable past a handful of endpoints; the
second slows down every change with abstraction that doesn't pay for itself
yet.
What a production API actually needs is a small number of clear boundaries — routing, request handling, business logic, data access — each doing one job, connected through explicit dependencies instead of magic.
Why it matters
The cost of getting this wrong doesn't show up on day one. It shows up when:
- A validation rule needs to change and it's duplicated across three handlers that each parse the same request body slightly differently.
- A bug in an SQL query can't be tested without spinning up the full HTTP stack, because the query lives inline in the handler.
- Logging is
fmt.Printlnscattered through the codebase with no request correlation, so a production incident can't be traced end to end. - Adding a second resource means copy-pasting a handler and hoping the next engineer keeps it in sync with the first.
A consistent structure fixes all four without requiring a framework.
Project structure
cmd/
api/
main.go # wiring: config, dependencies, router, server
internal/
config/
config.go # environment-based configuration
handler/
post_handler.go # HTTP concerns: decode, validate, respond
health_handler.go
service/
post_service.go # business logic, orchestration
repository/
post_repository.go # SQL, no HTTP or business logic
middleware/
request_id.go
logging.go
recover.go
model/
post.go # domain typesEach layer depends only on the one below it — handler depends on service,
service depends on repository — and each is defined in terms of the
narrowest interface the layer above actually needs, not a rewrite of every
technique in the previous rows of this file. internal/ keeps all of this
unimportable from outside the module, which is exactly the visibility you
want for application code that isn't a reusable library.
Configuration
Load configuration once, at startup, and fail fast if it's invalid — don't let a missing environment variable surface as a panic three requests in:
package config
import (
"fmt"
"os"
"time"
)
type Config struct {
Port string
DatabaseURL string
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Port: envOrDefault("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
ShutdownTimeout: 10 * time.Second,
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
}
return cfg, nil
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}Wiring it together
main.go is the only place that knows about every concrete type. Everything
else receives its dependencies through constructors:
package main
import (
"database/sql"
"log/slog"
"net/http"
"os"
"github.com/go-chi/chi/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/gobackend-dev/gobackend-starter/internal/config"
"github.com/gobackend-dev/gobackend-starter/internal/handler"
"github.com/gobackend-dev/gobackend-starter/internal/middleware"
"github.com/gobackend-dev/gobackend-starter/internal/repository"
"github.com/gobackend-dev/gobackend-starter/internal/service"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("invalid configuration", "error", err)
os.Exit(1)
}
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
postRepo := repository.NewPostRepository(db)
postSvc := service.NewPostService(postRepo, logger)
postHandler := handler.NewPostHandler(postSvc, logger)
healthHandler := handler.NewHealthHandler(db)
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logging(logger))
r.Use(middleware.Recover(logger))
r.Get("/health", healthHandler.Health)
r.Get("/ready", healthHandler.Ready)
r.Route("/api/v1/posts", func(r chi.Router) {
r.Get("/", postHandler.List)
r.Post("/", postHandler.Create)
r.Get("/{id}", postHandler.Get)
})
logger.Info("starting server", "port", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, r); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}This omits graceful shutdown for brevity. See Graceful Shutdown in Go HTTP
Servers for the http.Server + signal-handling
pattern that belongs in main.go alongside this wiring.
Handler: HTTP concerns only
A handler's job is narrow: decode the request, call the service, translate the result (or error) into an HTTP response. No SQL, no business rules.
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/gobackend-dev/gobackend-starter/internal/service"
)
type PostHandler struct {
svc *service.PostService
logger *slog.Logger
}
func NewPostHandler(svc *service.PostService, logger *slog.Logger) *PostHandler {
return &PostHandler{svc: svc, logger: logger}
}
type createPostRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createPostRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
post, err := h.svc.CreatePost(r.Context(), service.CreatePostInput{
Title: req.Title,
Content: req.Content,
})
if err != nil {
h.handleError(w, err)
return
}
writeJSON(w, http.StatusCreated, post)
}
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
post, err := h.svc.GetPost(r.Context(), id)
if err != nil {
h.handleError(w, err)
return
}
writeJSON(w, http.StatusOK, post)
}
func (h *PostHandler) handleError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrValidation):
writeError(w, http.StatusUnprocessableEntity, err.Error())
case errors.Is(err, service.ErrNotFound):
writeError(w, http.StatusNotFound, "post not found")
default:
h.logger.Error("unhandled error", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}Service: validation and business logic
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/gobackend-dev/gobackend-starter/internal/model"
)
var (
ErrValidation = errors.New("validation error")
ErrNotFound = errors.New("not found")
)
type PostRepository interface {
Insert(ctx context.Context, post model.Post) (model.Post, error)
FindByID(ctx context.Context, id string) (model.Post, error)
}
type PostService struct {
repo PostRepository
logger *slog.Logger
}
func NewPostService(repo PostRepository, logger *slog.Logger) *PostService {
return &PostService{repo: repo, logger: logger}
}
type CreatePostInput struct {
Title string
Content string
}
func (s *PostService) CreatePost(ctx context.Context, in CreatePostInput) (model.Post, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return model.Post{}, fmt.Errorf("%w: title is required", ErrValidation)
}
if len(title) > 200 {
return model.Post{}, fmt.Errorf("%w: title must be 200 characters or fewer", ErrValidation)
}
post := model.NewPost(title, in.Content)
saved, err := s.repo.Insert(ctx, post)
if err != nil {
return model.Post{}, fmt.Errorf("insert post: %w", err)
}
return saved, nil
}
func (s *PostService) GetPost(ctx context.Context, id string) (model.Post, error) {
post, err := s.repo.FindByID(ctx, id)
if errors.Is(err, model.ErrPostNotFound) {
return model.Post{}, ErrNotFound
}
if err != nil {
return model.Post{}, fmt.Errorf("find post %s: %w", id, err)
}
return post, nil
}Notice PostRepository is an interface defined in the service package,
scoped to exactly the two methods this service uses — not a copy of every
method the concrete repository happens to implement. That's what makes it
trivial to substitute a fake in tests without a mocking framework.
Repository: SQL, nothing else
package repository
import (
"context"
"database/sql"
"errors"
"github.com/gobackend-dev/gobackend-starter/internal/model"
)
type PostRepository struct {
db *sql.DB
}
func NewPostRepository(db *sql.DB) *PostRepository {
return &PostRepository{db: db}
}
func (r *PostRepository) Insert(ctx context.Context, post model.Post) (model.Post, error) {
const query = `
INSERT INTO posts (id, title, content, created_at)
VALUES ($1, $2, $3, $4)
`
_, err := r.db.ExecContext(ctx, query, post.ID, post.Title, post.Content, post.CreatedAt)
if err != nil {
return model.Post{}, err
}
return post, nil
}
func (r *PostRepository) FindByID(ctx context.Context, id string) (model.Post, error) {
const query = `SELECT id, title, content, created_at FROM posts WHERE id = $1`
var p model.Post
err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Title, &p.Content, &p.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return model.Post{}, model.ErrPostNotFound
}
if err != nil {
return model.Post{}, err
}
return p, nil
}Health and readiness
package handler
import (
"database/sql"
"net/http"
)
type HealthHandler struct {
db *sql.DB
}
func NewHealthHandler(db *sql.DB) *HealthHandler {
return &HealthHandler{db: db}
}
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
if err := h.db.PingContext(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}/health answers "is the process alive" and should never depend on external
systems. /ready answers "can this instance serve traffic right now" and
should check the dependencies that would make it fail requests — here, the
database connection.
Production considerations
Validate configuration at startup, not per-request. A missing
DATABASE_URL should fail the process immediately with a clear log line, not
surface as a nil-pointer panic on the first request.
Log with structured fields, not string concatenation. log/slog with a
JSON handler makes logs queryable in any log aggregator without a parsing
layer, and attaching a request ID to every log line inside a request's
lifetime is what makes tracing an incident across handler → service →
repository possible.
Keep interfaces where they're consumed. PostRepository lives in
service, not repository. This keeps the repository package free to add
methods without forcing every consumer's interface to grow, and it makes the
service package's dependencies explicit at a glance.
Don't build every seam on day one. A single-resource API doesn't need a
generic Repository[T], a CQRS split, or an event bus. Add those when a
second implementation or a real scaling problem demands it — the layering
above is enough to introduce them later without a rewrite.
Common mistakes
- Fat handlers. Once a handler starts doing more than decode → call →
respond, business logic has leaked into the HTTP layer, and it can no
longer be tested without spinning up an
http.Request. - Business logic in the repository. Validation, defaulting, and authorization decisions belong in the service layer. The repository should translate between Go types and SQL, and nothing more.
- Swallowing context. Every service and repository method here takes
ctx context.Contextas its first parameter and passes it all the way to the database call — dropping it anywhere in that chain breaks cancellation and timeouts for no benefit. - Over-abstracting early. Interfaces for every struct, a generic repository before there's a second table, or a plugin system for a service with one deployment target — these cost more to maintain than the flexibility they hypothetically provide.
Testing
Handlers are testable in isolation with httptest, using a hand-written fake
that implements the same narrow interface the service depends on:
package handler_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gobackend-dev/gobackend-starter/internal/handler"
"github.com/gobackend-dev/gobackend-starter/internal/model"
"github.com/gobackend-dev/gobackend-starter/internal/service"
)
type fakeRepo struct {
posts map[string]model.Post
}
func (f *fakeRepo) Insert(ctx context.Context, post model.Post) (model.Post, error) {
f.posts[post.ID] = post
return post, nil
}
func (f *fakeRepo) FindByID(ctx context.Context, id string) (model.Post, error) {
post, ok := f.posts[id]
if !ok {
return model.Post{}, model.ErrPostNotFound
}
return post, nil
}
func TestCreatePost_ValidationError(t *testing.T) {
repo := &fakeRepo{posts: map[string]model.Post{}}
svc := service.NewPostService(repo, testLogger())
h := handler.NewPostHandler(svc, testLogger())
body := strings.NewReader(`{"title": "", "content": "hello"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/posts", body)
rec := httptest.NewRecorder()
h.Create(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected status 422, got %d", rec.Code)
}
var resp map[string]string
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["error"] == "" {
t.Fatal("expected an error message in the response body")
}
}Because PostService depends on the PostRepository interface rather than
the concrete SQL type, this test never touches a real database — it verifies
the handler-to-service validation path end to end, in memory.
Summary
A production-ready Go REST API doesn't need a framework or a deep layer stack — it needs clear, narrow boundaries: handlers that only translate HTTP, services that own validation and business rules, repositories that own SQL, and interfaces defined where they're consumed rather than where they're implemented. That structure keeps each piece independently testable, keeps business logic out of both the transport and data layers, and leaves room to add real complexity later without restructuring what's already there.
funcRelated()[]Article
Building a Go API with Clean Architecture Without Overengineering
A practical take on Clean Architecture in Go: handler/service/repository separation, dependency direction, and knowing when to stop adding abstractions.
Idempotency in APIs: Preventing Duplicate Payments and Requests
Implementing idempotency keys in Go and PostgreSQL to prevent duplicate payments and orders when clients retry after a timeout.
Caching in Go: Redis, Cache-Aside and Cache Invalidation
A practical cache-aside implementation in Go with Redis: TTLs, cache stampedes, invalidation strategies, and when caching is the wrong call.