keryx has a set of house styles for the cover art on this blog, and they live in a library in my home directory. Any project I point it at gets the same eight. That’s what I want, right up until a project has to build on a runner that has never seen my home directory, and then it needs its own copy pinned in the repo.
So that tool now has two configurations, mine and this project’s, and nothing anywhere writes down which one wins.
You can carry both around and remember the rule. I did that for a while. It works until the morning someone asks why a setting they definitely changed is having no effect, and the answer is four levels down a call stack in an if nobody has read since it was written.
The better move is to stop treating them as two things. Make one config a layer of the other, so there is one store, one precedence order, and one place that can tell you where a value came from.
This is the canonical walkthrough from the go/config docs, reproduced here so the two don’t drift: https://config.go.phpboyscout.uk/tutorials/composing-stores/. Every command and output below was validated by running it against v0.17.3, including the bit where a file gets rewritten and keeps its comments.
It builds directly on precedence and routed writes, so do the layering walkthrough first if you haven’t. Twenty minutes, Go 1.26.5 or newer, and nothing else.
1. Create a module and two config files
mkdir cfgcompose && cd cfgcompose
go mod init cfgcompose
go get gitlab.com/phpboyscout/go/config
global.yaml, standing in for ~/.config/demo/config.yaml:
# The user's settings, shared by every project.
editor: vim
theme: dark
telemetry:
enabled: false
project.yaml, standing in for a .demo.yaml in the repo:
# This project's settings, checked into the repo.
theme: light
build:
target: wasm
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))
}
}
go run .
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, rather than 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.
That is worth pausing on. The whole reason to compose rather than juggle is to keep the straight answer at the end of it, and a join that flattens everything into one anonymous blob has thrown away the thing you were trying to protect.
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 to edit a 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)
}
ordinary write: editor -> project.yaml
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 most of the 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. That is the destructive version of a helpful default, and you only find out about it weeks later, when three unrelated repositories have quietly changed their minds about something.
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)
}
promotion: theme -> global.yaml (effective: false)
shadowed by [project.yaml]
global.yaml really is updated, comment and all:
# The user's settings, shared by every project.
editor: vim
theme: solarized
telemetry:
enabled: false
But effective: false is the bit you want. 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 goes.
Tell the user that, because it is the difference between a tool that saved their setting and one that appears to have ignored them.
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, which is the safe default and the usual case: a shared organisational base, a team standard nobody should be editing 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. Hiding a value from reads while still letting something write to it is the worst of both, so the filter applies to the whole surface.
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 that a broad token happens to be able to read.
6. Push a runtime override above everything
Some values get decided while the program is running: a --set flag, something fetched at startup, a test fixture. AddLayer puts 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, because 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 rather than half-applying something broken.
One restriction worth knowing before you hit it: calling AddLayer from inside an observer returns ErrWriteFromObserver. Observers see a snapshot, and 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
The docs carry the task-shaped versions of all of this: composing stores including cycles and reload semantics, filtering a backend with the full pattern syntax, and writing configuration with routing and targets in full. The Store is the why underneath: one component owning all configuration I/O is what makes composing them safe.
And if you want the argument for config being its own module rather than something the framework hands you, that one’s separate.
As for keryx, it now does just this. The eight styles come from my library, the project pins whatever it needs to build unattended, and when I can’t remember which one a cover actually used, I can ask.





