docs: update README and CLAUDE.md for v0.4.0

README: slim to landing-page essentials; add "Design forces" section
explaining why pkg/result and pkg/logger exist (form over mechanism).
Remove stale API docs — those live in package comments.

CLAUDE.md: add "form over mechanism" design principle; update logging
rule to document NewCLI two-mode model; add v0.4.0 to recent work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
djmil 2026-06-03 17:04:30 +00:00
parent 7bc91b0890
commit 4301f11efd
2 changed files with 43 additions and 196 deletions

View File

@ -18,6 +18,11 @@ Key constraint: `go.mod` stays free of dev tool deps (tools are pinned in `tools
## 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](https://12factor.net) is the primary reference for default implementation choices.**
Follow its recommendations unless circumstances force a deviation — and document the reason when you do.
@ -63,7 +68,12 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
- 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.Fail`
- **Logging** — logs go to `stderr` (structured, machine-readable, per 12-factor XI); human output goes to `stdout` via `fmt.Print*`. Use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
- **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
---
@ -142,3 +152,4 @@ make clean # remove bin/
- 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.

226
README.md
View File

@ -9,12 +9,32 @@ so packages extracted from this template can be published without module graph p
---
## Design forces
Two libraries in this template emerged from a recurring friction in Go:
**mechanism leaking into intent**.
**`pkg/result`** exists because `if err != nil { return nil, err }` repeated on every line
obscures what the code is actually doing. The happy path drowns in error-routing boilerplate.
`result.Expect` moves the exit logic to the edges — constructors and entry points — so the
body of a function reads as intent, not plumbing.
**`pkg/logger`** exists because `fmt.Fprintln(os.Stderr, "error: "+msg)` leaks *how* (write
to stderr, format a string) into code that should only express *what* (something went wrong).
The same friction appeared in format selection: choosing between human text and JSON based on
the execution environment is mechanism, not business logic. `logger.NewCLI` hides that choice —
the call site just says `log.Warn(...)` and gets the right output for the context.
The pattern: **constructors own the mechanism; call sites own the intent.**
---
## Features
| Area | Tool | Purpose |
|---|---|---|
| Language | Go 1.25 | Modules, toolchain directive |
| Logging | standard `log/slog` | Structured JSON/text logging + `WithField` extension |
| Logging | standard `log/slog` | Auto-detects terminal vs pipe: human text or JSON; optional debug file dump |
| Config | standard `flag` | CLI flags with defaults, no config files |
| Linting | [golangci-lint](https://golangci-lint.run) | Aggregated linters, one config file |
| Security | [gosec](https://github.com/securego/gosec) + [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) | SAST + dependency vulnerability scan |
@ -92,19 +112,17 @@ Commands live in `.claude/commands/` and are available to anyone who clones the
.
├── cmd/
│ └── app/
│ ├── config.go # Flag parsing and Config struct (all flags live here)
│ └── main.go # Composition root (thin — just wiring)
├── internal/
│ ├── config/ # flag-based config (config.Load)
│ ├── logger/ # slog wrapper with WithField / WithFields
│ └── greeter/ # Example domain package (replace with yours)
├── pkg/ # Publishable packages — no app-specific assumptions
│ ├── logger/ # slog wrapper: NewCLI, New, NewWriter, WithField/WithFields
│ ├── result/ # Happy-path error handling (Expect[T])
│ └── testutil/ # Test helpers (ResultOk, Equal, …)
├── .devcontainer/ # VSCode / Codespaces container definition
├── .githooks/ # pre-push hook (installed by `make setup`)
├── .vscode/ # launch, tasks, editor settings
├── pkg/
│ └── result/ # Example publishable package (Result/Expect types)
│ ├── doc.go # package-level godoc
│ ├── result.go # implementation
│ └── example_test.go # runnable examples (shown on pkg.go.dev)
├── .githooks/ # pre-push hook (gofmt + vet + lint + gosec)
├── .vscode/ # Launch configs, tasks, editor settings
├── tools.versions # Pinned tool versions (Makefile + hook source this)
├── .golangci.yml # Linter configuration
└── Makefile # All development commands
@ -112,183 +130,6 @@ Commands live in `.claude/commands/` and are available to anyone who clones the
---
## Configuration
All configuration is via CLI flags with sensible defaults:
```bash
./bin/app -help
-env string environment: dev | staging | prod (default "dev")
-log-level string log level: debug | info | warn | error (default "info")
-name string greeter name (default "Gopher")
-port int listen port (default 8080)
```
Override at runtime:
```bash
./bin/app -port 9090 -env prod -log-level warn
```
---
## Logging
The logger wrapper adds `WithField` and `WithFields` for ergonomic context chaining
on top of the standard `log/slog` package:
```go
log.WithField("request_id", rid).
WithField("user", uid).
Info("handling request")
log.WithFields(map[string]any{
"component": "greeter",
"name": name,
}).Debug("generating greeting")
```
In production (`app.env != dev`) the output is JSON (`slog.NewJSONHandler`).
In development, `logger.NewDevelopment()` uses the human-friendly text handler.
---
## Testing
Tests use the standard `testing` package. Concrete types are returned from
constructors — consumers (including tests) define their own minimal interfaces
and satisfy them with manual fakes. No code generation required.
```go
// Interface declared in the consumer (or _test.go), not in greeter package.
type greeter interface {
Greet(name string) result.Expect[string]
}
type fakeGreeter struct {
greetFn func(name string) result.Expect[string]
}
func (f *fakeGreeter) Greet(name string) result.Expect[string] {
return f.greetFn(name)
}
func TestSomething(t *testing.T) {
fake := &fakeGreeter{
greetFn: func(name string) result.Expect[string] {
return result.Ok("Hello, " + name + "!")
},
}
// pass fake to the system under test
}
```
---
## pkg/result — happy-path error handling
`pkg/result` is a small publishable package that demonstrates the pattern of
keeping `go.mod` clean. It provides `Expect[T]`, a generic type that holds
either a success value or an error.
The idea: deep call stacks write for the **happy path**, unwrapping results with
`.Expect()` or `.Must()` (which exit the goroutine on failure). A single
`defer result.Catch(&err)` at the entry point collects that exit as a normal
Go error, with the stack trace captured at the original failure site.
### Layering rule
`pkg/` libraries must only **return** `Expect[T]` — never call `.Expect()` or
`.Must()` themselves. Those methods exit the current goroutine via
`runtime.Goexit` and are only safe inside application-layer code (`cmd/`,
HTTP handlers) that is protected by `defer result.Catch(&err)` or
`result.Run(...)`. Calling them inside a reusable package takes control away
from the caller and makes the package non-composable.
```go
// Wrap any (value, error) function:
data := result.Of(os.ReadFile("config.json")).Expect("read config")
// Or construct explicitly:
func parsePort(s string) result.Expect[int] {
n, err := strconv.Atoi(s)
if err != nil {
return result.Fail[int](fmt.Errorf("parsePort: %w", err))
}
return result.Ok(n)
}
// Entry point catches panics from the whole call stack:
func run() (err error) {
defer result.Catch(&err)
port := parsePort(cfg.Port).Expect("load config")
_ = port // happy path continues …
return nil
}
```
`Catch` only intercepts error panics produced by this package. Real runtime
panics (nil-pointer dereferences, etc.) are re-panicked so bugs are never
silently swallowed.
At boundaries where you need to re-join normal Go code:
```go
port, err := parsePort("443").Unwrap() // back to (T, error)
```
See [`pkg/result/example_test.go`](pkg/result/example_test.go) for the
full set of runnable examples.
---
## Git hooks
The pre-push hook runs **gofmt + go vet + golangci-lint + gosec** against the
full codebase before every `git push`. Running on push (not commit) keeps the
inner commit loop fast while still blocking bad code from reaching the remote.
`govulncheck` is intentionally excluded (it makes network calls and can be
slow). Run it manually with `make security`.
To skip the hook in an emergency:
```bash
git push --no-verify
```
---
## Tool version management
Tool versions are pinned in `tools.versions` — a plain `KEY=value` file that
both the `Makefile` and the pre-push hook source directly.
**Why not `tools.go` + `go.mod`?**
The conventional `//go:build tools` approach adds development tools (linters,
scanners, debuggers) as entries in `go.mod`. This is fine for applications, but
it's a problem when the repo is intended to publish reusable packages: consumers
who `go get` your package see those tool dependencies in the module graph, even
though they are never compiled into the binary. Keeping `go.mod` clean makes the
module honest — it declares only what the package code actually needs.
Instead, `go run tool@version` is used in Makefile targets and the pre-push
hook. Go caches downloaded tools in the module cache, so after the first run
there is no extra download cost.
To update a tool, edit the version in `tools.versions`:
```
GOLANGCI_LINT_VERSION=v1.64.8 ← change this
```
Both `make lint` and the pre-push hook pick up the new version automatically.
Run `make tools` if you also want the updated binary installed to `GOPATH/bin`
(e.g. for IDE integration or direct CLI use).
---
## Releasing
```bash
@ -296,17 +137,12 @@ make release # list all existing tags
make release VERSION=v0.1.0 # run full checks, then tag and push
```
The release target validates the version format (semver `vX.Y.Z`), ensures the
working tree is clean, and runs `make test-race lint security` before tagging.
The tag is only created and pushed if every check passes.
---
## Devcontainer
Open this repo in VSCode and choose **"Reopen in Container"** — the Go
toolchain is installed automatically. Run `make init` to configure git hooks,
then `make tools` to install linter/scanner binaries to `GOPATH/bin`.
Open this repo in VSCode and choose **"Reopen in Container"**. Run `make init` to
configure git hooks, then `make tools` for IDE-integrated tool binaries.
Works with GitHub Codespaces out of the box.
@ -317,5 +153,5 @@ Works with GitHub Codespaces out of the box.
- **HTTP server** — add [chi](https://github.com/go-chi/chi) or [gin](https://github.com/gin-gonic/gin)
- **Database** — add [sqlx](https://github.com/jmoiron/sqlx) or [ent](https://entgo.io/)
- **CI** — add a Gitea Actions / CI pipeline running `make lint test security`
- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image (separate from the devcontainer)
- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image
- **OpenTelemetry** — add tracing with `go.opentelemetry.io/otel`