From 5055d696855914035ba334055dd646aa90e5dede Mon Sep 17 00:00:00 2001 From: djmil Date: Thu, 14 May 2026 18:10:09 +0000 Subject: [PATCH] pkg/logger and pkg/testutil --- CLAUDE.md | 20 ++++++- {internal/config => cmd/app}/config.go | 24 ++++---- cmd/app/main.go | 76 +++++++++++++++---------- internal/greeter/greeter.go | 12 ++-- internal/greeter/greeter_test.go | 4 +- {internal => pkg}/logger/logger.go | 39 ++++++++++--- {internal => pkg}/logger/logger_test.go | 4 +- {internal => pkg}/testutil/testutil.go | 2 +- 8 files changed, 116 insertions(+), 65 deletions(-) rename {internal/config => cmd/app}/config.go (78%) rename {internal => pkg}/logger/logger.go (65%) rename {internal => pkg}/logger/logger_test.go (83%) rename {internal => pkg}/testutil/testutil.go (96%) diff --git a/CLAUDE.md b/CLAUDE.md index bec81f9..4c3c7c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,23 @@ Module: `gitea.djmil.dev/go/template` — update this when you fork. --- +## Design philosophy + +**[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. + +Key factors that shape this codebase most directly: + +- **[Logs (factor XI)](https://12factor.net/logs)** — 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)](https://12factor.net/config)** — 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)](https://12factor.net/dependencies)** — 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 ``` @@ -47,7 +64,7 @@ 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** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend +- **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 - **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages --- @@ -70,6 +87,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push - 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` diff --git a/internal/config/config.go b/cmd/app/config.go similarity index 78% rename from internal/config/config.go rename to cmd/app/config.go index 3bd6981..4d2d4f5 100644 --- a/internal/config/config.go +++ b/cmd/app/config.go @@ -1,13 +1,4 @@ -// Package config parses application configuration from command-line flags. -// Defaults are defined here; override at runtime with flags: -// -// ./app -port 9090 -env prod -log-level warn -// -// Usage: -// -// cfg := config.Load() -// fmt.Println(cfg.App.Port) -package config +package main import ( "flag" @@ -36,9 +27,16 @@ type GreeterConfig struct { Name string } -// Load parses command-line flags and returns a Config. -// Call this once at startup before any other flag parsing. -func Load() *Config { +// parseArgs parses application configuration from command-line flags. +// Defaults are defined here; override at runtime with flags: +// +// ./app -port 9090 -env prod -log-level warn 2 > log.log +// +// Usage: +// +// cfg := config.parseArgs() +// fmt.Println(cfg.App.Port) +func parseArgs() *Config { name := flag.String("name", "Gopher", "application name") port := flag.Int("port", 8080, "listen port") env := flag.String("env", "dev", "environment: dev | staging | prod") diff --git a/cmd/app/main.go b/cmd/app/main.go index 3644e58..5555e42 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,6 +1,7 @@ // main is the composition root for the application. -// It wires together config, logger, and domain services — nothing more. -// Business logic lives in internal/; cmd/ is deliberately thin. +// It parses config, wires dependencies into an app struct, and delegates. +// Implementation details lives in internal/; cmd/ should be kept thin by +// deliberately operating only with high level concepts. package main import ( @@ -8,14 +9,42 @@ import ( "os" "path/filepath" - "gitea.djmil.dev/go/template/internal/config" "gitea.djmil.dev/go/template/internal/greeter" - "gitea.djmil.dev/go/template/internal/logger" + "gitea.djmil.dev/go/template/pkg/logger" "gitea.djmil.dev/go/template/pkg/result" ) +// app holds all wired dependencies for the lifetime of the process. +type app struct { + cfg *Config + log *logger.Logger + greeter *greeter.Service +} + +func newApp(cfg *Config) *app { + var log *logger.Logger + if cfg.App.Env == "dev" { + log = logger.NewDevelopment() + } else { + log = logger.New(cfg.Logger.Level).Expect("create logger") // might fail dramatically + } + return &app{ + cfg: cfg, + log: log, + greeter: greeter.New(log), + } +} + func main() { - if err := result.Run(showGreeting); err != nil { + conf := parseArgs() + + app := newApp(conf) + app.log.WithFields(map[string]any{ + "app": filepath.Base(os.Args[0]), + "env": conf.App.Env, + }).Info("starting up") + + if err := result.Run(app.run); err != nil { fmt.Fprintf(os.Stderr, "[failed] %v\n", err) if stack := result.StackTrace(err); stack != "" { fmt.Fprintf(os.Stderr, "%s\n", stack) @@ -24,30 +53,17 @@ func main() { } } -func showGreeting() { - // ── Config ──────────────────────────────────────────────────────────────── - cfg := config.Load() +func (a *app) run() { + // High level business logic goes here + a.showGreeting(a.cfg.Greeter.Name) - // ── Logger ──────────────────────────────────────────────────────────────── - var log *logger.Logger - if cfg.App.Env == "dev" { - log = logger.NewDevelopment() - } else { - log = logger.New(cfg.Logger.Level).Expect("create logger") - } - - log.WithFields(map[string]any{ - "app": filepath.Base(os.Args[0]), - "env": cfg.App.Env, - }).Info("starting up") - - // ── Services ────────────────────────────────────────────────────────────── - greetSvc := greeter.New(log) - - // ── Example usage ───────────────────────────────────────────────────────── - msg := greetSvc.Greet(cfg.Greeter.Name).Expect("greeting") - - log.WithField("message", msg).Info("greeting complete") - - fmt.Printf("%s (listening on :%d)\n", msg, cfg.App.Port) + // Human readable messages. + // Use logs for presenting technical data in machine friendly format. + fmt.Printf("TODO: implement listening on port %d\n", a.cfg.App.Port) +} + +func (a *app) showGreeting(name string) { + msg := a.greeter.Greet(name).Expect("greeting") + a.log.WithField("message", msg).Info("greeting complete") + fmt.Println(msg) } diff --git a/internal/greeter/greeter.go b/internal/greeter/greeter.go index ed25536..b630ec9 100644 --- a/internal/greeter/greeter.go +++ b/internal/greeter/greeter.go @@ -1,8 +1,7 @@ // Package greeter is a minimal example domain package. // It demonstrates how to: // - define an interface (satisfied by manual fakes in tests) -// - inject dependencies (logger) through a constructor -// - use the logger.WithField pattern +// - inject a component-scoped logger through the constructor // // Replace this package with your own domain logic. package greeter @@ -10,7 +9,7 @@ package greeter import ( "fmt" - "gitea.djmil.dev/go/template/internal/logger" + "gitea.djmil.dev/go/template/pkg/logger" "gitea.djmil.dev/go/template/pkg/result" ) @@ -21,7 +20,7 @@ type Service struct { // New creates a Greeter service with the provided logger. func New(log *logger.Logger) *Service { - return &Service{log: log} + return &Service{log: log.WithField("component", "greeter")} } // Greet returns a personalized greeting and logs the interaction. @@ -32,10 +31,7 @@ func (s *Service) Greet(name string) result.Expect[string] { msg := fmt.Sprintf("Hello, %s!", name) - s.log. - WithField("component", "greeter"). - WithField("name", name). - Info("greeting generated") + s.log.WithField("name", name).Debug("greeting generated") return result.Ok(msg) } diff --git a/internal/greeter/greeter_test.go b/internal/greeter/greeter_test.go index c8f6419..d2d0340 100644 --- a/internal/greeter/greeter_test.go +++ b/internal/greeter/greeter_test.go @@ -4,9 +4,9 @@ import ( "testing" "gitea.djmil.dev/go/template/internal/greeter" - "gitea.djmil.dev/go/template/internal/logger" - "gitea.djmil.dev/go/template/internal/testutil" + "gitea.djmil.dev/go/template/pkg/logger" "gitea.djmil.dev/go/template/pkg/result" + "gitea.djmil.dev/go/template/pkg/testutil" ) // ── Service (unit tests) ────────────────────────────────────────────────────── diff --git a/internal/logger/logger.go b/pkg/logger/logger.go similarity index 65% rename from internal/logger/logger.go rename to pkg/logger/logger.go index 03fdffd..3e2a050 100644 --- a/internal/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,16 +1,26 @@ // Package logger wraps log/slog with a thin, ergonomic API. // -// The key addition over raw slog is the WithField / WithFields helpers that -// return a *Logger (not a *slog.Logger), so callers stay in the typed world -// and can chain field attachments without importing slog directly. +// Per the Twelve-Factor App (factor XI), the application writes structured log +// events to stderr and never manages log files or routing itself. The execution +// environment (shell, systemd, Docker) is responsible for capturing and storing +// the stream. Human-readable output belongs on stdout via fmt.Print*. // -// Usage: +// Typical use: // // log := logger.New("info").Expect("create logger") -// log.Info("server started") +// log.Info("server started", "port", 8080) // -// req := log.WithField("request_id", rid).WithField("user_id", uid) -// req.Info("handling request") +// // child logger for request-scoped fields that repeat across many lines: +// req := log.WithField("request_id", rid) +// req.Info("start") +// req.Info("end") +// +// In tests that need to assert on log content, use NewWriter with a buffer: +// +// var buf bytes.Buffer +// log := logger.NewWriter(&buf, "debug").Expect("create logger") +// // ... exercise code ... +// // assert on buf.String() package logger import ( @@ -41,7 +51,7 @@ func New(level string) result.Expect[*Logger] { return result.Ok(logger) } -// NewDevelopment creates a human-friendly text logger writing to stderr. +// NewDevelopment creates a human-friendly text logger writing to stderr at debug level. // Use this in local dev; prefer New() in any deployed environment. func NewDevelopment() *Logger { h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) @@ -49,6 +59,19 @@ func NewDevelopment() *Logger { return &Logger{slog.New(h)} } +// NewWriter creates a JSON logger writing to w. Intended for tests that need to +// assert on log content — pass a *bytes.Buffer and inspect it after the fact. +func NewWriter(w io.Writer, level string) result.Expect[*Logger] { + lvl := parseLevel(level) + if lvl.Err() != nil { + return result.Errw[*Logger](lvl.Err(), "parse log level") + } + + handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()}) + + return result.Ok(&Logger{slog.New(handler)}) +} + // NewNop returns a no-op logger. Useful in tests that don't care about logs. func NewNop() *Logger { return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))} diff --git a/internal/logger/logger_test.go b/pkg/logger/logger_test.go similarity index 83% rename from internal/logger/logger_test.go rename to pkg/logger/logger_test.go index b219e31..110b3d3 100644 --- a/internal/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -3,8 +3,8 @@ package logger_test import ( "testing" - "gitea.djmil.dev/go/template/internal/logger" - "gitea.djmil.dev/go/template/internal/testutil" + "gitea.djmil.dev/go/template/pkg/logger" + "gitea.djmil.dev/go/template/pkg/testutil" ) func TestNew(t *testing.T) { diff --git a/internal/testutil/testutil.go b/pkg/testutil/testutil.go similarity index 96% rename from internal/testutil/testutil.go rename to pkg/testutil/testutil.go index 6da8eed..4aa4071 100644 --- a/internal/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -1,5 +1,5 @@ // Package testutil provides lightweight test helpers to reduce boilerplate in -// table-driven tests. Import it from any _test.go file in this module. +// table-driven tests. // // Every helper calls t.Helper() so failures are reported at the call site, not // inside this package.