Compose a project config over a global one¶
A tool that runs in a directory usually has two configurations: the user's own settings, shared by every project, and this project's settings, checked into the repo. Passing both around together and remembering which wins is the kind of job that quietly grows bugs.
Instead, make one store a layer of the other. By the end of this tutorial you'll have a project store that carries a global store's layers as its own — with provenance intact across the join — and you'll have promoted a setting up into the shared config, bounded what the shared config is allowed to contribute, and pushed a runtime override above the lot.
Time: about twenty minutes. You need: Go 1.26.5 or newer. Nothing else.
Do Layer defaults, a file, the environment and flags first if you have not — this builds directly on precedence and routed writes.
1. Create a module and two config files¶
global.yaml — stand-in for ~/.config/demo/config.yaml:
project.yaml — stand-in for a .demo.yaml in the repo:
They overlap on exactly one key, theme, which is the interesting one.
2. Nest the global store inside the project store¶
main.go:
package main
import (
"context"
"fmt"
"log"
"gitlab.com/phpboyscout/go/config"
)
func main() {
ctx := context.Background()
fsys, err := config.Dir(".")
if err != nil {
log.Fatal(err)
}
global, err := config.NewStore(ctx, config.WithFiles(fsys, "global.yaml"))
if err != nil {
log.Fatal(err)
}
project, err := config.NewStore(ctx,
config.WithBackend(config.Nested(global, "global", config.NestedPromotable)),
config.WithFiles(fsys, "project.yaml"),
)
if err != nil {
log.Fatal(err)
}
v := project.View()
for _, k := range []string{"editor", "theme", "telemetry.enabled", "build.target"} {
fmt.Printf("%-18s = %-8v %s\n", k, v.Get(k), v.Explain(k))
}
}
editor = vim editor = vim (from global.yaml)
theme = light theme = light (from project.yaml); also defined in global.yaml
telemetry.enabled = false telemetry.enabled = false (from global.yaml)
build.target = wasm build.target = wasm (from project.yaml)
Provenance survives the join. editor is reported as coming from global.yaml — the
actual file — not from "the aggregate" or "the nested store". The inner store's layers pass
through as a contiguous block at the position the backend was declared, each keeping its own
source, so nothing is interleaved and nothing is anonymised.
The "global" id names the aggregate in error messages. It is not a layer name, because an
aggregate contributes no layer of its own.
3. Watch an ordinary write stay local¶
editor is defined only in the global file. Routing's usual rule is edit the key where it
already lives — so does a write to it reach into the global config?
p, _ := project.Plan(config.Set("editor", "helix"))
for _, op := range p.Operations {
fmt.Printf("ordinary write: editor -> %s\n", op.Target)
}
No. A nested store is never a routing candidate, even with NestedPromotable passed.
An ordinary project-scoped edit lands in the project's own file, and creates the key there.
That asymmetry is the whole design. If routing could reach inside, a mundane "set my editor for this project" would walk past the project's file and rewrite the shared config every other project inherits — the destructive version of a helpful default.
4. Promote a setting deliberately¶
Promotion — moving a setting up into the shared config — has to be asked for by name:
promote := config.Set("theme", "solarized", config.To("global.yaml"))
p2, err := project.Plan(promote)
if err != nil {
log.Fatal(err)
}
for _, op := range p2.Operations {
fmt.Printf("promotion: theme -> %s (effective: %v)\n", op.Target, op.Effective())
if !op.Effective() {
fmt.Printf(" shadowed by %s\n", op.ShadowedBy)
}
}
if _, err := project.Apply(ctx, promote); err != nil {
log.Fatal(err)
}
global.yaml really is updated:
# The user's settings, shared by every project.
editor: vim
theme: solarized
telemetry:
enabled: false
But effective: false is the honest part. The project file still sets theme: light, so
reading theme back in this project still gives you light — the promotion will show up
in every other project, and not in this one until the local override is removed. Tell the
user that, rather than letting them wonder why the setting they just saved did nothing.
NestedPromotable is what made this possible at all. Without it a nested store is
strictly read-only and even a named write cannot reach in. That is the safe default and the
usual case — a shared organisational base, a team standard nobody should edit by accident.
5. Bound what the inner store may contribute¶
A nested store contributes everything it can see. When that is a shared config carrying settings this tool has no business reading, wrap it:
config.WithBackend(config.Filtered(
config.Nested(global, "global", config.NestedPromotable),
config.Deny("telemetry.*"),
)),
editor = vim editor = vim (from global.yaml)
theme = light theme = light (from project.yaml); also defined in global.yaml
telemetry.enabled = <nil> telemetry.enabled is not set
build.target = wasm build.target = wasm (from project.yaml)
telemetry.enabled is not merely hidden from reads — it is not set, as far as this store
is concerned, so Has is false and a write would not be accepted for it either.
Allow is the other half: with no Allow, every key is permitted unless a Deny excludes
it; with one, only matching keys get through. Deny beats Allow where they overlap,
so an Allow can never re-expose something explicitly denied. Filtered wraps any backend,
not just a nested store — the same call bounds a Consul prefix a broad token can read.
6. Push a runtime override above everything¶
Some values are decided while the program is running — a --set flag, a value fetched at
startup, a test fixture. AddLayer adds one above every layer declared at construction:
if err := project.AddLayer(ctx, "cli-override", strings.NewReader("theme: high-contrast\n")); err != nil {
log.Fatal(err)
}
theme = high-contrast theme = high-contrast (from override:cli-override); also defined in global.yaml, project.yaml
An override layer is read-only — there is nowhere to persist it — and it survives reloads, because it is re-read each time rather than merged in once. If its content will not parse it is refused and withdrawn, leaving the last-known-good configuration live.
One restriction worth knowing before you hit it: calling AddLayer from inside an observer
returns ErrWriteFromObserver. Observers see a snapshot; letting
one mutate the store mid-notification is how notification ordering stops being defined.
What you built¶
override:cli-override ← AddLayer, runtime, read-only, highest
project.yaml ← the repo's config, writable, routing's target
global.yaml ← via Nested + Filtered, promotable by name only
| Call | What it does |
|---|---|
config.Nested(s, id) |
inner store's layers become layers of the outer one, read-only |
config.NestedPromotable |
a named write may reach in; routing still may not |
config.Filtered(b, ...) |
bounds which keys a backend contributes, and accepts writes for |
config.To("name") |
pins one change to a named layer |
Store.AddLayer |
a read-only layer above everything, added at runtime |
Where to go next¶
- Compose stores — the same mechanics as a task guide, including cycles and reload semantics.
- Filter a backend — pattern syntax and what filtering does to writes.
- Write configuration — routing and targets in full.
- The Store — why one component owns all configuration I/O, which is what makes composition safe.