Skip to content

Layering: handler -> service -> repository -> domain

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/layering.md
DescriptionNot specified

Source Content

Layering: handler -> service -> repository -> domain

The non-negotiable rule: dependencies point inward. domain is the center and imports nothing from the outer layers. Each outer layer depends only on the layer directly inside it, and always through an interface it owns.

Package map

internal/
├── handler/ # HTTP: Fiber, generated OpenAPI types, error -> status mapping
├── service/ # business logic, orchestration, transactions; defines repo interfaces
├── repository/ # persistence: pgx / GORM, SQL, row mapping
├── domain/ # entities, value objects, domain errors, pure logic — imports nothing inward
├── config/ # Viper-loaded config struct
└── observability/ # Zap + OTel + Prometheus wiring

Import-direction rule

  • handler may import service and domain.
  • service may import domain and the repository interfaces it defines.
  • repository may import domain only.
  • domain imports nothing from handler, service, or repository.

If you find yourself importing gorm.io/gorm into a service, or github.com/gofiber/fiber into a service, the layering is wrong. The service speaks domain types and repository interfaces — nothing about transport or storage.

The seam is an interface in the consumer

The repository interface lives in the service package (the consumer), not the repository package. This is dependency inversion: the service declares what it needs; the repository implements it. Concrete wiring happens only in main.

internal/service/invoice.go
package service
import (
"context"
"github.com/acme/billing/internal/domain"
)
// InvoiceRepository is the seam. Defined here, in the consumer.
type InvoiceRepository interface {
ByID(ctx context.Context, id domain.InvoiceID) (domain.Invoice, error)
Save(ctx context.Context, inv domain.Invoice) error
}
type InvoiceService struct {
repo InvoiceRepository // depends on the interface, not the concrete type
}
func NewInvoiceService(repo InvoiceRepository) *InvoiceService {
return &InvoiceService{repo: repo}
}
func (s *InvoiceService) Finalize(ctx context.Context, id domain.InvoiceID) (domain.Invoice, error) {
inv, err := s.repo.ByID(ctx, id)
if err != nil {
return domain.Invoice{}, fmt.Errorf("finalize %s: %w", id, err)
}
if err := inv.Finalize(); err != nil { // business rule lives on the domain entity
return domain.Invoice{}, fmt.Errorf("finalize %s: %w", id, err)
}
if err := s.repo.Save(ctx, inv); err != nil {
return domain.Invoice{}, fmt.Errorf("finalize %s: %w", id, err)
}
return inv, nil
}

The domain entity owns its rules

Business invariants live on the entity, not in the service. The service orchestrates; the domain decides.

internal/domain/invoice.go
package domain
import "errors"
var ErrAlreadyFinalized = errors.New("invoice already finalized")
type InvoiceID string
type Invoice struct {
ID InvoiceID
Status string
TotalCents int64
}
func (i *Invoice) Finalize() error {
if i.Status == "finalized" {
return ErrAlreadyFinalized
}
i.Status = "finalized"
return nil
}

The handler maps, it does not decide

The handler parses input, calls the service, and maps the result (or a domain error) to an HTTP response. No business logic.

internal/handler/invoice.go
package handler
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/acme/billing/internal/domain"
"github.com/acme/billing/internal/service"
)
type InvoiceHandler struct {
svc *service.InvoiceService
}
func (h *InvoiceHandler) Finalize(c *fiber.Ctx) error {
id := domain.InvoiceID(c.Params("id"))
inv, err := h.svc.Finalize(c.Context(), id)
switch {
case errors.Is(err, domain.ErrNotFound):
return problem(c, fiber.StatusNotFound, "invoice not found")
case errors.Is(err, domain.ErrAlreadyFinalized):
return problem(c, fiber.StatusConflict, "invoice already finalized")
case err != nil:
return problem(c, fiber.StatusInternalServerError, "internal error")
}
return c.Status(fiber.StatusOK).JSON(toResponse(inv))
}

Wire concrete types only in main

// cmd/api/main.go (excerpt)
repo := repository.NewInvoiceRepository(pool) // concrete, takes *pgxpool.Pool
svc := service.NewInvoiceService(repo) // takes the interface
h := handler.NewInvoiceHandler(svc)

This is the only place the concrete repository meets the service. Every test can substitute a fake repository implementing service.InvoiceRepository without a database.

Breaking an apparent circular dependency

When package A seems to need something from B and B seems to need something from A, the fix is almost never a shared third package or a factory-created indirection — it’s recognizing that one side only needs an interface, and that interface belongs in the consumer (the “seam is an interface in the consumer” rule above, applied generally, not just at the service/repository boundary).

// internal/service/pricing.go — PricingService needs invoice totals
package service
// InvoiceLookup is the seam PricingService owns; it does not import
// the invoice service package to get this.
type InvoiceLookup interface {
Total(ctx context.Context, id domain.InvoiceID) (int64, error)
}
type PricingService struct {
invoices InvoiceLookup
}

InvoiceService implements InvoiceLookup without ever importing service/pricing — the dependency only ever points one way, from pricing inward to the interface it defines.

Depth check: services should not chain through services

InvoiceService depending on PricingService depending on TaxService depending on CurrencyService is a chain, not a tree — a sign the layering has drifted, not a natural consequence of business complexity. Most services depend directly on repositories and config; a service-to-service call is the exception, not the default composition tool.

When two services need the same logic, extract it as a plain function or type in domain (or a shared internal package) that both call directly, rather than routing one service’s request through another. A four-deep service chain means four network-equivalent hops of error wrapping and testing surface for what is usually one calculation.

Why this shape

  • Testability — the service is tested with a fake repository; no DB in unit tests (references/testing.md).
  • Swap-ability — pgx today, GORM tomorrow, in-memory for tests: the service never changes.
  • Legibility (ADR-024) — every service in the org has the same four layers, so an engineer moving between repos relearns nothing.