Day 76 — Hexagonal architecture lite
Day 76 — Hexagonal architecture lite
Stage VIII · ~3h
Goal: Apply a lite hexagonal (ports & adapters) layout to your service: domain at the center, driving adapters (HTTP/CLI), driven adapters (DB/metrics), and wiring only at the composition root. Go 1.26.
Why this day exists
Day 75 established interface direction. Hexagonal architecture (ports & adapters) names the roles:
- Driving (primary) adapters — HTTP, gRPC, CLI, tests that call use cases
- Application / domain — business rules
- Driven (secondary) adapters — DB, email, object storage, third-party APIs
“Lite” means no annotation frameworks, no 12-layer pure fantasy—just package discipline that Go teams actually keep. Capstone design (Day 82) will assume you can point at a hexagon map of your code.
Theory 1 — Hexagon diagram (ASCII)
+----------------------+
| HTTP / CLI / gRPC | driving (primary)
| adapters |
+----------+-----------+
|
v
+----------------------+
| application |
| (use cases) |
+----------+-----------+
|
+----------v-----------+
| domain + ports |
| (types, interfaces) |
+----------+-----------+
|
+----------v-----------+
| SQL / mail / queue | driven (secondary)
| adapters |
+----------------------+
Dependencies point toward the center. Adapters depend on ports; domain does not depend on adapters.
Vocabulary cheat sheet
| Term | Meaning in this volume |
|---|---|
| Port | Interface describing a need (e.g. UserRepository) |
| Adapter | Concrete implementation (SQLite repo, HTTP handler) |
| Driving | Calls into the app (inbound) |
| Driven | Called by the app (outbound) |
| Composition root | main (or small wire package) that constructs graph |
Theory 2 — Ports as interfaces
package port
import (
"context"
"time"
"example.com/svc/internal/domain"
)
type UserRepository interface {
FindByID(ctx context.Context, id string) (domain.User, error)
Save(ctx context.Context, u domain.User) error
}
type Clock interface {
Now() time.Time
}
type IDGen interface {
New() string
}Application service (use case object):
package app
type CreateUser struct {
Users port.UserRepository
IDs port.IDGen
Clock port.Clock
}
type CreateUserInput struct {
Name string
}
func (c CreateUser) Exec(ctx context.Context, in CreateUserInput) (domain.User, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return domain.User{}, domain.ErrInvalidName
}
u := domain.User{
ID: c.IDs.New(),
Name: name,
CreatedAt: c.Clock.Now().UTC(),
}
if err := c.Users.Save(ctx, u); err != nil {
return domain.User{}, err
}
return u, nil
}Domain stays free of database/sql, net/http, and Prometheus.
package domain
import "time"
type User struct {
ID string
Name string
CreatedAt time.Time
}
var (
ErrInvalidName = errors.New("invalid name")
ErrNotFound = errors.New("not found")
)Theory 3 — Driven adapter (SQLite)
package sqlite
type UserRepo struct{ db *sql.DB }
func NewUserRepo(db *sql.DB) *UserRepo { return &UserRepo{db: db} }
func (r *UserRepo) Save(ctx context.Context, u domain.User) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO users(id, name, created_at) VALUES(?,?,?)`,
u.ID, u.Name, u.CreatedAt.UTC(),
)
return err
}
func (r *UserRepo) FindByID(ctx context.Context, id string) (domain.User, error) {
var u domain.User
var ts string
err := r.db.QueryRowContext(ctx,
`SELECT id, name, created_at FROM users WHERE id = ?`, id,
).Scan(&u.ID, &u.Name, &ts)
if errors.Is(err, sql.ErrNoRows) {
return domain.User{}, domain.ErrNotFound
}
if err != nil {
return domain.User{}, err
}
// parse ts → u.CreatedAt
return u, nil
}Notice: adapter maps storage shape ↔︎ domain. Domain never sees SQL null quirks if you encapsulate them here.
Theory 4 — Driving adapter (HTTP)
package httpapi
type Handler struct {
Create app.CreateUser
Log *slog.Logger
}
type createUserDTO struct {
Name string `json:"name"`
}
func (h Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var dto createUserDTO
if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
writeErr(w, errInvalidJSON)
return
}
u, err := h.Create.Exec(r.Context(), app.CreateUserInput{Name: dto.Name})
if err != nil {
writeDomainErr(w, err) // maps domain.ErrInvalidName → 400, etc.
return
}
writeJSON(w, http.StatusCreated, userDTO{ID: u.ID, Name: u.Name})
}Rule: HTTP status codes live at the adapter edge—not inside CreateUser.Exec.
Theory 5 — Composition root
// cmd/service/main.go
func main() {
cfg := config.Load()
db := mustOpen(cfg.DSN)
defer db.Close()
repo := sqlite.NewUserRepo(db)
uc := app.CreateUser{
Users: repo,
IDs: ulidGen{},
Clock: realClock{},
}
h := httpapi.Handler{Create: uc, Log: slog.Default()}
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/users", h.CreateUser)
srv := &http.Server{Addr: cfg.Addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}Only here do frameworks and drivers meet. Tests of app never import sqlite or httpapi.
Test double clock/id
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
type seqID struct{ n int }
func (s *seqID) New() string { s.n++; return fmt.Sprintf("id-%d", s.n) }Theory 6 — How much is enough?
| Scale | Recommendation |
|---|---|
| Tiny CLI | Day 75 boundaries only |
| Service with DB + HTTP | Lite hexagon (today) |
| Multi-team monorepo | Stricter ports packages + docs |
| Capstone (Days 82+) | Lite hexagon as default layout |
Avoid:
- Interface for every struct
- DTO mapping layers thicker than the business logic
- Premature event-bus cores
- “Pure domain” that cannot validate a string without three packages
Ports package vs consumer interfaces
| Style | When |
|---|---|
internal/port |
Multiple driving adapters share use cases |
Interfaces in app |
Single service binary, simpler graph |
Either is fine. Document your choice in HEXAGON.md.
Worked example — folder layout
cmd/service/main.go
internal/domain/
user.go
errors.go
internal/port/
user_repo.go
clock.go
internal/app/
create_user.go
create_user_test.go
internal/adapter/sqlite/
user_repo.go
internal/adapter/http/
handler.go
routes.go
errors.go
Alternative flatter names (internal/store, internal/httpapi) are OK if the roles stay clear.
Lab 1 — Document the hexagon
Continue the Day 75 module.
- Create
HEXAGON.mdwith ASCII diagram using your package names.
- Label each package: driving / app / domain / driven / root.
- List ports (interfaces) and adapters (implementations).
# HEXAGON.md
## Diagram
(paste ASCII)
## Ports
- port.UserRepository → adapter/sqlite.UserRepo
## Driving
- adapter/http → app.CreateUser
## Composition root
- cmd/service/main.goLab 2 — One full use case path
- Ensure domain types + errors have no framework imports.
- Implement or refine one use case (
CreateX/RenameX/ agent collect).
- Driven adapter: SQLite/Postgres or mem for lab, but interface stable.
- Driving adapter: HTTP or CLI calling the same
Exec.
go test ./internal/appwith fakes—no Docker required.
go test ./internal/app -count=1
go test ./... -count=1Lab 3 — Keep observability at the edges
- Metrics/tracing stay in middleware or adapters—not on domain entities.
- If you inject a
Metricsport, keep it tiny (Inc(name string)) or prefer middleware.
- Log domain outcomes at the adapter (
errmapping), not with HTTP jargon inside app.
// good: middleware increments counter around handler
// avoid: domain.User carrying prometheus.Counter fieldsLab 4 — Optional second driving adapter
Prove the hexagon: call the same use case from a tiny CLI:
// cmd/admin/main.go (sketch)
uc := app.CreateUser{Users: repo, IDs: ulidGen{}, Clock: realClock{}}
u, err := uc.Exec(ctx, app.CreateUserInput{Name: *name})If CLI is out of scope, a _test.go in app is already a driving adapter—state that in HEXAGON.md.
Lab 5 — Import smoke checks
# domain should not import net/http or driver
go list -f '{{.ImportPath}}: {{.Imports}}' ./internal/domain
# app should not import database drivers
go list -f '{{.ImportPath}}: {{.Imports}}' ./internal/appRecord pass/fail in HEXAGON.md.
Hour-by-hour suggestion
| Time | Focus |
|---|---|
| 0:00–0:25 | HEXAGON.md diagram + package labels |
| 0:25–1:20 | Align domain/port/app folders |
| 1:20–2:10 | Wire one use case end-to-end |
| 2:10–2:40 | Fake tests + import checks |
| 2:40–3:00 | Second driver optional; commit |
Common gotchas
| Gotcha | Fix |
|---|---|
| Domain imports driver | Move types; adapters map columns |
God port package |
Split only when it hurts |
| Use case knows HTTP status codes | Map errors at adapter edge |
| Over-abstract Clock/IDGen | OK for testability; don’t abstract string |
| Rewriting all features | One vertical slice end-to-end |
| DTO confusion | JSON DTOs in httpapi; domain types inward |
| “Hexagonal” folder rename only | Behavior + tests must move with names |
| Composition logic in handlers | Move construction to main |
Checkpoint
- Domain free of
net/httpand driver imports
- One use case with port interface
- SQLite/Postgres (or mem) driven adapter
- HTTP (or CLI) driving adapter
- Composition root wires concretes
HEXAGON.mdmap with real package names
go test ./...green on Go 1.26
Commit
git add .
git commit -m "day76: hexagonal lite ports adapters"Write three personal gotchas before continuing.
Tomorrow
Day 77 — Config layering: flags, environment, and files layered like a 12-factor app without chaos—config stays an adapter concern, not a domain dependency web.