Day 77 — Config layering

Updated

July 30, 2026

Day 77 — Config layering

Stage VIII · ~3h
Goal: Implement a layered configuration loader—defaults → file → environment → flags—with clear precedence, typed config structs, validation, and no secret-in-repo footguns.

Note

Only the composition root (and a dedicated config package) should read env/flags. Lower layers receive values.

Why this day exists

Production processes need configuration. Ad-hoc os.Getenv sprinkled across packages causes:

  • Undocumented knobs
  • Different defaults in tests vs prod
  • Secrets committed to git
  • Flags that fight env vars

12-factor-ish discipline: config from environment (and files for local), validated at startup, injected into the composition root. Capstone days 83–88 will copy this pattern.


Theory 1 — Precedence model

Recommended order (later wins):

1. Code defaults
2. Config file (optional YAML/JSON/TOML)
3. Environment variables
4. CLI flags

Document this in README. Surprises here are outages.

type Config struct {
    Addr        string
    DatabaseURL string
    LogLevel    string
    ShutdownSec int
}

Why flags win last

Local overrides for debugging (--addr :9090) should beat shell env without editing files. Deployments usually set env and pass no flags.


Theory 2 — Stdlib-first loading

func Load(args []string) (Config, error) {
    cfg := Config{
        Addr:        ":8080",
        LogLevel:    "info",
        ShutdownSec: 15,
    }

    // optional file
    if path := os.Getenv("CONFIG_FILE"); path != "" {
        if err := loadFile(path, &cfg); err != nil {
            return Config{}, err
        }
    }

    if v := os.Getenv("ADDR"); v != "" {
        cfg.Addr = v
    }
    if v := os.Getenv("DATABASE_URL"); v != "" {
        cfg.DatabaseURL = v
    }
    if v := os.Getenv("LOG_LEVEL"); v != "" {
        cfg.LogLevel = v
    }
    if v := os.Getenv("SHUTDOWN_SEC"); v != "" {
        n, err := strconv.Atoi(v)
        if err != nil {
            return Config{}, fmt.Errorf("SHUTDOWN_SEC: %w", err)
        }
        cfg.ShutdownSec = n
    }

    fs := flag.NewFlagSet("service", flag.ContinueOnError)
    fs.SetOutput(io.Discard) // or os.Stderr for user CLIs
    addr := fs.String("addr", cfg.Addr, "listen address")
    db := fs.String("database-url", cfg.DatabaseURL, "database URL")
    logLevel := fs.String("log-level", cfg.LogLevel, "log level")
    if err := fs.Parse(args); err != nil {
        return Config{}, err
    }
    cfg.Addr = *addr
    cfg.DatabaseURL = *db
    cfg.LogLevel = *logLevel

    if err := cfg.Validate(); err != nil {
        return Config{}, err
    }
    return cfg, nil
}

func (c Config) Validate() error {
    if c.DatabaseURL == "" {
        return errors.New("DATABASE_URL required")
    }
    if c.ShutdownSec < 1 {
        return errors.New("shutdown sec must be >= 1")
    }
    switch strings.ToLower(c.LogLevel) {
    case "debug", "info", "warn", "error":
    default:
        return fmt.Errorf("invalid log level %q", c.LogLevel)
    }
    return nil
}

File format

JSON is enough without deps:

func loadFile(path string, cfg *Config) error {
    b, err := os.ReadFile(path)
    if err != nil {
        return err
    }
    return json.Unmarshal(b, cfg)
}
{
  "addr": ":8080",
  "log_level": "debug",
  "shutdown_sec": 20
}

YAML needs a library—fine if you already use one. Prefer one format per project.


Theory 3 — Secrets

Do Don’t
Env / secret manager Commit .env with real secrets
Document var names Log full DATABASE_URL
Separate Config vs Secrets if helpful Put tokens in config files in images
// redact when logging
func (c Config) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("addr", c.Addr),
        slog.String("log_level", c.LogLevel),
        slog.Int("shutdown_sec", c.ShutdownSec),
        slog.String("database_url", redactDSN(c.DatabaseURL)),
    )
}

func redactDSN(dsn string) string {
    // naive: hide password between : and @
    // improve for your DSN shape
    if i := strings.Index(dsn, "://"); i >= 0 {
        rest := dsn[i+3:]
        if at := strings.Index(rest, "@"); at >= 0 {
            return dsn[:i+3] + "***@" + rest[at+1:]
        }
    }
    return "***"
}

.env for local dev is OK if gitignored; provide .env.example with empty values.

# .env.example
DATABASE_URL=
ADDR=:8080
LOG_LEVEL=info
SHUTDOWN_SEC=15
CONFIG_FILE=

Theory 4 — Injection at the root

func main() {
    cfg, err := config.Load(os.Args[1:])
    if err != nil {
        fmt.Fprintf(os.Stderr, "config: %v\n", err)
        os.Exit(2)
    }
    logger := newLogger(cfg.LogLevel)
    logger.Info("starting", "cfg", cfg) // uses LogValue if slog.LogValuer
    db := mustOpen(cfg.DatabaseURL)
    // wire adapters with cfg fields — packages don't call os.Getenv
    _ = db
}

Rule: only config package (and main) reads env/flags. Lower layers receive values.

Why exit code 2

Convention: 2 for misuse/config; 1 for runtime failure. CLIs (Day 71) already care—services should too.


Theory 5 — Testing config

func TestLoadDefaults(t *testing.T) {
    t.Setenv("DATABASE_URL", "sqlite://file.db")
    t.Setenv("ADDR", "") // ensure not leaking from parent env if needed
    cfg, err := Load(nil)
    if err != nil {
        t.Fatal(err)
    }
    if cfg.Addr != ":8080" {
        t.Fatalf("addr %s", cfg.Addr)
    }
}

func TestFlagBeatsEnv(t *testing.T) {
    t.Setenv("DATABASE_URL", "sqlite://file.db")
    t.Setenv("ADDR", ":9090")
    cfg, err := Load([]string{"-addr", ":9091"})
    if err != nil {
        t.Fatal(err)
    }
    if cfg.Addr != ":9091" {
        t.Fatalf("got %s", cfg.Addr)
    }
}

func TestValidateMissingDB(t *testing.T) {
    t.Setenv("DATABASE_URL", "")
    _, err := Load(nil)
    if err == nil {
        t.Fatal("expected error")
    }
}

t.Setenv keeps tests hermetic (Go 1.17+).


Theory 6 — K8s / Docker alignment

Surface Maps to
container env: process environment
ConfigMap file mount CONFIG_FILE
Secret mount env or file; never image layers
CLI debug flags on command:

Document one canonical name per knob (DATABASE_URL not sometimes DB_URL).

GOMEMLIMIT is process env that Go runtime reads—document alongside app config even if you do not parse it in Go.


Worked example — layered demo

export DATABASE_URL=postgres://localhost/app
export ADDR=:9090
go run ./cmd/service --addr :9091
# effective ADDR :9091 (flag wins)

Document matrix in CONFIG.md:

Key Default Env Flag
addr :8080 ADDR -addr
database (none) DATABASE_URL -database-url
log level info LOG_LEVEL -log-level
shutdown 15 SHUTDOWN_SEC -shutdown-sec

Worked example — empty env must not clobber

// wrong: always assign
cfg.Addr = os.Getenv("ADDR") // empty string wipes file/default

// right: only override when set
if v := os.Getenv("ADDR"); v != "" {
    cfg.Addr = v
}

Lab

Suggested workspace: service composition root.

  1. Central internal/config (or config) package.
  2. Precedence: defaults → file (optional) → env → flags.
  3. Validate() fails fast on missing required values.
  4. .env.example + README table.
  5. Unit tests for precedence and validation errors.
  6. Redacted logging of secrets.

Stretch

  • GOMEMLIMIT documented alongside app config.
  • Feature flag file path field prepared for Day 79.
  • String() method that never prints secrets (for fmt debugging).

Common gotchas

Gotcha Fix
Env read deep in store package Inject DSN from main
Empty string overwriting file values Only override when env set
flag.Parse global in library FlagSet in Load
Secrets in logs Redact
Different names in K8s vs local One canonical env name
Panic on bad config Return error; main exits 2
Bool flags defaulting wrong Document true/false strings
Required vs optional confusion Validate() is the source of truth

Checkpoint

  • Typed Config + Load
  • Documented precedence
  • Validation at startup
  • Tests for env/flag behavior
  • Example env file without secrets
  • Redaction on log path

Commit

git add .
git commit -m "day77: config layering flags env file"

Write three personal gotchas before continuing.


Tomorrow

Day 78 — Graceful shutdown & health: production process lifecycle—SIGTERM, drain, liveness vs readiness.