Day 83 — Capstone build 1 (skeleton)

Updated

July 30, 2026

Day 83 — Capstone build 1 (skeleton)

Stage VIII · ~3h · Capstone day 1/6
Goal: Create the capstone module skeleton that builds, tests, and runs a thin vertical spike—layout, tooling scripts, and composition root aligned with CAPSTONE.md.

Note

Day 82 planned. Day 83 makes the plan physical. Resist building Day 88 features. Skeleton + spike only.

Why this day exists

Directories, go.mod, cmd/, lint script, and a “hello, architecture” path mean Days 84–88 never start from zero. Empty theory folders without a runnable binary are not a skeleton—they are procrastination in tree form.


Daily goals by option

Option A — Agent

Done when
Module go 1.26 builds
cmd/agent starts and logs heartbeat via slog
Config load (addr, interval) works
/healthz responds
scripts/lint.sh runs test+vet
CAPSTONE.md milestone 83 checked

Suggested layout:

cmd/agent/main.go
internal/config/
internal/agent/loop.go
internal/api/http.go
scripts/lint.sh
CAPSTONE.md

Spike behavior: ticker logs tick with timestamp; HTTP serves health.

Option B — Microservice

Done when
cmd/service listens
Config + logger wired
One route returns JSON
Package layout matches hexagon/boundaries plan
DB connection optional stub interface ready
lint script green

Suggested layout:

cmd/service/main.go
internal/config/
internal/domain/
internal/port/
internal/app/
internal/adapter/http/
internal/adapter/sqlite/   # may be stub
migrations/                # empty or first no-op

Spike: GET /v1/health + GET /v1/version.

Option C — CLI

Done when
cmd/<name> multi-command skeleton
version and help work
One real command stub (init or run --dry-run)
internal/ logic package with unit test
ldflags version embed
lint script green
cmd/mytool/main.go
internal/cli/
internal/<domain>/
scripts/build-release.sh  # can be stub calling go build

Theory 1 — Composition root first

func main() {
    cfg, err := config.Load(os.Args[1:])
    if err != nil {
        fmt.Fprintf(os.Stderr, "config: %v\n", err)
        os.Exit(2)
    }
    log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: parseLevel(cfg.LogLevel),
    }))
    if err := run(cfg, log); err != nil {
        log.Error("exit", "err", err)
        os.Exit(1)
    }
}

Keep run testable later; avoid business logic in main beyond wiring.

run shape (services)

func run(cfg config.Config, log *slog.Logger) error {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte("ok"))
    })
    mux.HandleFunc("GET /v1/version", func(w http.ResponseWriter, r *http.Request) {
        _ = json.NewEncoder(w).Encode(map[string]string{"version": version})
    })

    srv := &http.Server{Addr: cfg.Addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
    errCh := make(chan error, 1)
    go func() {
        log.Info("listen", "addr", cfg.Addr)
        errCh <- srv.ListenAndServe()
    }()

    select {
    case <-ctx.Done():
        shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        return srv.Shutdown(shCtx)
    case err := <-errCh:
        if errors.Is(err, http.ErrServerClosed) {
            return nil
        }
        return err
    }
}

Full readiness/liveness split can wait—but a clean SIGTERM path early is free insurance.


Theory 2 — Tooling baseline

#!/usr/bin/env bash
# scripts/lint.sh
set -euo pipefail
go test ./...
go vet ./...
command -v staticcheck >/dev/null && staticcheck ./... || echo "staticcheck not installed"
# .gitignore
/bin/
/dist/
*.out
*.pb.gz
.env
*.db
coverage.html

go.mod

go mod init example.com/capstone   # or your VCS path
# ensure:
# go 1.26

Version embedding (CLI and services)

go build -ldflags "-X example.com/capstone/internal/version.Version=dev" -o bin/app ./cmd/...
// internal/version/version.go
package version

var Version = "dev"

Theory 3 — Package boundaries on day one

Do not create fifteen empty packages. Create the minimum that matches CAPSTONE.md:

Need Package
Process entry cmd/...
Config internal/config
Domain types internal/domain (B) or internal/agent (A)
HTTP/CLI surface internal/adapter/http or internal/cli

Ports/interfaces can be one file until a second implementation exists (Day 75/76 wisdom).


Theory 4 — First test exists today

package config_test

import "testing"

func TestLoadRequiresDB(t *testing.T) {
    t.Setenv("DATABASE_URL", "")
    // for option B; adapt for A/C required fields
    _, err := config.Load(nil)
    if err == nil {
        t.Fatal("expected error")
    }
}

CLI variant:

func TestGreet(t *testing.T) {
    if greeter.Greet("Ada") != "hello, Ada" {
        t.Fatal()
    }
}

Agent variant: test that ParseInterval rejects zero.


Lab runbook (all options)

  1. Create module; set go 1.26.
  2. Implement layout for your option.
  3. Runnable spike (health/heartbeat/version).
  4. At least one unit test (even config validate or greeter).
  5. Update CAPSTONE.md checklist for day 83.
  6. Commit with green scripts/lint.sh.

Suggested workspace: ~/lab/90daysofx/01-go/capstone (or repo path in design doc).

Demo to yourself end of day

./scripts/lint.sh
# A/B:
go run ./cmd/...
curl -s localhost:8080/healthz
# C:
go run ./cmd/... version
go run ./cmd/... --help

Makefile optional

.PHONY: test lint run
test:
    go test ./...
lint:
    ./scripts/lint.sh
run:
    go run ./cmd/service

README stub (10 lines)

# Capstone
Option: B
## Dev
go test ./...
go run ./cmd/service
## Config
See CONFIG.md / CAPSTONE.md

Worked example — Option A heartbeat loop

func Loop(ctx context.Context, log *slog.Logger, every time.Duration) {
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case now := <-t.C:
            log.Info("tick", "at", now.UTC().Format(time.RFC3339))
        }
    }
}

Worked example — Option C command router

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: mytool <command>")
        os.Exit(2)
    }
    switch os.Args[1] {
    case "version":
        fmt.Println(version.Version)
    case "help", "--help", "-h":
        fmt.Println("commands: version, init, help")
    case "init":
        if err := cmdInit(os.Args[2:]); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    default:
        fmt.Fprintln(os.Stderr, "unknown command")
        os.Exit(2)
    }
}

Hour-by-hour suggestion

Time Focus
0:00–0:30 Module + dirs from CAPSTONE.md
0:30–1:20 main + config + logger
1:20–2:00 Spike route/command
2:00–2:30 lint script + first test
2:30–3:00 README stub + milestone check

Common gotchas

Gotcha Fix
Building features from day 88 Stop; skeleton only
No module path chosen Use example.com/capstone or your VCS path
Skipping tests “until later” One test today
Diverging from CAPSTONE.md Update doc if plan changed
Copy-paste entire old service blindly Import patterns, not mess
go 1.22 in go.mod by habit Set go 1.26 for this volume
Fifteen empty packages Create on demand
Global flag.Parse in library FlagSet in config.Load

Checkpoint

  • Module builds on Go 1.26
  • Spike runs
  • lint script exists and passes
  • Layout matches design
  • At least one unit test
  • Milestone 83 marked done

Commit

git add .
git commit -m "day83: capstone skeleton and spike"

Write three personal gotchas before continuing.


Tomorrow

Day 84 — Capstone build 2: core domain logic—the primary use case without caring about perfect polish.