Skip to content

Day 102 — Configuration from the Environment

Month 4 · Week 3 · ⬅ Day 101 · Day 103 ➡ · Journal index

🎯 Learning Objective

Load typed application config from environment variables with defaults, parsing, and required-value checks — and fail fast at startup with all problems at once.

📚 Topics

  • os.Getenv vs os.LookupEnv; defaults & typed parsing
  • Required values, errors.Join, fail-fast; 12-factor config

📖 Reading / Sources

📝 Notes

  • LookupEnv beats Getenv. Getenv returns "" for both unset and set-to-empty; LookupEnv returns (value, ok) so you can tell "missing" from "deliberately blank". Required-value checks need that distinction. → [[lookupenv-vs-getenv]]
  • Config is data from outside the binary (12-factor): never hard-code a port, DSN, or secret. Env vars keep the same image deployable to dev/stage/prod.
  • Parse into typed fields, once, at bootstrconv.Atoi, strconv.ParseBool, time.ParseDuration. Don't re-read os.Getenv deep in handlers; load a Config struct and pass it down. → [[parse-dont-validate]]
  • Fail fast, and collect every problem. A bad deploy should report all missing/garbage vars in one message via errors.Join, not crash-fix-crash on each one. → [[fail-collect]]
  • Inject the lookup function (func(string)(string,bool)) instead of calling os.LookupEnv inside the loader. Tests pass a map; production passes os.LookupEnv. Pure, table-testable config code. → [[dependency-injection]]
  • Secrets are config too, but env vars leak into logs//proc; for real secrets prefer a mounted file or a secret manager. Env is fine for non-secrets.

💻 Code Examples

type Config struct {
    Port        int           // PORT, default 8080
    Timeout     time.Duration // TIMEOUT, default 5s
    DatabaseURL string        // DATABASE_URL, REQUIRED
}

// Inject the lookup so tests don't touch the real environment.
func Load(lookup func(string) (string, bool)) (Config, error) {
    cfg := Config{Port: 8080, Timeout: 5 * time.Second}
    var errs []error

    if v, ok := lookup("PORT"); ok {
        if n, err := strconv.Atoi(v); err != nil {
            errs = append(errs, fmt.Errorf("PORT: %q is not an integer", v))
        } else {
            cfg.Port = n
        }
    }
    if v, ok := lookup("DATABASE_URL"); ok && v != "" {
        cfg.DatabaseURL = v
    } else {
        errs = append(errs, errors.New("DATABASE_URL: is required"))
    }
    return cfg, errors.Join(errs...) // nil when errs is empty
}

Full logic + tests: exercises/month-04/week-3/envconfig · Run: go test ./exercises/month-04/week-3/envconfig

🏋️ Exercises / Practice

Exercise Status Link
Typed Load with defaults + required vars exercises/month-04/week-3/envconfig
Accumulate all errors with errors.Join exercises/month-04/week-3/envconfig

🐛 Mistakes Made

  • Used os.Getenv("DATABASE_URL") == "" to detect "missing" — couldn't tell unset from set-empty. Switched to LookupEnv's ok.
  • Returned on the first bad var; an operator fixed one, redeployed, hit the next. Now errors.Join reports them all.

❓ Open Questions

  • When do I graduate from env vars to a config file (YAML/TOML) + flags? (Answer: when config grows nested/structured; flag for overrides, env for deploys, file for defaults — precedence flag > env > file.)

🧠 Active Recall (answer without looking)

  1. Q: Why prefer os.LookupEnv over os.Getenv for required config?

    A `LookupEnv` returns `(value, ok)`, distinguishing an *unset* variable from one *set to empty string*. `Getenv` returns `""` for both, so you can't enforce "must be present".

  2. Q: What does errors.Join return when you pass it zero non-nil errors?

    A `nil`. `errors.Join` ignores nil arguments and returns `nil` if every argument is nil — so `return cfg, errors.Join(errs...)` is the happy path too.

🪶 Feynman Reflection

Config is everything about this deployment that shouldn't be baked into the binary: which port, which database, how chatty to log. I read it from the environment once at startup, parse it into a typed struct, and if anything is missing or malformed I refuse to boot — listing every problem at once so the operator fixes them in a single pass.

🕳️ Knowledge Gaps

  • Precedence/merging across flags + env + file, and how flag integrates.

✅ Summary

I can load typed config from the environment with defaults, parse and validate it, distinguish unset from empty, and fail fast reporting all problems via errors.Join.

⏭️ Next Steps / Prep for Tomorrow

  • Day 103: per-client rate limiting with a token bucket.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦🟦⬜

Suggested commit: feat(exercises): typed env config with fail-fast joined errors (day 102)