Repository & Service Layer Patterns
Repository and service layers separate data access from business logic so Go services stay testable without standing up Postgres in every unit test.
Handlers translate HTTP or gRPC into service calls; repositories translate service needs into SQL, RPC, or cache operations.
Summary
A repository hides how rows are loaded and saved.
A service orchestrates rules: validation, authorization checks, idempotency, and multi-step workflows.
The handler layer deals with transport concerns: status codes, headers, and request parsing.
Interfaces are defined where they are consumed (typically the service package) and satisfied by concrete repository implementations in internal/storage or similar.
Recipe
Quick-reference recipe card - copy-paste ready.
type UserRepo interface {
Get(ctx context.Context, id string) (User, error)
}
type UserService struct {
repo UserRepo
}
func (s *UserService) DisplayName(ctx context.Context, id string) (string, error) {
u, err := s.repo.Get(ctx, id)
if err != nil { return "", err }
return strings.TrimSpace(u.First + " " + u.Last), nil
}When to reach for this:
- Business rules that outlive storage technology choices
- Services that need fast unit tests without Docker
- Teams where HTTP handlers were accumulating SQL strings
Working Example
package app
import (
"context"
"errors"
"fmt"
)
type Order struct {
ID string
UserID string
Total int64
Status string
}
var ErrNotFound = errors.New("not found")
type OrderRepo interface {
Get(ctx context.Context, id string) (Order, error)
Save(ctx context.Context, o Order) error
}
type OrderService struct {
repo OrderRepo
}
func NewOrderService(repo OrderRepo) *OrderService {
return &OrderService{repo: repo}
}
func (s *OrderService) Cancel(ctx context.Context, id string) error {
o, err := s.repo.Get(ctx, id)
if err != nil {
return err
}
if o.Status == "shipped" {
return fmt.Errorf("cannot cancel shipped order")
}
o.Status = "cancelled"
return s.repo.Save(ctx, o)
}
// PostgresRepo lives in internal/storage; tests use memRepo.
type memRepo struct {
data map[string]Order
}
func (m *memRepo) Get(_ context.Context, id string) (Order, error) {
o, ok := m.data[id]
if !ok { return Order{}, ErrNotFound }
return o, nil
}
func (m *memRepo) Save(_ context.Context, o Order) error {
m.data[o.ID] = o
return nil
}
func Example() {
svc := NewOrderService(&memRepo{data: map[string]Order{
"1": {ID: "1", Status: "pending"},
}})
_ = svc.Cancel(context.Background(), "1")
}What this demonstrates:
OrderServiceencodes the cancellation ruleOrderRepois a narrow interface with two methods- In-memory fake implements the same contract as Postgres
- Domain errors stay in the service; HTTP mapping happens elsewhere
Deep Dive
How It Works
HTTP handler --> Service --> Repository --> database/driver
| | |
transport business persistence
- Handlers parse input, call one service method, map errors to status codes
- Services compose repositories and other clients (email, billing)
- Repositories map domain types to SQL rows or API payloads
mainwires concrete repos into services and services into handlers
Interface Granularity
| Style | Pros | Cons |
|---|---|---|
Entity repo (UserRepo) | Clear ownership per aggregate | Many interfaces in large domains |
| Store interface per bounded context | Fewer types | Can grow wide without care |
| Function parameters for one-off ops | Minimal | Does not scale past one function |
Error Handling
- Repositories return
ErrNotFoundor wrapped driver errors - Services add business meaning (
cannot cancel shipped order) - Handlers map to
404,409,500usingerrors.Is/errors.As
Go Notes
// Wire in cmd/api/main.go
repo := postgres.NewOrderRepo(pool)
svc := app.NewOrderService(repo)
handler := httpapi.NewOrdersHandler(svc)- Keep SQL out of handlers and services
- Pass
context.Contextthrough every layer for cancellation and tracing - Avoid returning
*sql.Rowsfrom repositories - return domain structs
Gotchas
- Leaking sql.DB into services - Business package imports database/sql and becomes untestable. Fix: repository interface in service package; SQL in internal/storage.
- Repository returns HTTP status ints - Transport concerns infect persistence layer. Fix: return errors; map at handler.
- God service struct - One
Servicetype with 40 methods mirrors a god package. Fix: split by bounded context (OrderService,BillingService). - Interfaces with only one implementation "for mocks" - Premature abstraction without a second backend. Fix: start with concrete repo; extract interface when tests or second store need it.
- Context omitted on repo methods - Cancellation and tracing stop at service boundary. Fix:
ctxfirst parameter everywhere I/O may block. - DTO explosion - Separate API, DB, and domain structs for every field. Fix: start with one domain struct; add DTOs when shapes diverge.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Active record (methods on struct load self) | Tiny CRUD apps, prototypes | Complex rules or multiple storage backends |
| CQRS split read/write | Read models differ heavily from writes | Simple CRUD with one database |
| Repository + service (this pattern) | Testable services, evolving storage | Scripts under 200 lines |
| Handler calls SQL directly | One-off admin tools | Production APIs with business rules |
FAQs
Is repository pattern mandatory in Go?
No - small programs can call SQL from handlers.
Extract layers when tests or team size justify the boundary.
Where should interfaces live?
In the consumer package (service) that calls the behavior.
Postgres implementation lives in storage without importing service.
One repo per table or per aggregate?
Prefer per aggregate root (OrderRepo loads orders and lines together) to avoid chatty partial loads.
How do transactions fit?
Add WithTx(ctx, fn func(ctx context.Context) error) on repo or pass Tx through context in internal packages - keep service API stable.
Should services return domain errors or sentinel errors?
Use sentinel or typed errors in domain packages; handlers translate to transport codes.
How fat can a service method be?
If a method orchestrates more than one repo call plus rules, it belongs in a service.
Pure formatting stays on domain types.
gRPC versus HTTP handlers?
Same service layer; only the outer adapter changes (grpc server vs chi handler).
How do I test services?
Pass fake repos recording calls; table-test business rules without database containers.
Should repositories cache?
Caching is a decoration concern - wrap repo with cache layer implementing same interface.
What about read-only queries?
Add query methods to repo or a separate OrderQuery interface if read paths dominate and differ from writes.
Related
- Go Patterns Basics - repository interface snippet
- Constructor Functions & Package APIs - wiring in main
- Middleware & Decorator Patterns - thin handlers
- Go Idioms: Composition Over Inheritance - interface composition
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 at build).