Day 85 — Capstone build 3 (persistence & adapters)
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.
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)
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
- Write schema SQL or state file version.
- Automate apply on startup or
migrate/initcommand.
- Document in README.
- Fail fast if migrate fails (do not serve ready).
Lab 2 — Implement durable adapter
- Implement port methods with
Context.
- Map driver errors to domain sentinels.
- Keep domain free of
database/sqlimports.
go test ./internal/appstill passes with fakes.
Lab 3 — Integration test
- SQLite
:memory:or temp file.
- Or
t.TempDir()for file/JSONL adapters.
- Round-trip Save/Find or Append/Latest.
- Optional conflict test.
go test ./internal/adapter/... -count=1
go test ./... -count=1Lab 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
- Wire concrete adapter only in
cmd/*/main.go.
- Config: DSN/path from env/flags.
- 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.