Skip to content

phpboyscout · go toolkit

Configuration that shows its work

Assemble settings from defaults, files, the environment, flags and remote systems like Consul — with a precedence you read off the call site, every value able to say where it came from, and writes that never wreck the file.

go get gitlab.com/phpboyscout/go/config
config — layered plates resolving into one container

The thirty-second version

A user changes one setting. Here is what that does to their file:

db:
    password: hunter2-prod-secret
features:
    beta_ui: false
log:
    level: info
server:
    host: localhost
    port: 9090
# Which port the public listener binds to.
# Changing this needs a firewall change too — talk to platform first.
server:
  host: localhost # loopback only in dev
  port: 9090

# Feature flags. Keep alphabetical.
features:
  beta_ui: false

Comments gone, order alphabetised, a default pinned into the file — and a production password that arrived from an environment variable, now committed to git.

That is not a bug in anything. It is what "serialise the merged view" means, and it follows from merging eagerly: fold every source into one map and a writer holding that map has nothing to write but the whole of it. This module never folds its layers away — so it changes the key you asked for, and nothing else.

What you get

Provenance Explain("server.port")9090 (from ~/.app.yaml); also defined in embedded:defaults.yaml. Recorded during the merge, not reconstructed after.
Coherent reads A View is pinned to one snapshot, so two related values can never straddle a reload. Measured: 0 mismatched pairs in 11.6M reads, against 1,759 for a library reading live state.
Writes that preserve authorship Comments, key order, quoting and anchors survive. The change lands in the layer that owns the key, and a write that cannot take effect says so.
Notifications you can trust Exactly once per logical change, never for a rejected one, never out of order, pinned to one snapshot for the whole callback.
Fail-closed reload A file that will not parse, or fails your schema, is rejected — last-known-good stays live. Never half of one config and half of another.
Any source as a layer WithBackend takes any three-method Backend, so Consul, a secrets manager or an HTTP endpoint gets full precedence, provenance and shadowing.
Typed sections ObserveSection[T] keeps your struct current across reloads, and the package consuming it never imports this one.
Read any type Value[T] reads whatever T is — durations, IP addresses, URLs, and your own encoding.TextUnmarshaler types.
No filesystem imposed You name the filesystem — config.OS() for the real disk (the default choice), config.Dir(path) for a directory nothing can escape, or an adapter for elsewhere. config.FS is six methods, so your own works too.

→ The full case, with the reasoning and the measurements

At a glance

store, err := config.NewStore(ctx,
    config.WithReaders(config.NamedSource{Name: "embedded:defaults.yaml", Content: defaults}),
    config.WithFiles(config.OS(), "/etc/mytool/config.yaml", userPath),
    config.WithEnv("MYTOOL"),
    config.WithFlags(cmd.Flags()),
)
if err != nil {
    return err
}

// Precedence is the order you added the sources: flags beat env, env beats the
// user file, the user file beats /etc, and all of them beat the embedded defaults.
port := store.View().GetInt("server.port")

// Ask why it is what it is.
fmt.Println(store.View().Explain("server.port"))
// server.port = 9090 (from /home/me/.mytool/config.yaml); also defined in embedded:defaults.yaml

// Write it back to the layer that owns it, comments intact.
if _, err := store.Apply(ctx, config.Set("server.port", 9090)); err != nil {
    return err
}

Any format, any system — the same layered store

YAML is the default, not the ceiling. A file in another format, or configuration that lives in a remote system rather than a file at all, joins the store as an ordinary layer: same precedence, the same per-key provenance, the same coherent snapshots and fail-closed reload. This is where the model pulls decisively ahead of a library that bolts remote sources on as a special case — here there is no special case, and Explain will name Consul or a parameter store as the source exactly as it names a file.

Every adapter is its own sibling module, so your dependency graph carries only what you use: a consumer reading TOML never compiles the XML parser, and a consumer configuring from Consul never pulls a cloud SDK it does not touch.

File & format adapters — available now

  • JSON — JSON and JSON Lines, read and write, structure-preserving.
  • TOML — read and write TOML, structure-preserving.
  • HCL — HCL as a config format, read and write.
  • XML — read XML.
  • dotenv — read .env, no added dependency.
  • INI — read INI, no added dependency.
  • Java properties — read .properties.

Filesystem adapters — where the file lives

A format adapter parses the file; a filesystem adapter decides where it lives, over the six-method config.FS. The core covers local disk (config.OS(), config.Dir) — reach for one of these when the file lives somewhere else. They compose with any format adapter.

  • afero — bridge an existing afero filesystem.
  • io/fs — an embed.FS, zip or tar, read-only.
  • go-billy — a go-git / go-billy filesystem, read and write.
  • SFTP — a config file on a remote host over SSH.
  • cloud object storesS3, GCS & Azure Blob, read and write.

Dynamic backends — remote systems as layers

Fetch configuration at runtime from a remote system and give it full precedence, provenance and hot-reload, exactly as a file gets. The reference — config-consul — and the cloud parameter stores are released now at v0.1.0:

  • Parameter storesAWS SSM, Azure App Configuration and GCP Parameter Manager, Consul's siblings.
  • Secrets managers (roadmap) — Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager (read-only by default).
  • Cloud-native key–value (roadmap) — etcd and Kubernetes ConfigMaps, with native change-watch.

→ The full adapter ecosystem, with status and roadmap

Should you use this?

Yes, if any of these describe you — and note that only the first is about writing:

  • you have more than two sources and have lost an afternoon to "which one set this?";
  • a long-running service reloads configuration, and you need reads that never straddle a reload, notifications you can trust, and a broken file that cannot take the service down;
  • your program writes configuration back, and users have to live in the file afterwards;
  • configuration lands in files that get committed, reviewed, or shared between people;
  • you want configuration-dependent tests that run in parallel and do not reach for process globals;
  • your settings have real types — enums, addresses, URLs — and you are tired of decoding them by hand.

Probably not, if one file and a couple of environment variables cover you, nothing is ever written back, and nothing reloads. That is a large share of programs, and it is a genuinely well-served case: viper is battle-tested by an enormous number of them, has an ecosystem this module does not, and will be a smaller dependency in your graph. Reach for the smaller tool when the smaller tool fits — see History for how much of what is here was learned from years of using it.

Where next

The documentation follows the Diátaxis quadrants: learn, then do, then understand.

  • Getting started — the tutorial. Build a store over a file, read values, ask where they came from, write a change back, and react to one made outside your process.
  • Load & merge — files, embedded defaults, the environment, flags, and the precedence chain.
  • Read values — typed accessors, the generic Value[T], scoped views and struct decoding.
  • Write configuration — planning a write, where a change lands, conflicts, and shadowed writes.
  • Typed sections — project a subtree onto your own struct with UnmarshalSection and ObserveSection.
  • Hot-reloadWatch, observers, and reacting to foreign changes.
  • ValidateSchema and ValidateStruct[T] from config: struct tags.
  • Write a custom backend — make Consul, a secrets manager or an HTTP endpoint an ordinary layer.
  • Test with the mocksMockReader, MockBinder and MockObserved in your tests.
  • The Store — why one component owns config I/O, and what follows from that rule.
  • Provenance — what Origin, Shadowed and Explain can and cannot answer.

Coming from v0.2.x

v0.3.0 is a breaking release. Two things changed: the Viper-backed container became the Store, and afero.Fs became a six-method config.FS the module defines itself.

The migration guide covers both step by step — Containable becomes Reader, the several constructors become NewStore, afero.NewOsFs() becomes config.OS(), and the handful of call sites that are genuine ports rather than renames each get their own section.

Two traps with no compiler error

Watching is explicit now. v0.2.x started a watcher inside every constructor, so you got hot-reload without a call site to port. Skip Store.Watch(ctx) and the code compiles, the tests pass, and configuration silently stops reloading.

ApplyInitial is not a required fix. Section delivery has always been change-only, so nothing needs accommodating. Add it only if you want startup delivery, which v0.2.x could not do at all.

If your reusable packages declare their own one-method interface and take an ObservedSection[T] structurally, they need no change at all. That decoupling boundary is the reason the module exists, and it held.

For how the module got here, see History.

Reference

The Go API reference lives on pkg.go.dev, which is where a language API reference belongs — generated from the source it documents, so it cannot drift from it.