Day 84 — Capstone build 2 (core domain)
Day 84 — Capstone build 2 (core domain)
Stage VIII · ~3h · Capstone day 2/6
Goal: Implement the primary domain capability end-to-end in memory or with a minimal adapter—table-tested, interface-bound, still thin on operability polish. Go 1.26.
Why this day exists
Day 83 built the skeleton. Skeletons do not demo. Today you build the heart:
| Option | Heart |
|---|---|
| A | Collect and model host/process observations |
| B | Core domain use case (create/get resource, business rules) |
| C | Core command logic (transform, analyze, apply) |
UI/HTTP chrome can be incomplete; rules and types must exist. Day 85 will swap in durable adapters—do not block core logic on SQLite polish today.
Vertical slice > horizontal sprawl. One use case complete (domain + port + fake + test + thin adapter) beats five empty DTO files.
Daily goals by option
Option A — Agent core
| Goal | Detail |
|---|---|
| Sampler | Collect ≥1 signal (process count, load avg, Docker ps if available, or /proc on Linux) |
| Model | Typed structs for samples |
| Store | Ring buffer / in-mem history of last N samples |
| Test | Pure functions parse/format without HTTP |
| API hook | Optional: GET /v1/snapshot returns latest sample JSON |
| Safety | Dangerous actions out (no kill -9 by default) |
type Snapshot struct {
TS time.Time `json:"ts"`
NumProcs int `json:"num_procs"`
GoRoutines int `json:"go_routines"`
Load1 float64 `json:"load1,omitempty"`
}
type Collector interface {
Collect(ctx context.Context) (Snapshot, error)
}
type Store interface {
Append(Snapshot) error
Latest() (Snapshot, bool)
LastN(n int) []Snapshot
}Option B — Microservice core
| Goal | Detail |
|---|---|
| Domain types | Entities + validation errors |
| Use case | Create + Get (or List) for primary resource |
| Port | Repository interface |
| Fake repo | Unit tests for rules |
| HTTP | Wire create/get even if still in-mem adapter |
Example rules: unique names, non-empty fields, state transitions (loan only if available).
func (s CreateBook) Exec(ctx context.Context, in CreateBookInput) (Book, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return Book{}, domain.ErrInvalid
}
b := Book{ID: s.IDs.New(), Title: title, CreatedAt: s.Clock.Now().UTC()}
if err := s.Books.Save(ctx, b); err != nil {
return Book{}, err
}
return b, nil
}Option C — CLI core
| Goal | Detail |
|---|---|
| Library package | ~80% of logic outside main |
| Primary command | Real work (parse file, call API, plan migration, etc.) |
| Tests | Table tests for pure functions |
| Errors | Typed/sentinel errors mapped to exit codes |
| Dry-run | --dry-run if command mutates |
type Input struct {
Paths []string
DryRun bool
}
type Plan struct {
DryRun bool
Steps []Step
}
func BuildPlan(in Input) (Plan, error) { /* pure-ish */ }Theory 1 — Vertical slice over horizontal layers
Prefer:
one use case complete
domain types
+ port interface
+ fake implementation
+ table tests
+ thin driving adapter (HTTP/CLI)
over:
all DTOs defined, zero behavior
all packages created, empty files
Update CAPSTONE.md milestone 84 only when the slice runs.
Theory 2 — Error model early
Define semantics now even if HTTP mapping is thin:
package domain
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrInvalid = errors.New("invalid")
ErrConflict = errors.New("conflict")
)Map at edges later (HTTP status / CLI exit) on Day 86 if needed—but call sites should already use errors.Is.
if errors.Is(err, domain.ErrNotFound) {
// 404 or exit 2
}Avoid stringly errors for core paths (errors.New("not found: "+id) as the only type)—wrap if you need detail:
fmt.Errorf("%w: id=%s", domain.ErrNotFound, id)Theory 3 — Ports for testability
Even with in-memory data, name the port you will implement with SQLite tomorrow:
// Option B
type BookRepository interface {
Save(ctx context.Context, b Book) error
Find(ctx context.Context, id string) (Book, error)
}
// Option A
type Collector interface {
Collect(ctx context.Context) (Snapshot, error)
}
// Option C
type FS interface {
ReadFile(name string) ([]byte, error)
// or accept io.Reader at boundaries
}Day 85 should be an adapter swap, not a rewrite of rules.
Theory 4 — What “done” looks like tonight
| Signal | Pass |
|---|---|
go test ./internal/... |
Green with happy + ≥2 error paths |
| Manual invoke | One curl or CLI command shows core behavior |
| CAPSTONE.md | Milestone 84 checked; leftovers listed |
| No new product scope | Still matches Day 82 design |
Operability (metrics, fancy shutdown) can wait for Day 87—unless skeleton already has health.
Worked example — table test for a use case (B)
func TestCreateBook(t *testing.T) {
tests := []struct {
name string
title string
wantErr error
}{
{"ok", "Learning Go", nil},
{"empty", " ", domain.ErrInvalid},
{"conflict", "dup", domain.ErrConflict},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := memBooks{}
if tt.name == "conflict" {
_ = repo.Save(context.Background(), Book{ID: "1", Title: "dup"})
}
uc := app.CreateBook{Books: repo, IDs: seqID{}, Clock: fixedClock{t: time.Unix(0, 0)}}
_, err := uc.Exec(context.Background(), app.CreateBookInput{Title: tt.title})
if !errors.Is(err, tt.wantErr) {
t.Fatalf("got %v want %v", err, tt.wantErr)
}
})
}
}Option A — pure parse helper
func parseLoadAvg(line string) (float64, error) {
fields := strings.Fields(line)
if len(fields) < 1 {
return 0, fmt.Errorf("%w: bad loadavg", domain.ErrInvalid)
}
return strconv.ParseFloat(fields[0], 64)
}Test with fixture strings so CI never needs /proc:
func TestParseLoadAvg(t *testing.T) {
v, err := parseLoadAvg("0.52 0.58 0.59 1/234 12345")
if err != nil || v < 0.5 || v > 0.6 {
t.Fatalf("%v %v", v, err)
}
}Option C — table-driven pure core
func TestBuildPlan(t *testing.T) {
got, err := BuildPlan(Input{DryRun: true, Paths: []string{"a"}})
if err != nil || !got.DryRun || len(got.Steps) == 0 {
t.Fatalf("%+v %v", got, err)
}
}Worked example — in-memory ring store (A)
type Ring struct {
mu sync.Mutex
buf []Snapshot
size int
next int
full bool
}
func NewRing(n int) *Ring {
return &Ring{buf: make([]Snapshot, n), size: n}
}
func (r *Ring) Append(s Snapshot) error {
r.mu.Lock()
defer r.mu.Unlock()
r.buf[r.next] = s
r.next = (r.next + 1) % r.size
if r.next == 0 {
r.full = true
}
return nil
}Unit-test append/latest without starting the agent loop.
Lab 1 — Domain types + errors
- Open
CAPSTONE.md; re-read primary user story.
- Implement domain entities and sentinels under
internal/domain(or option-appropriate package).
- Reject empty/invalid inputs at the use-case boundary.
go build ./...
Lab 2 — Primary use case / collector / plan
- Implement the single most important behavior.
- Keep HTTP/CLI thin—call into
app/ library.
- Use
context.Contexton anything that may later block (I/O).
- No unbounded goroutine loops without cancel (agent ticker must observe
ctx).
// agent loop sketch
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
snap, err := collector.Collect(ctx)
// append / log
_ = snap
_ = err
}
}Lab 3 — Fake + table tests
- Fake the driven port (mem repo / mock collector / buffer FS).
- Cover happy path + invalid + not found (or conflict).
- Ensure tests do not require root, Docker, or network unless tagged.
go test ./internal/... -count=1Lab 4 — Thin adapter spike
| Option | Spike |
|---|---|
| A | GET /v1/snapshot or log line on tick |
| B | POST/GET /v1/<resource> against mem repo |
| C | mytool plan --dry-run prints steps |
# examples — adapt to your binary
curl -s localhost:8080/v1/snapshot | jq .
go run ./cmd/service &
# or
go run ./cmd/mytool plan --dry-run ./testdataLab 5 — CAPSTONE.md update
## Milestone 84 — core domain
- [x] Primary behavior implemented
- [x] Tests: happy + 2 errors
- [x] Port named for Day 85 adapter
- Leftovers: ...List explicitly what Day 85 must replace (mem → SQLite, mock collector → /proc, etc.).
Hour-by-hour suggestion
| Time | Focus |
|---|---|
| 0:00–0:30 | Domain types + errors |
| 0:30–1:30 | Primary use case / collector / plan |
| 1:30–2:20 | Fake + table tests |
| 2:20–2:50 | Thin adapter to prove manually |
| 2:50–3:00 | CAPSTONE.md update |
Common gotchas
| Gotcha | Fix |
|---|---|
| HTTP-only logic | Move to internal/app |
| No tests because “DB later” | Fake repository |
| Scope explosion (5 entities) | One entity done well |
| Agent that needs root/host mounts | Document; mock collector in tests |
| CLI calling network without timeout | context.WithTimeout |
| Unexported everything untestable | Export funcs or test from same package |
| Business rules in SQL strings | Rules in app; SQL only persists |
| Skipping error sentinels | Define ErrInvalid / ErrNotFound today |
| Feature creep from Day 82 non-goals | Re-read design non-goals |
Checkpoint
- Primary domain behavior implemented
- Table/unit tests green (happy + ≥2 errors)
- Port/interface for driven dependency
- Manual demo path works
- CAPSTONE milestone 84 done
- Day 85 leftovers listed
go test ./...on Go 1.26
Commit
git add .
git commit -m "day84: capstone core domain"Write three personal gotchas before continuing.
Tomorrow
Day 85 — Capstone build 3: persistence and real adapters—SQLite/files/APIs that survive restart. Keep today’s tests green by swapping implementations behind ports.