Go Layered Architecture
Opinionated, pragmatic guide for building Go applications using a layered architecture inspired by hexagonal (ports and adapters) principles. This is not a pure hexagonal implementation — it intentionally flattens the ports-and-adapters model into a straightforward layered structure where dependencies flow inward toward the domain core. The goal is clarity and navigability over architectural purity: each layer has clear responsibilities and boundaries without the extra indirection that strict hexagonal architecture introduces.
Dependency Flow
Interface (CLI/TUI/Web) → Service → Domain ← Storage
↑
Adapters (external APIs)
- Domain defines interfaces (ports). Storage and external client adapters implement them.
- Services depend on domain interfaces, never on concrete storage or external client packages.
- Interface layers depend on services, never on storage or domain internals.
Directory Structure
project/
├── cmd/
│ ├── cli/ # urfave/cli entry point
│ ├── tui/ # bubbletea entry point
│ └── web/ # chi router entry point
├── internal/
│ ├── domain/ # Entities, value objects, repository + client interfaces
│ ├── service/ # Business logic, validation, orchestration
│ ├── storage/ # Repository implementations (wraps sqlc)
│ │ └── db/ # sqlc-generated code (do not edit)
│ ├── <client>/ # External API adapters (e.g., bgg/, stripe/)
│ ├── cli/ # CLI command handlers
│ ├── tui/ # Bubbletea models and views
│ └── web/ # Chi handlers, middleware, templates
├── db/
│ ├── migrations/ # goose SQL migrations
│ └── queries/ # sqlc SQL query files
├── sqlc.yml
└── go.mod
Domain Layer
The domain layer is the innermost ring. It has zero external dependencies — no framework imports, no struct tags, no database concerns. Include a doc.go with a glossary of key business concepts so that new contributors (human or agent) can understand the domain language.
Entities
Pure Go structs representing business concepts:
type Boardgame struct {
ID int64
Name string
Players Optional[Range]
Complexity Optional[uint8]
Description string
}
- No
json,db, oryamlstruct tags - Use
Optional[T]for nullable fields instead of pointers or sql.Null types - Fields use domain-appropriate types, not database types
Value Objects
Enforce invariants through construction:
type Range struct {
min uint16 // private fields
max uint16
}
func Between(min, max uint16) (Range, error) {
if min == 0 || max == 0 {
return Range{}, errors.New("min and max must be greater than zero")
}
if max < min {
return Range{}, errors.New("max must be >= min")
}
return Range{min: min, max: max}, nil
}
- Private fields enforce invariants — values are always valid after construction
- Accessor methods expose data:
Min(),Max(),IsSingle() - Constructor functions return
(T, error)when validation is needed
Optional Type
Generic wrapper replacing *T and sql.NullXxx in the domain:
type Optional[T any] struct {
value T
ok bool
}
func Some[T any](v T) Optional[T] { return Optional[T]{value: v, ok: true} }
func None[T any]() Optional[T] { return Optional[T]{} }
func (o Optional[T]) IsSet() bool { return o.ok }
func (o Optional[T]) Value() T { return o.value }
func (o Optional[T]) OrElse(v T) T { if o.ok { return o.value }; return v }
Repository Interfaces
Defined in domain — not at point of consumption. Repository interfaces are the shared contract between service (consumer) and storage (implementor). Both layers depend inward on domain; neither should import the other. Moving these to the service package would force Storage → Service, breaking the dependency flow.
type BoardgameRepository interface {
Create(ctx context.Context, game Boardgame) (Boardgame, error)
GetByID(ctx context.Context, id int64) (Boardgame, error)
List(ctx context.Context) ([]Boardgame, error)
}
Createreturns the entity with server-generated fields (ID) populated- Keep interfaces narrow (2-4 methods) — this makes hand-written test fakes trivial
- Test doubles are hand-written inline in test files, not generated (see
references/testing.md)
External Client Interfaces
The same dependency inversion pattern applies to any external dependency the service needs — not just databases. When a service calls an external API (e.g., BoardGameGeek, Stripe, a weather service), define the interface in domain using domain types. The external client lives behind an adapter that translates between the API's types and domain types.
// internal/domain/game_lookup.go
// GameLookup searches an external catalog for boardgame metadata.
// The service depends on this interface; the BGG adapter implements it.
type GameLookup interface {
SearchGames(ctx context.Context, query string) ([]Boardgame, error)
GetGameDetails(ctx context.Context, externalID string) (Boardgame, error)
}
The adapter wraps the external client and translates its types into domain types — the same pattern as storage repositories:
// internal/bgg/adapter.go
type Adapter struct {
client *bgg.Client
}
func (a *Adapter) SearchGames(ctx context.Context, query string) ([]domain.Boardgame, error) {
results, err := a.client.Search(ctx, query)
if err != nil {
return nil, fmt.Errorf("searching BGG for %q: %w", query, err)
}
games := make([]domain.Boardgame, len(results))
for i, r := range results {
games[i] = gameFromBGGResult(r)
}
return games, nil
}
Without this pattern, the service imports the external client's types directly, coupling business logic to a specific API. The adapter keeps the service testable (fake the domain interface) and swappable (replace BGG with another catalog).
Service Layer
Services contain business logic, validation, and multi-repository orchestration. They depend only on domain interfaces.
type BoardGameService struct {
logger *slog.Logger
repo domain.BoardgameRepository
}
func NewBoardGameService(repo domain.BoardgameRepository, logger *slog.Logger) *BoardGameService {
return &BoardGameService{repo: repo, logger: logger}
}
Responsibilities
- Input validation — verify business rules before delegating to storage
- Cross-repository coordination — look up related entities, enforce consistency
- Error context — return meaningful errors, not raw database errors
- Logging — structured logging with slog for observability
What services must NOT do
- Import any framework package (urfave, chi, bubbletea)
- Know about HTTP, CLI flags, or UI concerns
- Import sqlc-generated types or database/sql
- Import external client packages directly (use domain interfaces + adapters)
- Handle serialization (JSON, HTML, etc.)
Storage Layer
Implements domain repository interfaces by wrapping sqlc-generated code. This is the only layer that knows about database types.
Translation Pattern
Every repository method translates between domain.Entity and db.Entity:
func (r *BoardgameRepository) GetByID(ctx context.Context, id int64) (domain.Boardgame, error) {
result, err := r.queries.GetBoardgameByID(ctx, id)
if err != nil {
return domain.Boardgame{}, err
}
return gameFromResult(result), nil
}
Helper functions handle type mapping:
sql.NullInt64↔domain.Optional[T]sql.NullString↔domain.Optional[string]- Paired nullable columns ↔
domain.Optional[domain.Range]
See references/database.md for complete sqlc workflow and translation patterns.
Interface Layer
Each interface (CLI, TUI, Web) is a thin adapter. It parses input, calls services, and formats output. It never contains business logic.
Interface Segregation
Handlers define narrow interfaces at point of consumption — in the handler file or its test file, not in a shared package:
// internal/web/handlers/boardgame_handler.go
type BoardGameService interface {
ListGames(context.Context) ([]domain.Boardgame, error)
FindGame(context.Context, int64) (domain.Boardgame, error)
AddGame(ctx context.Context, bg domain.Boardgame) (domain.Boardgame, error)
}
This enables focused testing — fakes only need to implement the methods the handler actually uses.
Why this differs from repository interfaces: Handler → Service is a one-directional dependency — no third party implements the interface across a layer boundary. Repository interfaces live in domain because they're a shared contract between service (consumer) and storage (implementor), enabling dependency inversion. Service interfaces for handlers have no such constraint.
Error Handling by Layer
| Layer | Pattern |
|---|---|
| Domain | Return error from constructors when invariants fail |
| Service | Validate inputs, wrap errors with identifying context: fmt.Errorf("adding session for game %d: %w", gameID, err) |
| Storage | Wrap database errors with operation and entity context: fmt.Errorf("getting game %d: %w", id, err) |
| Web | Map errors to HTTP status codes (400/404/500), log with slog.Error |
| CLI | Map errors to user-facing messages and exit codes |
Go Conventions
Standards that apply across all layers for consistency and correctness.
Comments
Write comments that explain why, not what. Never describe what code does — agents and humans can read the code. Instead, document:
- Why this approach was chosen over alternatives
- What constraints or invariants the code operates under
- What assumptions are safe to make
- Intentional duplication (explain why, or agents will refactor it into a shared abstraction)
// Bad — describes what the code does
// GetOrder retrieves an order by ID from the database
// Good — explains the design decision
// GetOrder retrieves an order and its full item tree. Items are denormalized
// at placement time because menu item IDs are unstable across published
// versions — we cannot look them up later.
Package Documentation
Every package should have a doc.go explaining its purpose, key concepts, and important design decisions. Domain packages should include a glossary of business terms:
// Package domain defines the core business entities for a boardgame tracker.
//
// Glossary:
// - Boardgame: a tabletop game with metadata (player count, complexity)
// - Session: a recorded play of a boardgame with players and date
// - Play: alias for Session in user-facing contexts
package domain
Error Handling
Always wrap errors with enough context to trace causation — include relevant IDs and values, not just the operation name:
// Good — an agent or operator can trace this immediately
return domain.Session{}, fmt.Errorf(
"creating session for game %d with %d players: %w", gameID, len(players), err)
// Good — includes the conflicting value
return domain.Boardgame{}, fmt.Errorf(
"game name %q already exists: %w", game.Name, err)
// Bad — bare error with no context for debugging
return domain.Review{}, err
// Bad — operation name only, no identifying information
return domain.Review{}, fmt.Errorf("adding review: %w", err)
Each layer adds its own context. A fully wrapped error reads as a chain of operations:
creating session for game 42 with 3 players: inserting session: UNIQUE constraint failed
└── service └── repository └── database
Greppability
Use unique, specific names that can be found with a single search. Prefer GameStatusUnplayed over Unplayed, OrderMatchScore over Score. If a function is called, there must be a greppable call site — avoid string-based dispatch and dynamic method resolution.
Slice Return Conventions
Known size: Use make([]T, len(results)) with index assignment — avoids repeated grow/copy from append:
games := make([]domain.Boardgame, len(results))
for i, r := range results {
games[i] = gameFromResult(r)
}
return games, nil
Unknown size: Use make([]T, 0) with append — signals intent to build up incrementally:
reviews := make([]domain.Review, 0)
for _, r := range results {
if r.Rating > 3 {
reviews = append(reviews, reviewFromResult(r))
}
}
return reviews, nil
On error: Return nil for the slice — signals the result is meaningless and should not be used:
if err != nil {
return nil, fmt.Errorf("listing reviews for game %d: %w", gameID, err)
}
| Scenario | Slice value | Error value |
|---|---|---|
| Success, known size | make([]T, len(n)) |
nil |
| Success, unknown size | make([]T, 0) + append |
nil |
| Error | nil |
fmt.Errorf("context: %w", err) |
| Struct + error | zero value T{} |
fmt.Errorf("context: %w", err) |
Configuration
Use koanf with layered precedence (lowest to highest):
- Hardcoded defaults
- Config file (
~/.config/<app>/config.yaml) - Environment variables (
APPNAME_*) - CLI flags (CLI interface only)
k := koanf.New(".")
k.Load(confmap.Provider(defaults, "."), nil)
k.Load(env.Provider("ROLLCALL_", ".", transformKey), nil)
return Config{Addr: k.String("addr"), DBPath: k.String("db_path")}
Critical rule: Load config in cmd/*/main.go, extract into a plain struct, pass values to internal layers. Never pass the koanf instance deeper than main.
Logging
Use slog (standard library) throughout:
- Create logger in
cmd/*/main.go - Inject via constructor:
NewService(repo, logger) - Use structured key-value pairs:
slog.Warn("parse failed", "id", id, "error", err) - Configure format (text vs JSON) per interface
Adding a New Feature
- Define domain entities in
internal/domain/(pure Go structs) - Create migration in
db/migrations/ - Write SQL queries in
db/queries/with sqlc annotations - Add repository interface to
internal/domain/ - Run
go generate ./...(generates sqlc code) - Implement repository in
internal/storage/(wrap sqlc, translate types) - Write repository integration tests with
:memory:SQLite - Implement business logic in
internal/service/ - Add handlers in
internal/cli/,internal/tui/, orinternal/web/ - Define narrow service interface at point of consumption (handler or handler test file)
- Write handler tests with hand-written inline fakes for the service interface
- Write service unit tests only if business logic is complex enough to warrant isolation from handler tests
- Wire dependencies in
cmd/*/main.go
Code Generation
go generate ./... # Runs sqlc
sqlc generate # SQL queries only
All generated code lives in internal/storage/db/ and must never be manually edited.
Reference Files
references/packages.md— Detailed usage patterns for each package (urfave/cli, chi, bubbletea, koanf, slog, templ, HTMX)references/testing.md— Testing strategy, hand-written fakes, testify usage, handler-first test structurereferences/database.md— sqlc workflow, goose migrations, repository translation patterns, SQL conventions
Consult reference files for package-specific syntax and detailed examples.
Converted and distributed by TomeVault — claim your Tome and manage your conversions.