Handler-Service-Repository Layering
The handler-service-repository pattern splits HTTP (or gRPC) adapters, business logic, and persistence into three testable layers.
It is the default architecture for Go APIs that outgrow main.go with inline SQL.
Summary
Handlers translate wire formats and status codes.
Services enforce rules and orchestrate workflows.
Repositories talk to databases, caches, and external APIs.
Dependencies flow handler → service → repository interface, never the reverse.
When to reach for this: Building REST or gRPC services, onboarding backend devs, or extracting logic from fat handlers.
Recipe
Quick-reference recipe card - copy-paste ready.
// repository port
type UserRepo interface {
ByEmail(ctx context.Context, email string) (User, error)
Insert(ctx context.Context, u User) error
}
// service
func (s *Service) Register(ctx context.Context, email string) error { /* rules */ }
// handler
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { /* decode, call service, respond */ }When to reach for this:
- Handlers contain SQL strings or business
ifchains longer than a screen. - You need unit tests for pricing, auth, or validation without
httptestand Postgres. - Multiple transports (HTTP + gRPC + CLI) will share the same workflows.
Working Example
// internal/user/repo.go
package user
import (
"context"
"time"
)
type User struct {
ID int64
Email string
CreatedAt time.Time
}
type Repository interface {
FindByEmail(ctx context.Context, email string) (User, error)
Create(ctx context.Context, u User) error
}// internal/user/service.go
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
var (
ErrInvalidEmail = errors.New("user: invalid email")
ErrExists = errors.New("user: email already registered")
)
type Service struct {
repo Repository
now func() time.Time
}
func NewService(repo Repository) *Service {
return &Service{repo: repo, now: time.Now}
}
func (s *Service) Register(ctx context.Context, email string) error {
email = strings.TrimSpace(strings.ToLower(email))
if !strings.Contains(email, "@") {
return ErrInvalidEmail
}
if _, err := s.repo.FindByEmail(ctx, email); err == nil {
return ErrExists
}
u := User{Email: email, CreatedAt: s.now()}
if err := s.repo.Create(ctx, u); err != nil {
return fmt.Errorf("register: %w", err)
}
return nil
}// internal/user/postgres.go
package user
import (
"context"
"database/sql"
"errors"
)
type PG struct {
db *sql.DB
}
func (p *PG) FindByEmail(ctx context.Context, email string) (User, error) {
var u User
err := p.db.QueryRowContext(ctx,
`SELECT id, email, created_at FROM users WHERE email=$1`, email,
).Scan(&u.ID, &u.Email, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, err
}
return u, err
}
func (p *PG) Create(ctx context.Context, u User) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO users (email, created_at) VALUES ($1,$2)`, u.Email, u.CreatedAt,
)
return err
}// internal/user/http.go
package user
import (
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
err := h.svc.Register(r.Context(), in.Email)
if err != nil {
switch {
case errors.Is(err, ErrInvalidEmail):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrExists):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "server error", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusCreated)
}// cmd/api/main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/jackc/pgx/v5/stdlib"
"example.com/app/internal/user"
)
func main() {
db, err := sql.Open("pgx", "postgres://localhost/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
repo := &user.PG{db: db}
svc := user.NewService(repo)
h := user.NewHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("POST /users", h.Register)
log.Fatal(http.ListenAndServe(":8080", mux))
}What this demonstrates:
Servicetests use a fakeRepositorywithout HTTP or SQL.Handlermaps domain errors to HTTP status codes in one place.PGis the only layer that knows SQL column names.
Deep Dive
How It Works
- Handler (adapter): Request parsing, auth middleware integration, response encoding, status codes.
- Service (application): Validation, authorization checks, transactions spanning repos, idempotency keys.
- Repository (infrastructure): CRUD and queries; returns domain structs or maps driver types.
Request Flow
Client -> Handler -> Service -> Repository -> Database
<- <- <-
Context propagates for cancellation.
Transactions usually start in the service (or a UnitOfWork port) and pass *sql.Tx to repositories when needed.
gRPC Variant
| HTTP | gRPC equivalent |
|---|---|
Handler | grpc.Server method on a generated service |
| JSON decode | proto unmarshaling |
http.Error | status.Error(codes.*, msg) |
Keep services identical; only driving adapters change.
Go Notes
// Fake repo for service tests
type memRepo struct {
byEmail map[string]User
}
func (m *memRepo) FindByEmail(ctx context.Context, email string) (User, error) {
u, ok := m.byEmail[email]
if !ok {
return User{}, errors.New("not found")
}
return u, nil
}- Prefer one package per feature (
user) withHandler,Service, andPGtypes over three packages for tiny APIs. - Split packages when files grow past team comfort or import cycles appear.
Gotchas
- Leaking
sql.Rowsto handlers - Exposes scanning logic in HTTP layer. Fix: Repositories return typed structs or slices. - Service returning HTTP status ints - Couples core to transport. Fix: Return sentinel errors; handlers translate.
- God service - One
Servicestruct with forty methods. Fix: Split by aggregate (UserService,BillingService). - Repository returning DTOs with JSON tags - Confuses persistence with API contracts. Fix: Separate API structs in handler package if shapes differ.
- Missing context on repo methods - Cannot cancel slow queries. Fix: First parameter
ctx context.Contexton all I/O. - Handler calling two services without transaction - Partial updates on failure. Fix: Orchestrate in a service method with explicit transaction port.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Fat handlers | Admin scripts, prototypes | Product APIs with evolving rules |
| Hexagonal ports/adapters | Many I/O systems and entrypoints | Simple CRUD with one database |
| Active record ORM | Team prioritizes velocity | Complex domain invariants |
| CQRS read/write split | Read models diverge heavily | Straightforward CRUD |
FAQs
One package or three packages per feature?
Start in one feature package (internal/user).
Split when clarity or import rules require internal/user/http subpackages.
Where do middleware and auth live?
HTTP middleware wraps handlers; pass principal via context values or explicit handler fields.
Services enforce authorization rules with injected Authorizer ports.
Should repositories return errors.Is(sql.ErrNoRows)?
Either return a domain ErrNotFound from the repository or let service map sql.ErrNoRows.
Be consistent across repos.
How do transactions fit?
Service begins transaction, passes Tx wrapper implementing Repository to helpers, commits on success.
Avoid handlers opening transactions.
Can services call other services?
Yes for orchestration, but watch cycles.
Prefer composing lower-level ports or a facade service.
What about pagination?
Repository methods accept Page structs; services apply defaults and caps.
Handlers parse query params into Page.
Is chi/gin required?
No.
net/http ServeMux (Go 1.22+) suffices; routers are edge concerns only.
How do I test handlers?
httptest with fake services injected into Handler.
Assert status codes and JSON bodies for mapping logic.
Where do OpenAPI types go?
Generated or hand-written DTOs in handler adapter package.
Map to domain before calling service.
Does this pattern work for workers?
Yes - replace HTTP handler with message consumer adapter calling the same service.
Related
- Go Architecture Basics - Layout and wiring examples
- Architecture in Go: Small Interfaces, Explicit Dependencies - Interface placement
- Clean & Hexagonal Architecture in Go - Formal ports and adapters
- Repository & Service Layer Patterns - Pattern catalog in Go Patterns
- Architecture & Design Best Practices - Team habits for evolving layers
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).