Day 26 — Architecture: Boundaries & Hexagonal Design
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.
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
- Program using kubeconfig that lists pods (or deployments) in a namespace.
- Flags:
--namespace, optional label selector.
- Clear error if no cluster (
ELECTIVE.mdcan include “ran against kind/minikube”).
- No full operator / CRD work unless already expert and still time-boxed.
- 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 defaultTrack 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 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).
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.mdrecords 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 reasonReturn *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:
- Move shared entities/errors to
domain(orappif only one consumer layer).
- Wire concretes only in
cmd/*/main.go.
- Define interfaces where used; implement in
store.
- Never let
domainimport 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.
- List packages:
go list ./...
- Draw current package diagram (ASCII) in
BOUNDARIES.md.
- Mark cycles, god packages, and “main does too much.”
- 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 → domainLab 2 — Extract consumer interface + adapter
- Introduce interface at consumer (
appor handler).
- Move SQL (or filesystem) details into
store/ adapter package.
- Return concrete
*SQLiteUserfrom constructor.
- Compile; fix import cycles by moving types to
domain.
go build ./...Lab 3 — Fake + unit test without DB
- Implement in-memory fake of the consumer interface.
- Table-test one use case: happy path + invalid input + not found.
- Ensure test file does not import
database/sqldrivers.
go test ./internal/app -count=1 -vLab 4 — Self-review questions
Answer in BOUNDARIES.md:
- Can
domain/appcompile without importingnet/http?
- Can you swap store implementation without editing handlers?
- Are interfaces small enough to implement in <30 lines for a fake?
- Does
mainown all wiring?
- Any remaining
internal/utils?
# crude check: app should not import net/http
go list -f '{{.ImportPath}} {{.Imports}}' ./internal/appLab 5 — Optional second boundary
If time remains:
- Extract a
ClockorIDGeninterface 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.
- 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.