Day 85 — Capstone build 3 (persistence & adapters)

Updated

July 30, 2026

Day 85 — Capstone build 3 (persistence & adapters)

Stage VIII · ~3h · Capstone day 3/6
Goal: Replace in-memory stubs with durable adapters—database, files, or external APIs—plus migrations or schema bootstrap, without breaking Day 84 domain tests. Go 1.26.

Why this day exists

Demos that lose state on restart feel fake. Driven adapters prove hexagonal wiring (Day 76) under real I/O. Today is adapter day, not rewrite-the-domain day: ports stay stable; implementations get real.

Note

Keep Day 84 fake-based unit tests green. Add integration tests beside them—do not delete fakes “because SQL exists now.”


Daily goals by option

Option A — Agent

Goal Detail
Durability Append samples to SQLite or JSONL file
Collector real OS-backed collector; build tags if needed (//go:build linux)
Fallback Mock collector for tests/CI
Config Paths, sample interval, retention N
Safety Read-only observation default

JSONL is acceptable:

func (s *FileStore) Append(snap Snapshot) error {
    f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
    if err != nil {
        return err
    }
    defer f.Close()
    return json.NewEncoder(f).Encode(snap)
}

Option B — Microservice

Goal Detail
Schema Migration SQL or embed bootstrap
Repository SQLite or Postgres implementing port
Context All queries *Context
Pool SetMaxOpenConns sensible defaults
Test Integration with :memory: or temp file DB
//go:embed schema.sql
var schema string

func Migrate(ctx context.Context, db *sql.DB) error {
    _, err := db.ExecContext(ctx, schema)
    return err
}

Option C — CLI

Goal Detail
Workspace Config/project file on disk (./.mytool.yaml or XDG)
State Local DB or file store for indexes/cache
Idempotent writes Safe re-run
Tests t.TempDir() fixtures
Migration Version field in state file

Theory 1 — Adapter testing strategy

Layer Test
Domain/app Fake ports (Day 84)—fast, always on
SQL adapter Integration with real driver
Files Temp dirs (t.TempDir())
func TestUserRepo_SQLite(t *testing.T) {
    db, err := sql.Open("sqlite", ":memory:")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = db.Close() })
    if err := Migrate(context.Background(), db); err != nil {
        t.Fatal(err)
    }
    repo := NewUserRepo(db)
    // Save + FindByID assertions
}

Pure-Go SQLite drivers ease CGO_ENABLED=0 containers (Day 68/88).


Theory 2 — Failure modes to handle

Failure Map to
sql.ErrNoRows domain.ErrNotFound
Unique constraint domain.ErrConflict
Context deadline return ctx.Err() / wrap
Disk permission surface as internal; log path
Partial multi-row write transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()
// multiple ExecContext on tx ...
return tx.Commit()

Always use placeholders—Day 81 rule still applies when you write SQL by hand today.


Theory 3 — Pool and DSN hygiene

db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
Do Don’t
DSN from env Commit passwords
File SQLite for restart demos :memory: as only gate path
Ping on startup before ready Mark ready before migrate
# example SQLite DSN
file:capstone.db?_pragma=foreign_keys(1)

Theory 4 — Build tags for OS-specific collectors (A)

// collector_linux.go
//go:build linux

package agent

func readLoadAvg() (float64, error) { /* /proc/loadavg */ }
// collector_stub.go
//go:build !linux

package agent

func readLoadAvg() (float64, error) {
    return 0, errUnsupported
}

CI on macOS can still unit-test parse helpers with fixtures (Day 84).


Worked example — SQLite repository (option B)

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().Format(time.RFC3339Nano),
    )
    // map unique violations → domain.ErrConflict for your driver
    return err
}

func (r *UserRepo) FindByID(ctx context.Context, id string) (domain.User, error) {
    var u domain.User
    var created string
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name, created_at FROM users WHERE id = ?`, id,
    ).Scan(&u.ID, &u.Name, &created)
    if errors.Is(err, sql.ErrNoRows) {
        return domain.User{}, domain.ErrNotFound
    }
    if err != nil {
        return domain.User{}, err
    }
    t, err := time.Parse(time.RFC3339Nano, created)
    if err != nil {
        return domain.User{}, err
    }
    u.CreatedAt = t
    return u, nil
}

Wire in main only:

db := mustOpen(cfg.DatabaseURL)
if err := sqlite.Migrate(ctx, db); err != nil {
    log.Fatal(err)
}
repo := sqlite.NewUserRepo(db)
uc := app.CreateUser{Users: repo, IDs: ids, Clock: clock}

Worked example — CLI state file (option C)

type State struct {
    Version int               `json:"version"`
    Entries map[string]string `json:"entries"`
}

func LoadState(path string) (State, error) {
    b, err := os.ReadFile(path)
    if errors.Is(err, os.ErrNotExist) {
        return State{Version: 1, Entries: map[string]string{}}, nil
    }
    // unmarshal ...
}

func SaveState(path string, st State) error {
    tmp := path + ".tmp"
    // write tmp, fsync, rename — idempotent-ish
}

Tests use filepath.Join(t.TempDir(), "state.json").


Lab 1 — Schema / bootstrap path

  1. Write schema SQL or state file version.
  2. Automate apply on startup or migrate / init command.
  3. Document in README.
  4. Fail fast if migrate fails (do not serve ready).

Lab 2 — Implement durable adapter

  1. Implement port methods with Context.
  2. Map driver errors to domain sentinels.
  3. Keep domain free of database/sql imports.
  4. go test ./internal/app still passes with fakes.

Lab 3 — Integration test

  1. SQLite :memory: or temp file.
  2. Or t.TempDir() for file/JSONL adapters.
  3. Round-trip Save/Find or Append/Latest.
  4. Optional conflict test.
go test ./internal/adapter/... -count=1
go test ./... -count=1

Lab 4 — Restart persistence proof

# B example
curl -sf -X POST localhost:8080/v1/books -H 'Content-Type: application/json' \
  -d '{"title":"Go"}' | tee /tmp/created.json
# kill -TERM service; start again with same DSN
curl -sf localhost:8080/v1/books/$(jq -r .id /tmp/created.json)
Option Proof
A Samples present after restart (file/SQLite)
B Entity GET after restart
C State file survives process exit

Record commands in CAPSTONE.md or PERSISTENCE.md.


Lab 5 — Composition root + CAPSTONE

  1. Wire concrete adapter only in cmd/*/main.go.
  2. Config: DSN/path from env/flags.
  3. Check milestone 85; list Day 86 surface leftovers.
## Milestone 85 — persistence
- [x] Durable adapter
- [x] Migrate/bootstrap
- [x] Restart proof
- Leftovers: ...

Hour-by-hour suggestion

Time Focus
0:00–0:20 Schema + migrate path
0:20–1:20 Repository / file methods + error mapping
1:20–2:00 Integration test
2:00–2:40 Wire composition root; restart proof
2:40–3:00 CAPSTONE.md; lint

Common gotchas

Gotcha Fix
CGO sqlite breaks distroless Pure Go driver or glibc base
Migrations only in README Automate on boot or migrate cmd
Domain imports database/sql Keep in adapter
Leaking open files defer Close; integration test
Absolute paths in tests t.TempDir()
Timezones in DB Store UTC; document format
Forgetting indexes Add PK/unique needed for conflicts
Replacing fakes entirely Keep unit tests on fakes
:memory: only File DSN for restart demo

Checkpoint

  • Durable adapter implemented
  • Schema/migration path exists
  • Restart proof documented
  • Integration or file test green
  • Day 84 unit tests still green
  • Milestone 85 done
  • Go 1.26 go test ./... pass

Commit

git add .
git commit -m "day85: capstone persistence adapters"

Write three personal gotchas before continuing.


Tomorrow

Day 86 — Capstone build 4: finish the external surface—HTTP API completeness or CLI UX polish users touch—mapped onto today’s durable ports.