Loading config
Packages used:confoptionerrslog
Production services need configuration that comes from multiple sources at once: defaults baked into the binary, a JSON file on disk, environment variables set by the operator, and command-line flags for one-off overrides. conf layers all of those into a single atomic commit, delivers a snapshot accessor to every interested package, and makes concurrent reads safe without any locking at the call site.
Walkthrough
Step 1 - Register a configuration section
Each package declares its own configuration struct once, at package init time. conf.Register[T] returns a func() *T - call it anywhere to get the latest committed snapshot.
package server
import "github.com/nathanbrophy/glacier/conf"
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
// cfg is the snapshot accessor returned by Register.
var cfg = conf.Register[Config]("server", Config{
Host: "localhost",
Port: 8080,
})
// Cfg returns the most recently loaded server configuration snapshot.
// The returned pointer is immutable; do not modify it.
func Cfg() *Config { return cfg() }The defaults supplied to Register are the fallback values used when a source does not provide a key. They are never nil.
Step 2 - Load configuration at startup
Call conf.Load once in main (or in your CLI command's Run method). All registered sections across all imported packages are populated in a single staged-and-replace commit. Torn reads are impossible: a concurrent reader always sees either the full pre-load or the full post-load state.
package main
import (
"context"
"log"
"github.com/nathanbrophy/glacier/conf"
_ "myapp/db" // registers "db" section
_ "myapp/server" // registers "server" section
)
func main() {
if err := conf.Load(
context.Background(),
conf.WithFile("config.json"),
conf.WithEnvPrefix("APP"),
); err != nil {
log.Fatal(err)
}
// server.Cfg() and db.Cfg() now return populated snapshots.
}Step 3 - Override with environment variables
With prefix APP, the field server.port is overridden by the environment variable APP__SERVER__PORT. The double-underscore separates the prefix, section, and field name.
APP__SERVER__PORT=9090
APP__DB__MAX_CONNS=50Environment variables override JSON file values; command-line flags override environment variables. The precedence order is fixed:
defaults → JSON file → env vars → flags → explicit Set overridesStep 4 - Add a flag source
To let command-line flags override env vars, pass conf.WithFlagSource pointing at the parsed flag set. With the cli package, cli.FlagSource() returns the right value automatically.
if err := conf.Load(ctx,
conf.WithFile(s.ConfigPath),
conf.WithEnvPrefix("APP"),
conf.WithFlagSource(cli.FlagSource()),
); err != nil {
return errs.Wrap(err, "serve: load config")
}errs.Wrap prepends "serve: load config: " to the message while keeping the original error available for errors.Is / errors.As traversal.
Step 5 - Log the loaded state
After conf.Load, log the active configuration at Info or Notice level so operators can confirm what the service picked up.
import (
"log/slog"
"github.com/nathanbrophy/glacier/log"
)
snap := server.Cfg()
ctx = log.With(ctx, slog.String("host", snap.Host), slog.Int("port", snap.Port))
slog.InfoContext(ctx, "configuration loaded")Putting it together
package main
import (
"context"
"log/slog"
"github.com/nathanbrophy/glacier/cli"
"github.com/nathanbrophy/glacier/conf"
"github.com/nathanbrophy/glacier/errs"
"github.com/nathanbrophy/glacier/log"
_ "myapp/db"
_ "myapp/server"
)
// RunCmd is the root CLI command.
//
// +glacier:command name=run
// +glacier:root
type RunCmd struct {
// ConfigFile is the path to the JSON config file.
//
// +glacier:default "config.json"
// +glacier:env APP_CONFIG
ConfigFile string
}
func (r *RunCmd) Run(ctx context.Context) error {
if err := conf.Load(ctx,
conf.WithFile(r.ConfigFile),
conf.WithEnvPrefix("APP"),
conf.WithFlagSource(cli.FlagSource()),
); err != nil {
return errs.Wrap(err, "run: load config")
}
snap := server.Cfg()
ctx = log.With(ctx,
slog.String("host", snap.Host),
slog.Int("port", snap.Port),
)
slog.InfoContext(ctx, "configuration loaded")
return startServer(ctx)
}
func main() { cli.Default.Main() }What's happening underneath
- Leaf · Tier 2
conf: manages the registry ofatomic.Pointer[T]per section;Loadbuilds all new structs and swaps every pointer in one pass. - Kernel · Tier 0
option:conf.WithFile,conf.WithEnvPrefix, andconf.WithFlagSourceare all functional options built onoption.Option[T]. - Kernel · Tier 0
errs: wraps load errors with the"run: load config: "prefix so the call site is clear in log output. - Kernel · Tier 0
log: attaches config values to the context once; every downstream log record carries them automatically.
Related
- Building a CLI - how a CLI command's
Runmethod drives the full startup sequence. - Structured logging - attaching context attributes that flow through the entire request lifecycle.
- Writing tests - using
fixture.NewFSto provide a fake config file in unit tests.