Working with lwlee2608/adder
Adder reads YAML into Go structs and overlays env vars. It does case-insensitive key matching only — it does not fold snake_case ↔ CamelCase. Every rule below exists to avoid fields silently binding to their zero value.
Rules
Prefer YAML keys that already match the lowercased Go field name. Adder looks up
strings.ToLower(field.Name), so a fieldSessionTtlis resolved against the YAML keysessionttl. Both lowercase and camelCase keys work because adder lowercases both sides — camelCase is just easier to read.# good — matches field name when lowercased auth: username: admin sessionTtl: 24htype AuthConfig struct { Username string SessionTtl time.Duration // no tag required }Idiomatic Go vs. tag-free binding is a tension. Go style says initialisms should be all-caps (
SessionTTL). Butstrings.ToLower("SessionTTL")is"sessionttl", which forces either an ugly YAML key (sessionttl: 24h) or amapstructuretag. Pick your poison: idiomatic Go + tag, or non-idiomatic field name + no tag. This skill leans toward the latter to minimize tags, but either is defensible — be consistent within a project.This applies recursively at every nesting level —
auth.session.ttlresolves each segment the same way.Prefer not to use snake_case in YAML. A snake_case key like
session_ttlwill never bind toSessionTTLautomatically — adder comparessessionttltosession_ttland finds no match, so the field silently stays at its zero value (e.g.time.Duration(0), which means "expires immediately" for TTLs). If you must use snake_case for readability, every such field requires an explicitmapstructuretag:// only if you insist on snake_case YAML SessionTTL time.Duration `mapstructure:"session_ttl"`Prefer rule 1 over this — fewer tags, fewer ways to forget one.
Put defaults in
application.yml, not in code.application.ymlis the canonical source of default values. Code should not paper over missing config with hard-coded fallbacks (e.g.if cfg.SessionTTL == 0 { cfg.SessionTTL = 24*time.Hour }) — that hides genuine misconfiguration. If a value is required, validate at startup and panic with a clear message.# application.yml — defaults live here auth: sessionTtl: 24hRely on
AutomaticEnv()for the standard env var pattern. A keyPath is the dotted path adder uses internally — struct fieldAuth.SessionTtlbecomes keyPathauth.sessionttl. WithSetEnvKeyReplacer(strings.NewReplacer(".", "_"))andAutomaticEnv(), adder derives the env var asstrings.ToUpper(keyPath)with dots replaced by underscores. Don't addBindEnvcalls for env vars that already follow this pattern — they're noise.keyPath: openrouter.apikey → env: OPENROUTER_APIKEY (auto, no BindEnv needed) keyPath: db.url → env: DB_URL (auto)Use
BindEnvonly when the env var name diverges from the auto pattern. Common reason: an established external env name (e.g.OPENROUTER_API_KEYwith an underscore betweenAPIandKEY) doesn't match the auto-derivedOPENROUTER_APIKEY.// Only because the external name is OPENROUTER_API_KEY, not OPENROUTER_APIKEY _ = adder.BindEnv("openrouter.apikey", "OPENROUTER_API_KEY") // Don't write this — DB_URL is already automatic from keyPath db.url // _ = adder.BindEnv("db.url", "DB_URL")If you can rename the env var to match the auto pattern, do that instead and drop the
BindEnv.Co-locate config structs with the package they configure. Each package owns its own config type (
internal/auth/config.go→auth.Config,internal/db/config.go→db.Config). The cmd-levelConfigis just composition.// cmd/myapp/config.go type Config struct { Auth auth.Config DB db.Config }Adder binds either way — this is for code organization: package owns its fields, masking tags, and any
Enabled()/Validate()helpers, andcmd/<app>/config.gostays short. Config types used only by cmd wiring (e.g.LogConfig) can stay incmd/<app>/.
Verification procedure
After adding or changing a config field (these checks apply at every nesting level — verify the deepest field, not just the top-level struct):
- Lowercase-match check — does
strings.ToLower(fieldName)equal the YAML key exactly? If not, you need amapstructuretag, or you should rename the YAML key. - Default check — is the default value in
application.yml? Run the binary with no env overrides and confirm the field has the expected value. Don't rely on memory — log it, e.g.log.Printf("%+v", cfg). - Env override check — if the field is meant to be env-overridable, set the auto-derived env var (
UPPER_CASE_WITH_UNDERSCORES) and confirm it overrides the YAML default. Same rule: log the loaded value, don't assume. - Zero-value trap — for
time.Duration,int,float64, orboolfields, a binding miss looks identical to "configured as 0/false". Always test a non-zero default actually loads.
Common mistakes to watch for
- Adding a multi-word field without a
mapstructuretag and using snake_case YAML. This is the #1 footgun: the field silently binds to zero.SessionTTL+session_ttl:→0, not24h. - Adding
BindEnvfor env vars that already follow the auto pattern. Redundant and obscures which bindings are actually needed. - Hiding misconfiguration with code defaults. A
if cfg.X == 0 { cfg.X = default }masks the real bug (the YAML key didn't bind). Fix the binding instead. - Assuming case-insensitive means snake-aware. It doesn't —
caseInsensitiveLookuponly normalizes letter case, not separators. - Forgetting that
time.Durationisint64. A missing duration looks like0s, which most code treats as "no timeout" or "expired" — almost always wrong.