concept
Go Layered Architecture
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
| Layer | Responsibility | Calls into | Called by |
|---|---|---|---|
| Handler (a.k.a. Controller) | Parse HTTP request, validate, marshal response | Service | HTTP router |
| Service | Business logic, orchestration across repos | Repository | Handler |
| Repository | DB queries, persistence I/O | DB driver / ORM | Service (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
Txinterface that both the Repository and Service understand - Unit-of-Work pattern: the Service starts a tx, hands it to Repositories explicitly
- Just leak
*gorm.DBto Service for the rare write paths and accept the impurity
- Pass a
- 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
| Pattern | Boundary | Notes |
|---|---|---|
| Layered (this) | Layer = horizontal slice (HTTP / logic / DB) | Easy to teach; weak on transactions |
| Clean / Hexagonal | Domain at center; ports + adapters | Stronger boundaries; more ceremony |
| DDD | Bounded contexts + aggregates | Required for complex domains; overkill for CRUD |
| MVC | Model / View / Controller | Web-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.
Related
- functional-options-pattern — Go constructor idiom for shared
pkg/clients - mockery, gin, gorm — the canonical toolkit
- go-layered-architecture-2026 — the source article