Handler-Service-Repository Layering
The handler-service-repository pattern splits HTTP (or gRPC) adapters, business logic, and persistence into three testable layers.
Search across all documentation pages
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.
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.
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:
if chains longer than a screen.httptest and Postgres.// 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:
Service tests use a fake Repository without HTTP or SQL.Handler maps domain errors to HTTP status codes in one place.PG is the only layer that knows SQL column names.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.
| 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.
// 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
}user) with Handler, Service, and PG types over three packages for tiny APIs.sql.Rows to handlers - Exposes scanning logic in HTTP layer. Fix: Repositories return typed structs or slices.Service struct with forty methods. Fix: Split by aggregate (UserService, BillingService).ctx context.Context on all I/O.| 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 |
Start in one feature package (internal/user).
Split when clarity or import rules require internal/user/http subpackages.
HTTP middleware wraps handlers; pass principal via context values or explicit handler fields.
Services enforce authorization rules with injected Authorizer ports.
Either return a domain ErrNotFound from the repository or let service map sql.ErrNoRows.
Be consistent across repos.
Service begins transaction, passes Tx wrapper implementing Repository to helpers, commits on success.
Avoid handlers opening transactions.
Yes for orchestration, but watch cycles.
Prefer composing lower-level ports or a facade service.
Repository methods accept Page structs; services apply defaults and caps.
Handlers parse query params into Page.
No.
net/http ServeMux (Go 1.22+) suffices; routers are edge concerns only.
httptest with fake services injected into Handler.
Assert status codes and JSON bodies for mapping logic.
Generated or hand-written DTOs in handler adapter package.
Map to domain before calling service.
Yes - replace HTTP handler with message consumer adapter calling the same service.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026