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: 24h
type 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). But strings.ToLower("SessionTTL") is "sessionttl", which forces either an ugly YAML key (sessionttl: 24h) or a mapstructure tag. 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.ttl resolves 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.List every field, with local dev values.
application.ymlis the config for running locally and the maintainer's bird's-eye view of all available config. Give every field a value that works for local dev (localhost, a docker-compose password,10s); env vars override them per deployment. Only secrets with no safe dev value (third-party API keys) get"". Never commit real credentials. If a non-string field truly has no default, use a bare key (timeout:), not""—""fails to parse fortime.Duration.Keep
application.ymlfree of comments — with every field listed, per-field comments drown out the config.auth: sessionTtl: 24h smtp: host: localhost port: 1025 password: "" from: dev@localhostIf the code calls
godotenv.Overload(), document overrides in.env.example. List the env vars a deployment needs to set — secrets and anything whose dev default is wrong in prod — with a comment explaining each. Use placeholder values only. Withoutgodotenv.Overload(), don't add.envfiles.# SMTP relay for outgoing auth emails AUTH_SMTP_HOST=smtp.example.com AUTH_SMTP_PORT=587 # App password from the mail provider, not the account password AUTH_SMTP_PASSWORD=changeme AUTH_SMTP_FROM=no-reply@example.com -
Rely 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. -
Address slice elements by index in env vars. A slice field's keyPath gains the element index:
Clients []ClientConfig→clients.0.token→CLIENTS_0_TOKEN. An index past the end of the list inapplication.ymlappends a new element, so a list can come entirely from env. Appending stops at the first missing index, and an unindexed key (CLIENTS=...) is ignored. An unindexed key for a field inside an element (CLIENTS_TOKEN=...) still applies to every existing element, with the indexed key winning where both are set.CLIENTS_1_TOKEN=t9 → clients[1].token (override) CLIENTS_2_NAME=baz → clients[2] appended CLIENTS_2_AUTH_ID=id-2 → clients[2].auth.id PATHS_0=/one → paths[0], no yaml entry neededFor secrets inside a fixed-shape list, use
${NAME}placeholders instead of""— the yaml still documents the shape, and named env vars survive list reordering where indexed ones don't. Use them only for string secrets: an unset var expands to empty, and there's no${NAME:-default}syntax. If the project has a.env.example, list the${NAME}vars there. Reach for indexed env vars when the number of elements varies per environment. -
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 — does the field have a key in
application.ymlwith a working local dev value? 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. - Omitting env-only fields from
application.yml. They still bind from env, but maintainers reading the yaml can't see they exist. - Using prod values or
""as defaults.application.ymlshould run locally as-is; reserve""for secrets with no safe dev value. - Commenting
application.yml. Keep it bare; if the project usesgodotenv.Overload(), put explanations next to the env vars in.env.example. - 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. - Expecting an unindexed env var to replace a whole list.
CLIENTS=...does nothing; slice overrides must be indexed (CLIENTS_0_NAME). - 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.