concept

Go Layered Architecture

created 2026-05-05 go · architecture · layered-architecture · patterns · backend

Go Layered Architecture

A 3-layer Handler → Service → Repository structure for Go HTTP services, borrowed from Spring Boot’s MVC pattern but kept Go-idiomatic via interfaces and embedding.

The three layers

LayerResponsibilityCalls intoCalled by
Handler (a.k.a. Controller)Parse HTTP request, validate, marshal responseServiceHTTP router
ServiceBusiness logic, orchestration across reposRepositoryHandler
RepositoryDB queries, persistence I/ODB driver / ORMService (only)

Hard rule: Repository methods are called from and only from Service. Handlers never touch a Repository directly. Service never touches the HTTP request object.

Interfaces, not concrete pointers

Every layer is built on interfaces. The “wiring” (NewHandler, NewService, NewRepository) returns the interface; concrete struct types stay private (lowercase). This is what makes mockery generation viable and unit tests trivial.

type Handlers interface {
    User
    Article
}

type handlers struct {
    srv services.Services
}

func NewHandler() Handlers {
    return &handlers{srv: services.NewServices()}
}

The User and Article types are themselves interfaces — Go’s embedding lets you compose a root Handlers from per-domain sub-interfaces without building one giant fat interface.

Method signature convention

Every layer method returns (responseDTO, error). Two values, predictable, error-on-the-right. Internal types (DB rows) get translated to DTOs at the Service ↔ Repository boundary.

Where it breaks

  • Transactions across multiple repositories: with each Repository receiving its own *gorm.DB, the Service has no way to wrap a multi-call transaction without leaking the DB type upward. The article calls this out and admits Go has no clean answer (no equivalent to Spring’s @Transactional). Common workarounds:
    • Pass a Tx interface that both the Repository and Service understand
    • Unit-of-Work pattern: the Service starts a tx, hands it to Repositories explicitly
    • Just leak *gorm.DB to Service for the rare write paths and accept the impurity
  • Single root interface per layer scales poorly in larger codebases — every new feature touches Handlers / Services / Repository. For 10+ bounded contexts, prefer per-context root interfaces.

When this pattern fits

✅ Greenfield Go HTTP service with a relational DB and < 10 bounded contexts ✅ Team coming from Spring/.NET that values familiarity over Go idioms ✅ Heavy unit-test discipline (interfaces are the price of admission)

❌ Event-driven / async-heavy systems (this is sync-HTTP-shaped) ❌ Microservices where each is < 500 lines (overkill) ❌ Pure CLIs (Vault‘s shape) — no HTTP layer to gate

How it compares

PatternBoundaryNotes
Layered (this)Layer = horizontal slice (HTTP / logic / DB)Easy to teach; weak on transactions
Clean / HexagonalDomain at center; ports + adaptersStronger boundaries; more ceremony
DDDBounded contexts + aggregatesRequired for complex domains; overkill for CRUD
MVCModel / View / ControllerWeb-server cousin; Service layer often missing

Relevance to kulify

  • katastar is the natural place to apply or audit this. If logic currently lives in handlers, refactoring during the multi-user auth pass is a good moment.
  • New Go services inside kulify (e.g., a future Hermes write-side, a SB sync daemon) should default to this shape unless event-driven from day one.
  • Vault is a CLI — pattern doesn’t apply directly today, but if a daemon mode is added, Service + Repository become relevant.