template/CLAUDE.md
djmil c9ab7854cb add flow controls to the pkg/result
Combinators for composing Expect-returning calls into a single expression.
2026-08-05 21:18:50 +00:00

14 KiB

Project overview

Go 1.25 template (developed on the 1.26 toolchain) for PoC, hobby projects, and small publishable packages. Stack: structured logging (slog), config (flag), consumer-defined interfaces + manual fakes, result type (happy-path error handling), linting (golangci-lint), security scanning (gosec, govulncheck).

Key constraint: go.mod stays free of dev tool deps (tools are pinned in tools.versions and run via go run tool@version) so packages published from this repo have a clean module graph for consumers.


Design philosophy

Form over mechanism — call sites express what the code is doing, not how it achieves it. Mechanism belongs in constructors and infrastructure; intent belongs at the call site. When you see fmt.Fprintln(os.Stderr, ...) or if err != nil { return } scattered through business logic, that is mechanism leaking into intent — move it to the edges.

The Twelve-Factor App is the primary reference for default implementation choices. Follow its recommendations unless circumstances force a deviation — and document the reason when you do.

Key factors that shape this codebase most directly:

  • Logs (factor XI) — the app writes log events to stderr as an unbuffered stream. It never opens log files or manages rotation. The execution environment (shell, systemd, Docker, k8s) routes and stores the stream. Human-readable program output goes to stdout (fmt.Print*).
  • Config (factor III) — all configuration comes from the environment (flags in this template; env vars are the 12-factor ideal). No hard-coded values in logic packages.
  • Dependencies (factor II) — explicit declaration; dev tools are pinned in tools.versions and kept out of go.mod so published packages have a clean module graph.

Project structure

cmd/app/                 CLI entrypoint: parses flags, wires dependencies, and expresses
                         what the program does in high-level readable steps — calls internal/
                         and pkg/ packages; no domain logic lives here
internal/                domain-specific packages; logical grouping of substantial code into
                         digestible types and interfaces; not importable outside this module
pkg/                     generic, publishable packages reusable across projects; no
                         assumptions about the calling application
tools.versions           Pinned tool versions (sourced by Makefile and pre-push hook)
.golangci.yml            Linter rules
.githooks/pre-push       Runs gofmt + go vet + golangci-lint + gosec before push

Project rules

  • Module imports — always use the full module path from go.mod (never relative imports)
  • Packagescmd/ owns CLI parsing, dependency wiring, and high-level orchestration; domain logic belongs in internal/
  • Types — expose concrete types from constructors (New(...) *Type); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free)
  • Errorspkg/result is a convenience tool for removing error-threading clutter from application logic; use it as follows:
    • pkg/ libraries only return result.Expect[T] — never call .Expect(), .Must(), or .Expectf() inside library code; those methods exit the goroutine via runtime.Goexit and are only safe in application-layer code protected by a boundary
    • application code (cmd/, HTTP handlers, etc.) chains .Expect("context") freely — each call exits the goroutine on failure and is caught at the entry point
    • top-level entry points defer result.Catch(&err) (or use result.Run(...)) to convert any result exit into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked
    • result.Catch is incompatible with -tags result_goexit: it relies on recover() which cannot intercept runtime.Goexit; prefer result.Run/result.Go which work in both builds
    • bridge existing (T, error) stdlib/third-party calls with result.Of(...): result.Of(os.ReadFile("cfg.json")).Expect("read config")
    • construct failures with these constructors, each a distinct intent — don't invent more:
      • result.Ok[T](v) / result.Err[T](err) — the two field constructors: a success value, or a bare error boxed verbatim (use Err for return zero, err and sentinels)
      • result.Failf[T]("msg")originate a failure from a message; embed a cause with %w (result.Failf[T]("load config: %w", err))
      • result.Wrap[U](r, "msg")propagate an already-failed Expect into a new type U, optionally adding context; only valid on a failed result (panics on a success), so guard with if r.Err() != nil. Use this instead of pulling the error out with r.Err() by hand
    • use result.StackTrace(err) to retrieve the capture-site stack from a caught error
    • still use fmt.Errorf("context: %w", err) when wrapping errors before constructing a result.Err
  • Flow controlpkg/result/flow.go holds combinators over Expect[T]; error handling is not written, it emerges from the operations chosen:
    • a computation that can fail at several steps reads as one expression, with no if err != nil between them: parsePort(raw).Filter(unprivileged, "privileged port").Or(fromConfigFile).UnwrapOr(8080)
    • a failure short-circuits: no later closure runs and no zero value is carried onward, until something explicitly handles it (Or, OrElse, UnwrapOr, UnwrapOrElse) or the boundary collects it (pinned by TestFlowShortCircuits)
    • combinators never Goexit, so unlike .Expect()/.Must() they are allowed inside pkg/ library code
    • a chain holds only while T holds. Go has no type parameters on methods, so Expect.Map/Expect.AndThen are the same-type (chaining) forms and result.Map(r, f)/result.AndThen(r, f) are the type-changing ones — the chain breaks at the crossing and resumes on the result
    • name a step and declare it before the chain when it is reused, or when its body would bury the pipeline — the name is then the documentation and the call site reads as a sentence. But a one-shot closure with a single call site stays where it is used: hoisting a single-use one-liner out adds a name without adding structure, and only forces the reader to jump elsewhere
    • Filter(pred, "msg") can name the rejected value given a binding — r.Filter(pred, "port %d bad", r.Value()) (the eager arg is harmless: the message is formatted only on rejection). Mid-chain there is no binding, so use AndThen + Failf there
    • there is deliberately no Option/Maybe type: a nil error means the value is valid, default-constructed or not, so a second wrapper would add a distinction the codebase does not want
  • Logging — logs go to stderr per 12-factor XI; human output goes to stdout via fmt.Print*. Use logger.NewCLI(level, debugFile) for CLI apps: auto-detects TTY → human text on terminal, JSON when piped. Use logger.New(level) for headless services that always want JSON. Passing a non-empty debugFile enables debug mode: INFO/WARN/ERROR on screen plus a full JSON DEBUG trace to file for post-run investigation. Use log.WithField("key", val) for structured context; never fmt.Sprintf in log messages
  • Config — all configuration parsed in cmd/app/config.go (flags); no hard-coded values in internal/ or pkg/ packages

Code style

  • Follow gofmt + goimports formatting (enforced by linter and git hook)
  • Imports: stdlib → blank line → external → blank line → internal (goimports handles this)
  • Error variables: err for local, ErrFoo for package-level sentinels
  • Constructors: New(deps...) *Type pattern
  • Comment every exported symbol (golangci-lint will warn if missing)
  • Max line length: 120 chars (configured in .golangci.yml)

Testing rules

  • Tests use only the standard testing package — no third-party assertion libraries
  • Test files: package foo_test (black-box) unless white-box access is needed
  • Fake dependencies with manual fakes (implement the interface inline in _test.go)
  • Use logger.NewNop() when the test doesn't care about log output
  • Use logger.NewWriter(&buf, "debug") in acceptance / integration tests that need to assert on log content
  • Table-driven tests with t.Run("description", ...) for multiple cases
  • The race detector is enabled in CI (make test-race); don't introduce data races
  • Never use time.Sleep in tests; use channels or t.Cleanup
  • Use gitea.djmil.dev/go/template/pkg/check helpers instead of manual checks — Ok, OkNotNil for result.Expect[T]; NoError, Error, ErrorContains accept either an error or an Expect[T]; Equal, NotEqual, DeepEqual, ElementsMatch for plain values

Development commands

make init          # first-time setup: fetch deps, configure git hooks
make tools         # install tool binaries to GOPATH/bin (versions from tools.versions)
make build         # compile to ./bin/app
make run           # go run with default flags
make test          # run all tests
make test-race     # tests + race detector
make lint          # go vet + golangci-lint
make lint-fix      # go fix + golangci-lint auto-fix
make security      # gosec + govulncheck
make release       # list releases, or tag+push after full checks (make release VERSION=v0.1.0)
make clean         # remove bin/

Adding new features (checklist)

  1. Write the implementation in internal/<domain>/ — return a concrete *Type, no interface at the implementation site
  2. In the consumer package (or _test.go), declare a minimal interface covering only the methods you call
  3. Write unit tests using a manual fake that satisfies that interface
  4. Wire the concrete type in cmd/app/main.go
  5. Run make lint test before committing

Known pitfalls

  • govulncheck makes network calls; excluded from pre-push hook (run manually)
  • Tool versions live in tools.versions — edit that file to upgrade, both Makefile and hook pick it up
  • go run tool@version is used in lint/security targets; Go caches downloads so subsequent runs are fast
  • make tools installs binaries to GOPATH/bin for IDE integration (e.g. dlv for the debugger)
  • go fix rewrites source files; run make lint-fix before committing after a Go version bump

Recent work

  • 2026-03-29 — Stripped to stdlib-only: removed testify/mockery→manual fakes, zap→slog, viper→flag.
  • 2026-03-29 — Pre-commit hook moved to pre-push; go vet + go fix added to lint pipeline.
  • 2026-03-29 — Fixed stale docs and .golangci.yml local-prefixes; .vscode launch configs use CLI flags.
  • 2026-04-01 — Replaced tools.go/go.mod pinning with tools.versions + go run tool@version; go.mod is now free of dev tool deps.
  • 2026-04-01 — Added make release: lists tags with no args; validates semver, runs test-race+lint+security, then tags+pushes.
  • 2026-04-23 — Documented result layering rule: pkg/ libraries only return Expect[T]; .Expect()/.Must() calls belong in application-layer code.
  • 2026-06-03 — pkg/logger v0.4.0: replaced NewDevelopment with NewCLI(level, debugFile); two-mode model (human text on TTY / JSON when piped); debug file mode; IsInteractive() helper. Established "form over mechanism" as core design principle.
  • 2026-06-13 — Build stamping + multi-binary build: internal/buildinfo (Version, Commit, BuildTime injected via -ldflags); make build discovers all cmd/* via find and produces named binaries in ./bin/; make run replaced with make run/ pattern; devcontainer adds ./bin to PATH via ${containerWorkspaceFolder}.
  • 2026-06-14 — pkg/result: reworked the failure surface into four intent-split constructors — Ok/Err (field constructors: value / bare error), Failf (originate from a message, %w for a cause), WrapU (propagate a failed Expect into a new type; panics on success). Removed Errf/Errw. Wrap eliminates the r.Err() unwrap-rewrap dance; behavioral guarantees covered in pkg/result/wrap_test.go.
  • 2026-06-28 — Bumped devcontainer image golang:1.25→1.26-bookworm to develop on the latest toolchain. Deliberately kept the go.mod go 1.25.0 directive unchanged: it is the minimum-version floor for consumers of the published pkg/ packages, so the newer toolchain builds against the lower floor for maximum compatibility. Bump the floor only when a 1.26 language/stdlib feature is actually needed (and run make lint-fix after).
  • 2026-08-05 — Added pkg/result/flow.go: combinators over Expect[T] (Map, AndThen, Filter, MapErr, Or, OrElse, UnwrapOr, UnwrapOrElse) so a multi-step fallible computation reads as one expression and error handling emerges from the operations rather than being written out. Map/AndThen exist twice on purpose: same-type methods (which chain) and type-changing package functions (which cannot be methods — Go has no type parameters on methods), so a chain breaks only at a type crossing and resumes on the far side. Combinators never Goexit, so they are library-safe unlike .Expect()/.Must(). Design history worth keeping: this started as a separate pkg/option with a Maybe[T] type, and was rejected on two grounds. (1) A nil error already means the value is valid — default-constructed or not — so Maybe added a distinction the codebase does not want; and building Maybe on Expect was worse still, since Expect's zero value reads as Ok(zero), silently inverting absent/present. (2) Combinators can only chain as methods, and Go forbids defining methods on a type from another package — so a standalone pkg/option could only offer inside-out nested functions. Hence: one type, combinators beside it, no Option. Also fixed the stale pkg/testutil reference in the testing rules (the package is pkg/check).