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
The thirty-second version¶
A user changes one setting. Here is what that does to their file:
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¶
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.
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 stores — AWS 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
UnmarshalSectionandObserveSection. - Hot-reload —
Watch, observers, and reacting to foreign changes. - Validate —
SchemaandValidateStruct[T]fromconfig:struct tags. - Write a custom backend — make Consul, a secrets manager or an HTTP endpoint an ordinary layer.
- Test with the mocks —
MockReader,MockBinderandMockObservedin your tests. - The Store — why one component owns config I/O, and what follows from that rule.
- Provenance — what
Origin,ShadowedandExplaincan 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.