Day 84 — Capstone build 2 (core domain)

Updated

July 30, 2026

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.

Note

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

  1. Open CAPSTONE.md; re-read primary user story.
  2. Implement domain entities and sentinels under internal/domain (or option-appropriate package).
  3. Reject empty/invalid inputs at the use-case boundary.
  4. go build ./...

Lab 2 — Primary use case / collector / plan

  1. Implement the single most important behavior.
  2. Keep HTTP/CLI thin—call into app / library.
  3. Use context.Context on anything that may later block (I/O).
  4. 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

  1. Fake the driven port (mem repo / mock collector / buffer FS).
  2. Cover happy path + invalid + not found (or conflict).
  3. Ensure tests do not require root, Docker, or network unless tagged.
go test ./internal/... -count=1

Lab 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 ./testdata

Lab 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.