Skip to content
Backend

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.

GoBackend.dev12 min read
GoREST APIArchitecture

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.Println scattered 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 types

Each 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.Context as 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.