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:
parent
7bc91b0890
commit
4301f11efd
13
CLAUDE.md
13
CLAUDE.md
@ -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
226
README.md
@ -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`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user