Layering: handler -> service -> repository -> domain
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/layering.md |
| Description | Not 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 wiringImport-direction rule
handlermay importserviceanddomain.servicemay importdomainand the repository interfaces it defines.repositorymay importdomainonly.domainimports nothing fromhandler,service, orrepository.
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.
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.
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.
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.Poolsvc := service.NewInvoiceService(repo) // takes the interfaceh := 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 totalspackage 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.