Day 83 — Capstone build 1 (skeleton)
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.
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.26Version 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)
- Create module; set
go 1.26.
- Implement layout for your option.
- Runnable spike (health/heartbeat/version).
- At least one unit test (even config validate or greeter).
- Update
CAPSTONE.mdchecklist for day 83.
- 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/... --helpMakefile 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.mdWorked 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.