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.
TL;DR
Structure a Go REST API into handler, service, and repository layers with narrow interfaces, validate configuration at startup, and keep business logic out of both the HTTP layer and the database layer.
What You'll Learn
- How to structure a Go REST API into handler, service, and repository layers
- Where to define interfaces so packages stay decoupled and testable
- How to wire configuration, routing, and middleware without a framework
- How to test handlers and services in isolation with httptest and fakes
- Common mistakes that turn a clean layering into a tangled one
Prerequisites
- Go interfaces
- Context in Go
- HTTP basics
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 — see Building a Go API with Clean Architecture Without
Overengineering for where that line
actually sits.
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. It loads
config, opens the database, wires the layers together, and starts the
HTTP server. 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 — the same approach
covered in Testing Go Backend Applications —
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.
Interview Questions
Key Takeaways
- Handlers should only decode, call the service, and respond — nothing else.
- Business logic and authorization belong in the service layer, not the repository.
- Define repository interfaces where they're consumed (in `service`), not where they're implemented.
- Validate configuration at startup so missing environment variables fail fast, not as a nil-pointer panic mid-request.
- Don't add layering (generic repositories, CQRS, event buses) before a real second implementation or scaling problem demands it.
Production Backend Series
Article 1 of 6
- 1.Building a Production-Ready REST API with Go
- 2.PostgreSQL Connection Pooling in Go: pgxpool Explained
- 3.API Rate Limiting in Go: Token Bucket, Middleware and Distributed Limits
- 4.Caching in Go: Redis, Cache-Aside and Cache Invalidation
- 5.Retries, Timeouts and Backoff in Go: Building Resilient Services
- 6.Idempotency in APIs: Preventing Duplicate Payments and Requests
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.
Securing Go REST APIs: Authentication, Authorization and Common Attack Surfaces
Practical Go API security: authentication vs authorization, JWT and refresh tokens, object-level authorization, SQL injection, CORS, and a production checklist.
Testing Go Backend Applications: Unit, Integration and HTTP Tests
A practical testing strategy for Go backend services: table-driven unit tests, httptest for handlers, mocks for services, and integration tests against a real PostgreSQL database.
Code Example
Want to run this locally?
The same handler/service/repository architecture from this article, as a runnable Go backend template with Chi, PostgreSQL, SQLC, JWT auth, and tests.
docker compose up --buildView complete example on GitHub