Configuration patterns¶
Configuration scattered as os.Getenv calls throughout a codebase has
no single place to read, no validation, and a typo becomes a zero value
rather than an error. One struct, loaded and checked once at startup,
fixes all three.
type Config struct {
Environment string `env:"ENVIRONMENT"`
Port int `env:"PORT"`
Timeout time.Duration `env:"TIMEOUT"`
DBHost string `env:"DB_HOST"`
}
One struct, loaded once¶
The rules that make this work:
- Load in
main, before anything else runs. - Pass the struct down as a constructor parameter. Never a package global, or you are back to invisible dependencies.
- Fail at startup, not on the first request. A missing database host should stop the process immediately, while someone is watching.
Give fields real types. Timeout time.Duration rather than
TimeoutSeconds int means the parsing and the unit live in one place
instead of at every use.
Defaults first, then overrides¶
Start from a populated struct so every field has a sensible value, then let the environment override:
func Load() (Config, error) {
c := Config{
Environment: "local",
Port: 8080,
Timeout: 5 * time.Second,
}
// ... apply environment ...
}
Defaults in the struct literal are self-documenting: one place shows what the program does with no configuration at all.
Binding with reflection¶
For a handful of fields, explicit os.LookupEnv calls are fine and
clearer. Once there are thirty, the repetition becomes its own bug
source, and walking the struct tags is worth it — the reflect
technique from
XML, CSV and reflection:
v := reflect.ValueOf(&c).Elem()
t := v.Type()
for i := range t.NumField() {
tag := t.Field(i).Tag.Get("env")
if tag == "" || tag == "-" {
continue
}
raw, ok := os.LookupEnv(tag)
if !ok {
continue // leave the default in place
}
f := v.Field(i)
switch f.Interface().(type) {
case string:
f.SetString(raw)
case int:
n, err := strconv.Atoi(raw)
if err != nil {
return Config{}, fmt.Errorf("%s: %w", tag, err)
}
f.SetInt(int64(n))
case time.Duration:
d, err := time.ParseDuration(raw)
if err != nil {
return Config{}, fmt.Errorf("%s: %w", tag, err)
}
f.SetInt(int64(d))
}
}
reflect.ValueOf(&c).Elem() is what makes the fields settable. ok from
LookupEnv distinguishes "unset" from "set to empty", so an unset
variable leaves the default rather than blanking it.
Note the error includes the variable name:
env:"-" marks a field the loader must skip — typically one derived
from others.
This is exactly what configuration libraries do. Knowing the mechanism means you can read theirs, and decide whether forty lines beats a dependency.
Derived fields¶
Compute anything assembled from other values once, after loading:
Better still as a method, so it cannot drift out of date:
Either way, build it in one place. A DSN assembled at three call sites will differ at two of them.
Validate, and report everything at once¶
Returning on the first problem means fixing one variable per restart.
errors.Join collects them:
func (c Config) validate() error {
var errs []error
if c.DBHost == "" {
errs = append(errs, errors.New("DB_HOST is required"))
}
if c.Port < 1 || c.Port > 65535 {
errs = append(errs, fmt.Errorf("PORT %d out of range", c.Port))
}
return errors.Join(errs...)
}
errors.Join returns nil when the slice is empty, so the happy path
needs no special case.
Validate ranges and formats too, not just presence. A pool size of zero or a similarity threshold of 1.5 will otherwise fail somewhere far from the cause.
Secrets¶
Environment variables are the normal transport, and they leak easily. Two habits:
- Never log the config struct.
%+von it puts your database password in the logs. If you want a startup summary, write aRedacted()method, or give secret fields aString()that returns"[redacted]". - Do not put secrets in defaults. A default password in source is a password in your git history.
.env files are a local-development convenience. The standard library
does not read them; that is a third-party concern, and production should
use real environment variables or a secret store.
Flags, environment, or file¶
| Source | Good for |
|---|---|
| environment | deployment config: hosts, credentials, feature flags |
| flags | per-invocation choices: --mode, --dry-run, a file path |
| file | large or structured config, checked into the repo |
Environment is the default for services, because that is what orchestrators supply. A common order is defaults, then file, then environment, then flags — each overriding the last. Whatever you pick, document it, because "why is this value not taking effect" is otherwise unanswerable.
From Python: this is pydantic-settings, hand-rolled. The struct is the schema, the tags are the field aliases, and validation is a method you write. Less magic, and the whole thing fits in one readable function.
Quick reference¶
| Concern | Form |
|---|---|
| shape | one struct, real types (time.Duration, not int) |
| when | once in main, passed down as a parameter |
| defaults | a populated struct literal before overrides |
| unset vs empty | os.LookupEnv, not os.Getenv |
| many fields | walk struct tags with reflect |
| skip a field | env:"-" |
| derived values | one method, not repeated at call sites |
| validation | collect with errors.Join, fail at startup |
| secrets | never log the struct; no secret defaults |