Skip to content

Migrating from v0.2.x

v0.2.x was a wrapper around Viper. This release is not: a Store owns every read, write and watch, and Viper is no longer a dependency — it is absent from go.mod, go.sum and every source file.

Be realistic about the size of this. Most of the surface renames mechanically, but a handful of call sites are genuine ports rather than substitutions, and every one of them is a place where the old behaviour was doing something you probably did not intend. Measured against the main consumer, gitlab.com/phpboyscout/go-tool-base: of 160 config.Containable references, about 89 are bare parameter declarations that become config.Reader by renaming the type, and only 12 call a method Reader does not have. Those 12 are the work.

If you use the local-interface pattern, you are unaffected

The typed-section semantics are frozen. ObservedSection[T], delivery only when the decoded struct actually changes, the monotonic Version(), and the WithSection* options all behave exactly as they did. What changed is the parameter types they are handed.

So a package that declares its own one-method interface and never imports this module —

type SettingsSource interface {
    Current() *ServerSettings
}

— needs no change at all. *config.ObservedSection[T] still satisfies it structurally. That decoupling boundary is the reason the module exists, and it held. If your reusable packages are written this way, the port is confined to the code that constructs configuration, not the code that consumes it.

Step 1 — construction

There is one constructor now. Sources are added in precedence order and later ones win, so the order of the options is the precedence chain — there is no separate ranking to remember.

Before:

cfg, err := config.LoadFilesContainer(fs,
    config.WithConfigFiles(paths...),
    config.WithEnvPrefix("MYTOOL"),
    config.WithSchema(schema),
)

After:

store, err := config.NewStore(ctx,
    config.WithReaders(config.NamedSource{Name: "embedded:defaults.yaml", Content: defaults}),
    config.WithFiles(config.OS(), paths...),   // was an afero.Fs — see step 2
    config.WithEnv("MYTOOL"),
    config.WithFlags(flags),
    config.WithSchema(schema),
)
if err != nil && !errors.Is(err, config.ErrInvalidConfig) {
    return err
}

cfg := store.View()

Two things to note.

NewStore loads immediately; there is no unloaded state to handle. And a configuration that parses but fails schema validation is returned alongside ErrInvalidConfig rather than discarded — the usual if err != nil { return } still fails fast, but a tool whose job is to repair configuration can check for ErrInvalidConfig specifically and carry on with a usable Store. Every other failure returns nil.

RequireFirstSource() makes the first backend mandatory, which is the closest equivalent to the old required-base-file behaviour. Note that it applies to whichever backend you added first — if that is your embedded defaults, it is those that become mandatory.

There is no equivalent of LoadEnv. If you relied on a .env file being read from the working directory, load it yourself before calling NewStore.

Step 2 — the filesystem

WithFiles no longer takes an afero.Fs. It takes config.FS, a six-method interface this module defines, and the module ships two implementations.

For the overwhelmingly common case the change is one call:

config.WithFiles(afero.NewOsFs(), paths...)   // before
config.WithFiles(config.OS(), paths...)       // after

If you passed afero only to reach the real filesystem, you can drop the dependency entirely.

If you have an afero.Fs to pass through

Wrap it. The adapter is six methods, and two optional ones worth including — without RealPath your filesystem is polled rather than watched natively, and without Readlink a write through a symlink replaces the link instead of following it:

type aferoFS struct{ fs afero.Fs }

func (a aferoFS) ReadFile(name string) ([]byte, error) { return afero.ReadFile(a.fs, name) }
func (a aferoFS) Stat(name string) (fs.FileInfo, error) { return a.fs.Stat(name) }
func (a aferoFS) Rename(old, new string) error          { return a.fs.Rename(old, new) }
func (a aferoFS) Remove(name string) error              { return a.fs.Remove(name) }

func (a aferoFS) WriteFile(name string, data []byte, perm fs.FileMode) error {
    return afero.WriteFile(a.fs, name, data, perm)
}

func (a aferoFS) MkdirAll(path string, perm fs.FileMode) error {
    return a.fs.MkdirAll(path, perm)
}

Only implement RealPath when the filesystem really has OS paths

RealPather is how the module decides whether native notification can work at all. An adapter that implements it unconditionally — returning false from the method rather than not having the method — makes every filesystem look watchable, so fsnotify is selected and the absence of anything to watch is discovered one path at a time.

Give the method to a type that wraps *afero.OsFs or *afero.BasePathFs, and use a plain type for anything else.

Testing

If you used afero.NewMemMapFs() in tests, config.Dir(t.TempDir()) replaces it with no dependency at all:

fsys, err := config.Dir(t.TempDir())
require.NoError(t, err)
require.NoError(t, fsys.WriteFile("config.yaml", []byte(body), 0o600))

store, err := config.NewStore(ctx, config.WithFiles(fsys, "config.yaml"))

It is a real directory, so watching works and a write behaves exactly as it does in production — which an in-memory filesystem cannot exercise.

Dir takes paths relative to its root

Pass "config.yaml", not "/tmp/xyz/config.yaml". An absolute path is treated as an attempt to escape the root and fails with path escapes from parentnot fs.ErrNotExist. The distinction matters: a missing optional source is normal and skipped, whereas an escape is fatal, so an absolute path turns a merely-absent file into a hard failure.

Step 3 — the mechanical rename

Containable is gone. The read surface is Reader, and it is deliberately free of any dependency's types: nothing in it exposes an underlying library, because anything reachable through such an escape hatch becomes part of your contract.

Before:

func ServerSettingsFromConfig(cfg config.Containable, prefix string) ServerSettings {
    return ServerSettings{Port: cfg.GetInt(prefix + ".port")}
}

After:

func ServerSettingsFromConfig(cfg config.Reader, prefix string) ServerSettings {
    return ServerSettings{Port: cfg.GetInt(prefix + ".port")}
}

This covers the large majority of references. Get, GetString, GetBool, GetInt, GetFloat, GetDuration, GetTime, Has, IsSet, SectionExists, Unmarshal and UnmarshalKey all keep their names and signatures. GetStringSlice and Keys are new to the interface, and Origin, Shadowed and Explain are new provenance methods.

One trap worth stating plainly: Sub now returns *View, which satisfies Reader but not Binder. An adapter chain that takes a scoped container and passes it down must be typed Reader all the way. If a function in that chain needs to register an observer, it must take the *Store (or a Binder) instead, not a scoped view.

In tests, mocks.MockContainable becomes mocks.NewMockReader, with mocks.NewMockBinder and mocks.NewMockObserved alongside it.

Step 4 — what was removed, and what replaces it

Removed Use instead Note
Containable Reader Rename; the accessor set is a superset
Containable.GetViper() typed accessors, Store.Snapshot(), Store.AddLayer See below — the five real uses each have a first-class answer
Containable.Set(key, value) Store.Apply(ctx, config.Set(...)) Staging and persisting are one step now
Containable.WriteConfigAs(dest) Store.Apply The old method wrote the merged view, including env-sourced secrets
Containable.BindPFlag(key, flag) WithFlags(flags, BindFlag(...)) at construction Bindings now survive a reload
Containable.ToJSON() / Dump(w) Store.Snapshot().Values() and marshal it yourself The module no longer picks an output format for you
Containable.ConfigFiles() Store.Sources(), or Snapshot().Layers() filtered on Kind == SourceFile Layers carry provenance, not just paths
Containable.Sub(key) View.Sub(key) *View Still nil when absent, so if sub != nil guards behave
Containable.Validate(schema) View.Validate(schema) Not on Reader; a validating function must take *View
Container.Close() the stop func returned by Store.Watch Watching is now started explicitly, not implicitly
Container.SetSchema(schema) WithSchema(schema) at construction
Container.GetObservers() Store.Observers()
NewFilesContainer, NewReaderContainer NewStore with WithFiles / WithReaders
LoadFilesContainer, LoadFilesContainerWithSchema NewStore with WithSchema
Load, LoadEmbed NewStore with WithFiles / WithReaders Embedded assets become NamedSource values
LoadEnv Load .env yourself before constructing
NewContainerFromViper There is no Viper to construct from
WithConfigFiles(files...) WithFiles(fs, paths...) Filesystem and paths are now one option
WithConfigReaders(readers...) WithReaders(NamedSource...) Each source now carries a name, which appears in provenance
WithEnvPrefix(prefix) WithEnv(prefix) Still a security control; still no trailing underscore
WithConfigFormat(format) YAML only
WithLogger(l) Store.OnReloadError, Store.OnObserverError The Store reports; it does not log on your behalf
WithReloadDebounce(d) Watch(ctx, WithPollInterval(d)) DefaultPollInterval is 2s
Observable.Run(Containable) Observable.Run(Observed)
AddObserverFunc(func(Containable) error) AddObserverFunc(func(Observed) error)
Observer struct ObserverFunc
ValidateStruct[T](cfg Containable, ...) ValidateStruct[T](cfg *View, ...) Takes the concrete view
UnmarshalSection[T](cfg Containable, ...) UnmarshalSection[T](cfg Reader, ...) Rename only
WithSectionDefaultFunc(func(Containable) T, merge) WithSectionDefaultFunc(func(Observed) T, merge) Rename only
ErrConfigFileNotFound, ErrNoFilesFound ErrNoSources, and fs.ErrNotExist from a backend
DefaultReloadDebounce DefaultPollInterval

Step 5 — the genuinely breaking cases

GetViper()

Across all repositories this escape hatch was reached for exactly five distinct methods. Three of them are now first-class:

Old New
cfg.GetViper().GetStringSlice(key) cfg.GetStringSlice(key)
cfg.GetViper().Set(key, value) store.Apply(ctx, config.Set(key, value))
cfg.GetViper().AllSettings() store.Snapshot().Values()
cfg.GetViper().ConfigFileUsed() cfg.Origin(path) — or store.Sources() for the whole list
cfg.GetViper().MergeConfig(r) store.AddLayer(ctx, "computed", r)

ConfigFileUsed() is the interesting one. It answered "the last file read", which for a merged configuration is misleading. The honest question is per-key, and provenance answers it:

if src, ok := cfg.Origin("server.port"); ok {
    fmt.Printf("server.port came from %s\n", src)
}

fmt.Println(cfg.Explain("server.port")) // the whole chain, including shadowed layers

AddLayer is not a like-for-like replacement for MergeConfig. It contributes an in-memory layer at the highest precedence and it survives reloads, which is what you want for values your tool computed at runtime. It is never a write target, so a later write lands in the file beneath it and is reported as shadowed rather than silently having no effect. Compiled-in defaults belong in WithReaders at construction instead, where they sit at the bottom of the order.

WriteConfigAs — read this one carefully

This is the most important change in the release, and it is a bug fix as much as a port.

WriteConfigAs serialised Viper's AllSettings(), which is the merged view. That means it wrote environment-sourced values — including secrets injected as env vars — into a file on disk, alongside every compiled-in default the user never asked for. It also dropped empty maps, because AllSettings() omits key: {}. Consumers worked around this by constructing a second, throwaway Viper containing only the keys they meant to persist, and writing that instead.

None of that is needed now. Apply routes each change to a layer, in reverse merge order, picking the first writable match. Env and flags are never targets, so a value that reached you from the environment cannot be written into a file by accident.

Before:

p.Config.Set("telemetry.enabled", enabled)

v := p.Config.GetViper()

// A fresh Viper holding only the telemetry keys, so the write does not
// persist embedded defaults into the user's config file.
fresh := viper.New()
fresh.SetFs(p.FS)
fresh.SetConfigType("yaml")
fresh.Set("telemetry.enabled", v.GetBool("telemetry.enabled"))

if err := fresh.WriteConfigAs(configFile); err != nil {
    return errors.Wrap(err, "failed to write config")
}

After:

if _, err := store.Apply(ctx, config.Set("telemetry.enabled", enabled)); err != nil {
    return errors.Wrap(err, "failed to write config")
}

The write preserves comments, key order and block style in the target file, and only the key you named is touched.

If you need to know where a write will land before committing it — a --dry-run, or a settings screen previewing its own changes — Plan returns exactly what Apply would execute:

plan, err := store.Plan(config.Set("server.port", 9090))
if err != nil {
    return err
}

fmt.Println(plan.String())

for _, op := range plan.Operations {
    if !op.Effective() {
        fmt.Printf("%s will be written to %s but %v still wins\n",
            op.Change.Path, op.Target, op.ShadowedBy)
    }
}

Reporting that is worth doing. "Written, but the environment still overrides it" is the difference between a user understanding what happened and concluding your tool is broken.

Writes fail with ErrNoWritableLayer, ErrNotWritable, ErrConflict, ErrNoChanges, ErrInvalidPath or ErrPartialCommit; branch on them with errors.Is.

One rule to know before you port an observer: writing from inside an observer callback returns ErrWriteFromObserver. Each such write notifies, which runs the observer again, which is a cascade with no natural end. Capture what you need, return, and write from somewhere else.

BindPFlag

Flags are now a backend, declared at construction. This fixes a real defect: under the old container a reload rebuilt Viper from files and silently discarded every flag binding, so a --port the user passed stopped applying the first time the config file changed.

Only flags the user actually changed contribute — that filtering is now the backend's job, not yours. Binding a flag left at its default is how a flag default silently masks configuration, and the user has no way to discover why their file is being ignored.

Before:

for key, flag := range boundFlags {
    if flag == nil || !flag.Changed {
        continue
    }

    if err := cfg.BindPFlag(key, flag); err != nil {
        log.Debug("failed to bind flag to config", "flag", flag.Name, "key", key, "error", err)
    }
}

After — at construction, adding the flag backend last so an explicit flag outranks everything:

store, err := config.NewStore(ctx,
    config.WithFiles(fs, paths...),
    config.WithEnv("MYTOOL"),
    config.WithFlags(flags,
        config.BindFlag("log-level", "logging.level"), // only where name and key differ
    ),
)

The hyphen-to-dot convention (server-portserver.port) is applied for you, so BindFlag is needed only for the exceptions. The local bindable interface some consumers declared for this has nothing left to describe and should be deleted.

ToJSON and Dump

The module no longer chooses an output format for you. Take the values and marshal them:

data, err := json.Marshal(store.Snapshot().Values())
if err != nil {
    return err
}

fmt.Fprintln(w, string(data))

Values() returns a copy, so mutating it cannot affect the snapshot. Note that this is the merged view — if you are producing output for a user, mask anything whose Origin is SourceEnv before printing it.

Set

Set was stage-then-persist: it mutated the in-memory Viper and left you to write the file separately. Every measured downstream use was the first half of that pair, and not one was a genuine ephemeral override. Apply does both in one step:

snap, err := store.Apply(ctx,
    config.Set("github.auth.method", "token"),
    config.Remove("github.auth.password"),
)

Batched changes land together. If you genuinely wanted an in-process override that is never persisted, that is AddLayer.

Step 6 — ObserveSection

The semantics are unchanged. What changed is the first parameter: ObserveSection now takes a Binder, which is anything with View() *View and AddObserverFunc(func(Observed) error). *Store satisfies it, so at the call site this is usually just passing the store instead of the container — and the closures passed to WithSectionDefaultFunc and friends take a config.Observed where they took a config.Containable.

ApplyInitial is new capability, not a required fix

The apply callback fires on change and not on binding — and that was true in v0.2.x too, where apply was likewise only reachable from inside the registered observer. If your bindings already work, this step does not break them, and you need no ApplyInitial call to preserve current behaviour.

Add it only if you want the callback to run once at startup, which v0.2.x could not do at all. See configure your component at startup.

Before:

func ObserveServerSettings(
    cfg config.Containable,
    prefix string,
) (*config.ObservedSection[ServerSettings], error) {
    return config.ObserveSection[ServerSettings](cfg, prefix,
        config.WithSectionDefaultFunc(func(next config.Containable) ServerSettings {
            if next == nil {
                return ServerSettings{}
            }

            return ServerSettings{Port: next.GetInt("server.port")}
        }, mergeServerSettings),
    )
}

After:

func ObserveServerSettings(
    binder config.Binder,
    prefix string,
) (*config.ObservedSection[ServerSettings], error) {
    return config.ObserveSection[ServerSettings](binder, prefix,
        config.WithSectionDefaultFunc(func(next config.Observed) ServerSettings {
            if next == nil {
                return ServerSettings{}
            }

            return ServerSettings{Port: next.GetInt("server.port")}
        }, mergeServerSettings),
    )
}

Taking Binder rather than *Store keeps the function testable: a fixed snapshot in a test satisfies the interface without dragging in the machinery that loads one, and mocks.NewMockBinder is generated for exactly this.

Current(), Value(), Exists() and Version() are untouched, as are WithSectionDefaults, WithSectionEqual, WithSectionValidator and WithSectionApply.

Step 7 — watching

If you do not add a Watch call, hot-reload silently stops working

This is the one migration step with no compiler error and no runtime error to tell you it was missed. v0.2.x started a watcher during constructionwatchConfig ran from every constructor — so consumers got hot-reload without asking and without a call site to port.

There is nothing to rename here. There is a call to add, and if you skip it the code compiles, the tests pass, and configuration simply stops reloading.

Search your codebase for anything that assumed reload: ObserveSection, an observer, or a component re-reading configuration. If any of it exists, you need Watch.

Watching is explicit now, and it fails loudly when it cannot work. The old container's implicit watcher silently did nothing on a filesystem the operating system could not see — so hot reload was quietly dead in exactly the tests and virtual-worktree tools that most needed it. Explicit is the fix for both problems: you can see the call, and it tells you when it cannot do its job.

stop, err := store.Watch(ctx)
if err != nil {
    return err // ErrWatchUnavailable: better than believing you will hear about changes
}

defer stop()

Pass WithPollInterval(d) to tune polling, or WithWatcher(w) to drive change detection deterministically in tests. NewWatcher picks native notification where it works and polling everywhere else. Store.Close() does not exist; the returned stop is the whole lifecycle.

WithReloadDebounce becomes WithSettleInterval, and moves from construction to Watch — it only ever applied to foreign changes, and now sits where those are handled.

What you gain

The port buys more than parity.

Layer-correct writes that preserve comments. A write lands in one layer, touching one key, and the file's comments, key order and block style survive it. Repeated writes converge rather than drifting.

Per-key provenance. Origin names the layer that supplied a value, Shadowed lists every layer that defines it, and Explain renders the chain. "Which file do I edit?" and "why is my edit not taking effect?" are the same question from two directions, and both are now answerable.

Multi-document YAML. The old stack read the first document of a file and discarded everything after the first --- with no error at all. Every document is now a layer, in order, with later documents overriding earlier ones. Nothing is silently lost.

Reload no longer discards flag bindings. Flags are a backend that is re-read like any other, so a --port the user passed keeps applying across reloads.

Empty containers are values. key: {} survives a write and appears in Keys(), instead of being silently deleted.

Limits worth knowing before you port

  • A multi-line flow collection with interior comments cannot be round-tripped safely and is refused at load with ErrBackendUnsafe, naming the location. Reformat it to block style.
  • A map-valued Set replaces the whole subtree; comments and anchors inside it may not survive. Set leaves where you can.
  • Individual reads are always coherent, but a sequence of them can straddle a reload. Use store.With(func(v *config.View) error { ... }) for a block of related reads.
  • Serialisation is in-process only. It does not coordinate between processes.
  • Invisible and bidirectional characters are escaped on write — a Trojan Source mitigation (CVE-2021-42574). The escape is lossless; every visible character survives verbatim.