Day 26 — Architecture: Boundaries & Hexagonal Design

Updated

July 30, 2025

Day 26 — Architecture: Boundaries & Hexagonal Design

Day 73 — Elective: k8s client-go or deepen

Stage VII · ~3h
Goal: Choose one track and finish a concrete deliverable—either a minimal Kubernetes client program or a deliberate depth upgrade to your Stage VI service—without starting a second project.

Note

An elective forces decision hygiene: pick, time-box, ship, document. Half of both tracks is a fail. One complete track is a win.

Why this day exists

Stage VII ends soon. Real careers diverge:

  • Platform / control-plane engineers touch client-go
  • Product engineers deepen domain services (reliability, APIs, data)

Capstone options map roughly:

Capstone Elective lean
A agent client-go or deepen observability
B microservice deepen service
C CLI deepen CLI packaging / k8s plugin stretch

Tomorrow’s Stage VII gate expects evidence of ship quality—not a half-built operator framework.


Decision (do this first — 10 minutes)

Write in ELECTIVE.md:

## Choice
Track: A-k8s | B-deepen
Why (3 bullets):
Out of scope today:
Success criterion:
Cluster/tooling available? (kind/minikube/none):

If undecided: B-deepen is the safer default for Stage VIII architecture days.

Decision timer

Clock Action
0–10 min Choose and write ELECTIVE.md
If still stuck Default B
If Track A setup >45 min Switch to B; note why

Track A — Kubernetes client-go (light)

Theory A1 — What client-go is

k8s.io/client-go is the official Go client for the Kubernetes API:

  • Typed clients for core resources (Pods, Deployments, …)
  • Informers/listers for efficient watches (advanced)
  • Works with kubeconfig (local) or in-cluster config

You will not build an operator framework today. Goal: list/get something real and print a useful summary.

Your program → client-go → kube-apiserver → etcd (cluster)
                 ↑
           kubeconfig or in-cluster SA token

Theory A2 — Config resolution

import (
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
)

func newClient() (*kubernetes.Clientset, error) {
    loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
    configOverrides := &clientcmd.ConfigOverrides{}
    kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
    cfg, err := kubeConfig.ClientConfig()
    if err != nil {
        return nil, err
    }
    return kubernetes.NewForConfig(cfg)
}

In-cluster (when the binary runs as a Pod):

import "k8s.io/client-go/rest"

cfg, err := rest.InClusterConfig()
if err != nil {
    return nil, err
}
return kubernetes.NewForConfig(cfg)

Prefer kubeconfig for the lab laptop; mention in-cluster in notes only.

Theory A3 — List pods example

ctx := context.Background()
cs, err := newClient()
if err != nil {
    log.Fatal(err)
}
ns := flagNamespace // from --namespace
pods, err := cs.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
    LabelSelector: labelSelector, // optional
})
if err != nil {
    log.Fatal(err)
}
for _, p := range pods.Items {
    fmt.Printf("%s\t%s\t%s\n", p.Namespace, p.Name, p.Status.Phase)
}

Modules are large—use a recent compatible set and follow upstream versioning notes for your cluster version. Pin versions in go.mod and commit go.sum.

Track A lab output

  1. Program using kubeconfig that lists pods (or deployments) in a namespace.
  2. Flags: --namespace, optional label selector.
  3. Clear error if no cluster (ELECTIVE.md can include “ran against kind/minikube”).
  4. No full operator / CRD work unless already expert and still time-boxed.
  5. At least one unit test with fake clientset (even if you never reach a real cluster).
# example local cluster
# kind create cluster
go run ./cmd/klist --namespace default

Track A — fake clientset unit test (no cluster)

import (
    "context"
    "testing"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes/fake"
)

func TestListPods(t *testing.T) {
    cs := fake.NewSimpleClientset(&corev1.Pod{
        ObjectMeta: metav1.ObjectMeta{Name: "p1", Namespace: "default"},
    })
    pods, err := cs.CoreV1().Pods("default").List(context.Background(), metav1.ListOptions{})
    if err != nil {
        t.Fatal(err)
    }
    if len(pods.Items) != 1 || pods.Items[0].Name != "p1" {
        t.Fatalf("got %#v", pods.Items)
    }
}

Useful when kind install is blocked; still practice client-go types.

RBAC awareness (do not ignore)

Listing pods requires permission. On managed clusters you may lack access—use kind local admin, or stick to fake clientset + documented limitation in ELECTIVE.md.


Track B — Deepen the service

Theory B1 — Depth menu (pick 1–2 only)

Depth Deliverable
Reliability Retries with jitter on outbound calls; timeouts on all I/O
API Pagination + consistent error JSON (Day 53–55 revisit)
Data One more entity + migration; transaction boundary
Observability Finish metrics+traces on the hottest path
Performance Close a Day 65–66 loop in the service, not a toy
Packaging Harden Dockerfile + Makefile release targets

Theory B2 — Quality bar for “deepen”

Deepen means merged improvement with tests, not refactors for their own sake.

Checklist:

  • User-visible or operator-visible benefit
  • Test or manual script proving it
  • NOTES linking to Stage VII tools used
  • No new microservice spawned

Example deepen: timeouts everywhere

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
row := s.db.QueryRowContext(ctx, "SELECT id, name FROM items WHERE id = ?", id)

Example deepen: idempotency key on create

Document header Idempotency-Key behavior; store key for 24h; return same resource on replay.

key := r.Header.Get("Idempotency-Key")
if key != "" {
    if cached, ok := s.idem.Get(key); ok {
        writeJSON(w, http.StatusOK, cached)
        return
    }
}
// create… then s.idem.Put(key, result)

Example deepen: retries with jitter

func withRetry(ctx context.Context, attempts int, fn func(context.Context) error) error {
    var err error
    for i := 0; i < attempts; i++ {
        if err = fn(ctx); err == nil {
            return nil
        }
        // simple backoff; prefer full jitter in real systems
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(time.Duration(50*(i+1)) * time.Millisecond):
        }
    }
    return err
}

Only retry idempotent operations (or those with idempotency keys).


Shared lab rules (both tracks)

Suggested workspace: continue existing module or ~/lab/90daysofx/01-go/day73

  1. Time-box: if Track A cluster setup exceeds 45 minutes, switch to Track B.
  2. One PR/commit story: single coherent change set.
  3. Update ELECTIVE.md with commands and result.
  4. Do not start the capstone early—save integration ambition for Days 82–88.
  5. Leave Stage VII gate inputs stable: container, metrics, govulncheck path still work.

Worked example — deepen timeouts (Track B)

// before: unbounded
resp, err := http.Get(url)

// after
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
    return err
}
resp, err := client.Do(req)

Document in ELECTIVE.md the before/after failure mode (hung handler).

Worked example — Track A CLI shape

func main() {
    ns := flag.String("namespace", "default", "Kubernetes namespace")
    sel := flag.String("selector", "", "label selector")
    flag.Parse()

    cs, err := newClient()
    if err != nil {
        fmt.Fprintf(os.Stderr, "kubeconfig: %v\n", err)
        os.Exit(2)
    }
    if err := listPods(context.Background(), cs, *ns, *sel); err != nil {
        fmt.Fprintf(os.Stderr, "list: %v\n", err)
        os.Exit(1)
    }
}

Hour-by-hour suggestion

Time Focus
0:00–0:10 Write ELECTIVE.md choice
0:10–2:10 Execute one track
2:10–2:40 Tests/demo commands
2:40–3:00 Notes + commit readiness for gate

Common gotchas

Gotcha Fix
Both tracks half-done Stop; finish one
client-go version hell Match a tutorial set of module versions
Deepen as endless rewrite Cap scope in ELECTIVE.md first
Needing production cluster kind/k3d/minikube or fake clientset
Touching everything before gate Gate is tomorrow—prefer stability
New module for elective Prefer improving existing service unless A needs isolation
Operator envy List/get only; controllers are multi-week
Deepen without proof Script or test must show delta

Checkpoint

  • ELECTIVE.md records choice + why
  • One track deliverable runs
  • Tests or demo commands documented
  • Explicit non-goals listed
  • Ready for Stage VII gate evidence pack
  • Did not start capstone early

Commit

git add .
git commit -m "day73: elective k8s or deepen"

Write three personal gotchas before continuing.


Tomorrow

Day 74 — Stage VII gate: assemble the release-candidate checklist—profiled, scanned, containerized, lint-clean.


Day 75 — Package boundaries

Stage VIII · ~3h
Goal: Refactor toward clear package boundaries using the Go proverb accept interfaces, return structs—with dependency direction that tests can fake without mocks-everywhere frameworks. Baseline Go 1.26.

Why this day exists

Stage VIII is architecture and capstone. Spaghetti main + god packages will fight every remaining day. Good boundaries give you:

  • Unit tests without a database
  • Swappable adapters (HTTP, CLI, gRPC)
  • Readable import graphs
  • Safer refactors before Days 82–90

This is not enterprise ceremony—it is how maintainable Go is written at scale. Day 76 will name the same ideas as hexagonal lite; today you draw the lines and move real code.


Theory 1 — The import graph is the architecture

cmd/app          → wires everything (main only)
internal/httpapi → HTTP adapters (driving)
internal/app     → use cases / services
internal/domain  → entities + small shared types/errors
internal/store   → implements ports (Postgres/SQLite)

Rule: dependencies point inward toward domain/policy, not outward toward frameworks.

# good direction
httpapi → app → domain
store   → domain   (implements interfaces defined in app or domain)

# bad direction
domain → httpapi
domain → database/sql driver details
app    → net/http status codes (prefer mapping at edge)

internal/ keeps packages private to the module (Day 9 revisited with teeth). Anything under internal/ cannot be imported by external modules—good API surface control.

Visualize before refactoring

# optional: go install golang.org/x/tools/cmd/package-repository... 
# or simply:
go list -f '{{.ImportPath}} -> {{.Imports}}' ./...

Hand-draw ASCII in BOUNDARIES.md first—tools help later.


Theory 2 — Accept interfaces, return structs

// consumer defines the small interface it needs
package app

type UserStore interface {
    Find(ctx context.Context, id string) (User, error)
    Save(ctx context.Context, u User) error
}

type Service struct {
    users UserStore // interface field
}

func NewService(users UserStore) *Service {
    return &Service{users: users}
}

func (s *Service) Rename(ctx context.Context, id, name string) (User, error) {
    name = strings.TrimSpace(name)
    if name == "" {
        return User{}, ErrInvalid
    }
    u, err := s.users.Find(ctx, id)
    if err != nil {
        return User{}, err
    }
    u.Name = name
    if err := s.users.Save(ctx, u); err != nil {
        return User{}, err
    }
    return u, nil // return concrete struct, not an interface
}
// producer returns concrete type
package store

type Postgres struct{ db *sql.DB }

func NewPostgres(db *sql.DB) *Postgres { return &Postgres{db: db} }

func (p *Postgres) Find(ctx context.Context, id string) (app.User, error) {
    // QueryRowContext ...
    return app.User{}, nil
}

func (p *Postgres) Save(ctx context.Context, u app.User) error {
    // ExecContext ...
    return nil
}
Do Don’t
Interfaces at consumer Giant interfaces.go nobody owns
Small interfaces (1–3 methods) IUserRepository with 40 methods
Return concrete structs Returning interfaces “for flexibility” by default
*Service constructors Global mutable stores
Pass context.Context first Hidden timeouts in globals

Why “return structs”?

Returning interfaces from constructors forces every caller to only see the interface, hides useful methods, complicates debugging, and often indicates speculative abstraction. Return *Postgres; let the consumer decide the interface width.


Theory 3 — Where interfaces live

Two valid styles in modern Go:

A) Consumer-side (preferred default)

Interfaces in app / package that uses them. Multiple consumers may define tiny overlapping interfaces (Go structural typing).

B) Port package

domain or port package holds interfaces when multiple adapters exist—leads into Day 76 hexagonal.

Anti-pattern: interface + impl only for “hiding”

// anti-pattern: interface + impl in same package only for testing
type Store interface { ... }
type store struct { ... }
func NewStore() Store { return &store{} } // hides type for no reason

Return *store or *Postgres; let tests invent fakes implementing the consumer interface.


Theory 4 — Fakes over mocks

type memUsers map[string]User

func (m memUsers) Find(ctx context.Context, id string) (User, error) {
    u, ok := m[id]
    if !ok {
        return User{}, ErrNotFound
    }
    return u, nil
}

func (m memUsers) Save(ctx context.Context, u User) error {
    m[u.ID] = u
    return nil
}

Table-test the service with memUsers. Reserve heavy mock codegen for rare cases (complex interaction order verification). Day 17 skills apply directly.

func TestService_Rename(t *testing.T) {
    users := memUsers{"1": {ID: "1", Name: "Ada"}}
    svc := NewService(users)
    got, err := svc.Rename(context.Background(), "1", "Ada Lovelace")
    if err != nil {
        t.Fatal(err)
    }
    if got.Name != "Ada Lovelace" {
        t.Fatalf("got %q", got.Name)
    }
}

Theory 5 — Circular imports and how to break them

Classic cycle:

httpapi → store → httpapi   # DTO types lived in httpapi
app → store → app           # shared User type owned by store

Fixes:

  1. Move shared entities/errors to domain (or app if only one consumer layer).
  2. Wire concretes only in cmd/*/main.go.
  3. Define interfaces where used; implement in store.
  4. Never let domain import adapters.
// domain/user.go
package domain

type User struct {
    ID   string
    Name string
}

var ErrNotFound = errors.New("not found")

Adapters map columns ↔︎ domain; handlers map domain ↔︎ JSON DTOs.


Theory 6 — Package naming and junk drawers

Bad Better
internal/utils internal/clock, internal/idgen, or stdlib
internal/common name by capability
internal/helpers absorb into caller package
models + controllers domain + httpapi + app (Go style)

Names should say what the package owns, not “misc.”


Worked example — boundary refactor sketch

Before:

main.go  // sql + http + business rules mixed

After:

cmd/service/main.go
internal/domain/user.go
internal/domain/errors.go
internal/app/user_service.go
internal/app/user_service_test.go
internal/store/sqlite_user.go
internal/httpapi/user_handler.go
internal/httpapi/routes.go
// cmd/service/main.go — composition root
func main() {
    cfg := config.Load()
    db := mustOpen(cfg.DSN)
    defer db.Close()

    repo := store.NewSQLiteUser(db)
    svc := app.NewService(repo)
    h := httpapi.New(svc)

    srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes(), ReadHeaderTimeout: 5 * time.Second}
    log.Fatal(srv.ListenAndServe())
}

Only main knows about concrete SQLite + HTTP.


Lab 1 — Map current boundaries

Suggested workspace: Stage VI/VII service (preferred) or a trimmed module.

  1. List packages: go list ./...
  2. Draw current package diagram (ASCII) in BOUNDARIES.md.
  3. Mark cycles, god packages, and “main does too much.”
  4. Pick one vertical seam to fix today (e.g. item create).
# BOUNDARIES.md

## Before
cmd/service → everything
internal/httpapi → database/sql directly

## After (target)
cmd/service → store, app, httpapi
httpapi → app
app → domain (interfaces in app)
store → domain

Lab 2 — Extract consumer interface + adapter

  1. Introduce interface at consumer (app or handler).
  2. Move SQL (or filesystem) details into store / adapter package.
  3. Return concrete *SQLiteUser from constructor.
  4. Compile; fix import cycles by moving types to domain.
go build ./...

Lab 3 — Fake + unit test without DB

  1. Implement in-memory fake of the consumer interface.
  2. Table-test one use case: happy path + invalid input + not found.
  3. Ensure test file does not import database/sql drivers.
go test ./internal/app -count=1 -v

Lab 4 — Self-review questions

Answer in BOUNDARIES.md:

  • Can domain/app compile without importing net/http?
  • Can you swap store implementation without editing handlers?
  • Are interfaces small enough to implement in <30 lines for a fake?
  • Does main own all wiring?
  • Any remaining internal/utils?
# crude check: app should not import net/http
go list -f '{{.ImportPath}} {{.Imports}}' ./internal/app

Lab 5 — Optional second boundary

If time remains:

  • Extract a Clock or IDGen interface for deterministic tests, or
  • Split a second use case behind the same store port

Do not rewrite the entire service—depth over breadth.


Hour-by-hour suggestion

Time Focus
0:00–0:30 BOUNDARIES.md before diagram
0:30–1:30 Extract interface + move SQL/HTTP details
1:30–2:20 Fake + table tests
2:20–2:45 Cycle fixes + self-review
2:45–3:00 After diagram; commit

Common gotchas

Gotcha Fix
Circular imports Move shared types to domain; wire in main
Interface pollution Delete unused methods; split interfaces
internal/utils junk drawer Name by domain capability
Returning interfaces from constructors Return structs
Testing through HTTP only Unit-test app with fakes
One giant Store interface Per-consumer smaller interfaces
Moving files without updating tests go test ./... after each seam
Over-extracting on day one One vertical slice end-to-end

Checkpoint

  • ASCII/package map written (before + after)
  • Consumer interface introduced
  • Concrete adapter implements it
  • Fake-based unit test green without DB
  • No import cycles
  • go test ./... pass on Go 1.26
  • Self-review questions answered

Commit

git add .
git commit -m "day75: package boundaries accept interfaces"

Write three personal gotchas before continuing.


Tomorrow

Day 76 — Hexagonal lite: explicit ports and adapters so the domain stays free of frameworks—building on today’s dependency direction.


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.