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.
Search across all documentation pages
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.
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.
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:
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:
OrderService encodes the cancellation ruleOrderRepo is a narrow interface with two methodsHTTP handler --> Service --> Repository --> database/driver
| | |
transport business persistence
main wires concrete repos into services and services into handlers| 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 |
ErrNotFound or wrapped driver errorscannot cancel shipped order)404, 409, 500 using errors.Is / errors.As// Wire in cmd/api/main.go
repo := postgres.NewOrderRepo(pool)
svc := app.NewOrderService(repo)
handler := httpapi.NewOrdersHandler(svc)context.Context through every layer for cancellation and tracing*sql.Rows from repositories - return domain structsService type with 40 methods mirrors a god package. Fix: split by bounded context (OrderService, BillingService).ctx first parameter everywhere I/O may block.| 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 |
No - small programs can call SQL from handlers.
Extract layers when tests or team size justify the boundary.
In the consumer package (service) that calls the behavior.
Postgres implementation lives in storage without importing service.
Prefer per aggregate root (OrderRepo loads orders and lines together) to avoid chatty partial loads.
Add WithTx(ctx, fn func(ctx context.Context) error) on repo or pass Tx through context in internal packages - keep service API stable.
Use sentinel or typed errors in domain packages; handlers translate to transport codes.
If a method orchestrates more than one repo call plus rules, it belongs in a service.
Pure formatting stays on domain types.
Same service layer; only the outer adapter changes (grpc server vs chi handler).
Pass fake repos recording calls; table-test business rules without database containers.
Caching is a decoration concern - wrap repo with cache layer implementing same interface.
Add query methods to repo or a separate OrderQuery interface if read paths dominate and differ from writes.
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).
Reviewed by Chris St. John·Last updated Jul 19, 2026