Day 76 — Hexagonal architecture lite

Updated

July 30, 2026

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.

  1. Create HEXAGON.md with ASCII diagram using your package names.
  2. Label each package: driving / app / domain / driven / root.
  3. 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.go

Lab 2 — One full use case path

  1. Ensure domain types + errors have no framework imports.
  2. Implement or refine one use case (CreateX / RenameX / agent collect).
  3. Driven adapter: SQLite/Postgres or mem for lab, but interface stable.
  4. Driving adapter: HTTP or CLI calling the same Exec.
  5. go test ./internal/app with fakes—no Docker required.
go test ./internal/app -count=1
go test ./... -count=1

Lab 3 — Keep observability at the edges

  1. Metrics/tracing stay in middleware or adapters—not on domain entities.
  2. If you inject a Metrics port, keep it tiny (Inc(name string)) or prefer middleware.
  3. Log domain outcomes at the adapter (err mapping), not with HTTP jargon inside app.
// good: middleware increments counter around handler
// avoid: domain.User carrying prometheus.Counter fields

Lab 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/app

Record 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/http and driver imports
  • One use case with port interface
  • SQLite/Postgres (or mem) driven adapter
  • HTTP (or CLI) driving adapter
  • Composition root wires concretes
  • HEXAGON.md map 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.