Viper — Patterns#
Concurrency patterns#
Viper is deliberately low-concurrency. Only 4 goroutines are spawned across the entire codebase, and they are entirely confined to the WatchConfig live-reload path and the remote-provider watch path.
Nested goroutines with WaitGroup synchronisation#
- Usage:
WatchConfig()(viper.go:283) - Example:
viper.go:285— outer goroutine initialises thefsnotify.Watcherand adds the config directory; inner goroutine atviper.go:306runs the event loop. - Assessment: The outer goroutine uses
initWG.Done()to signal the caller only after the watcher is set up (or after an error). The inner goroutine uses its owneventsWGto signal completion when the events channel closes. This two-level WaitGroup preventsWatchConfigfrom returning before the watcher is ready — a subtle but important correctness detail.
// viper.go:283
initWG := sync.WaitGroup{}
initWG.Add(1)
go func() {
watcher, _ := fsnotify.NewWatcher()
...
eventsWG := sync.WaitGroup{}
eventsWG.Add(1)
go func() {
for {
select {
case event, ok := <-watcher.Events: ...
case err, ok := <-watcher.Errors: ...
}
}
}()
initWG.Done() // signal outer caller
eventsWG.Wait() // block outer goroutine until inner exits
}()
initWG.Wait()Channel fan-in with select (fsnotify event loop)#
- Usage: Inner event loop in
WatchConfig; mirrored inremote/remote.go:59 - Example:
viper.go:308— multiplexeswatcher.Eventsandwatcher.Errorschannels. - Assessment: Classic Go channel demultiplexing via
select. Correct use of the two-value receiveevent, ok := <-chto detect channel closure. No goroutine leak: both arms calleventsWG.Done()on exit.
Remote watch channel adaptation (remote module)#
- Usage:
remote/remote.go:47–78 - Example: Converts
crypt.Responsechannel toviper.RemoteResponsechannel; uses aquitchan boolfor shutdown signalling. - Assessment: Simple adapter goroutine bridging two channel types.
quitchannel pattern (rather thancontext.Context) shows the remote module predates wide context adoption; functions correctly but is less idiomatic by current standards.
Not found: Worker pools, Fan-out/Fan-in, Pipeline, Rate limiting, Context cancellation#
Viper has only 2 context.Context usages and no errgroup — appropriate for a configuration library with no concurrent data processing.
Sync primitives#
sync.RWMutex on DefaultCodecRegistry#
- File:
encoding.go:96 - Pattern:
RWMutexguards thecodecs map[string]Codec. Writes (RegisterCodec) take the write lock; reads (Encoder,Decoder,codec()) take the full write lock (same call tor.mu.Lock()— a minor oversight;r.mu.RLock()would suffice for reads). Thread-safe, but not optimally so.
sync.Once for lazy initialisation#
- File:
encoding.go:97 - Pattern:
once.Doinitialises thecodecsmap on first use. Combined with a publicNewCodecRegistry()constructor that callsinit()eagerly — so the Once mainly protects against zero-value struct usage without the constructor.
func (r *DefaultCodecRegistry) init() {
r.once.Do(func() {
r.codecs = map[string]Codec{}
})
}sync.WaitGroup for goroutine lifecycle#
- File:
viper.go:283,304 - Pattern: Described above under concurrency. Two independent WaitGroups gate the initialisation and teardown phases of
WatchConfig.
Error handling#
Style: Rich custom type hierarchy + fmt.Errorf %w wrapping#
Viper defines a deliberate error type taxonomy in errors.go:
| Type | Kind | Purpose |
|---|---|---|
FileLookupError | Interface | Marker interface for all file-lookup failures |
FileNotFoundFromSearchError | Struct | File not found in any search path |
ConfigFileNotFoundError | Struct | Deprecated wrapper — delegates via Unwrap() |
FileNotFoundError | Struct | Specific file path not found |
ConfigFileAlreadyExistsError | String type | File already exists on write |
ConfigMarshalError | Struct (wraps error) | Marshalling failure |
UnsupportedConfigError | String type | Unknown format string |
ConfigParseError | Struct (util.go) | YAML/JSON parse failure |
UnsupportedRemoteProviderError | String type | Unknown remote backend |
RemoteConfigError | String type | Generic remote error |
Deprecation migration via Unwrap: ConfigFileNotFoundError.Unwrap() returns a FileNotFoundFromSearchError, allowing callers to migrate to errors.As(&FileNotFoundFromSearchError{}) while older code using errors.As(&ConfigFileNotFoundError{}) continues to work. This is an unusually thoughtful deprecation strategy.
String-typed sentinel errors (UnsupportedConfigError string) satisfy error via a value receiver. They carry context (the bad format string) without heap allocation for a struct.
Wrapping: fmt.Errorf("%w", err) throughout viper.go and remote.go; errors.Is(err, fs.ErrNotExist) used at viper.go:1596 for stdlib error identity checks.
Example:
// errors.go
type ConfigFileNotFoundError struct { name, locations string }
func (e ConfigFileNotFoundError) Unwrap() error { return FileNotFoundFromSearchError(e) }
// usage — errors.As works for both old and new type
assert.ErrorAs(t, err, &ConfigFileNotFoundError{}) // viper_test.go:1681Configuration pattern#
Approach: Functional options via Option interface + post-construction setter methods
The Option interface (viper.go:190) follows a named-interface variant of the functional options idiom rather than the more common bare func(*Viper) type:
type Option interface {
apply(*Viper)
}
type optionFunc func(*Viper)
func (fn optionFunc) apply(v *Viper) { fn(v) }
func WithLogger(l *slog.Logger) Option {
return optionFunc(func(v *Viper) { v.logger = l })
}This allows Option to appear in function signatures and documentation in a self-documenting way. Available options at construction time: KeyDelimiter, EnvKeyReplacer, WithDecodeHook, WithLogger, WithFinder, WithEncoderRegistry, WithDecoderRegistry, WithCodecRegistry.
Post-construction configuration uses plain setter methods: SetConfigName, AddConfigPath, SetEnvPrefix, AutomaticEnv, SetDefault, BindEnv, BindPFlag.
This two-phase approach (options for dependencies, setters for behaviour) cleanly separates immutable structural concerns (codec registry, filesystem) from mutable runtime concerns (search paths, env prefix).
Dependency injection#
Approach: Manual wiring via functional options — no framework
All dependencies flow in at NewWithOptions call time:
afero.Fs— injected if non-nil, else defaults toafero.OsFsEncoderRegistry/DecoderRegistry/CodecRegistry— swappable codec backendsslog.Logger— observabilityFinder— experimental file locator
No wire, dig, or fx. The functional options pattern is the DI mechanism. Because Viper is a leaf library, there is no need for a more elaborate DI framework.
Other notable patterns#
Registry pattern (DefaultCodecRegistry)#
encoding.go:90–160 implements a thread-safe, lazy-initialised codec registry. Codecs are registered by string key (format name, case-insensitive). The registry is injected into Viper at construction, making it straightforward to swap the whole codec set or add new formats at runtime without changing the core.
The same “package-level variable as registry” pattern appears for remote providers: var RemoteConfig remoteConfigFactory (remote.go:26) acts as a process-wide slot that the remote/ module fills via its init() function on blank import.
Blank-import self-registration (Plugin pattern)#
// consumer code
import _ "github.com/spf13/viper/remote"
// remote/remote.go init()
func init() {
viper.RemoteConfig = &remoteConfigProvider{}
}This is the Go plugin pattern without shared libraries. The remote module self-registers by setting a package-level variable. It’s simple and reliable but allows only one remote backend at a time (last init() wins).
Interface as marker / categorisation#
FileLookupError (errors.go:11) is a marker interface with a private fileLookup() method. This prevents external types from accidentally satisfying the interface while still allowing errors.As targeting. Callers can write:
var lookupErr viper.FileLookupError
if errors.As(err, &lookupErr) { ... }Type switches for untyped map traversal#
Viper’s config is stored as map[string]any. Deep nested key lookup at viper.go:469–631 uses type switches extensively to distinguish between map[string]any, map[any]any, and scalar values:
switch next := next.(type) {
case map[interface{}]interface{}: // YAML maps
...
case map[string]interface{}: // JSON / decoded TOML
...
}This is a necessary consequence of using an untyped map as the canonical config store. It is correct but verbose, and the two-map-type handling is a well-known YAML-in-Go pain point.
Observer / event callback#
OnConfigChange(func(fsnotify.Event)) registers a single callback invoked after each successful live-reload. Implemented as a plain function field on the Viper struct (v.onConfigChange). No event bus or multi-listener support — intentionally minimal.
Global singleton + instance duality#
Every public function in the package (Get, Set, ReadInConfig, etc.) is a thin wrapper that calls the identical method on a package-level *Viper singleton initialised in init(). Zero logic duplication. Users who need isolation call viper.New() and work with the returned instance. This is a well-executed “convenience API over instance API” pattern.
Build-tag compile-time feature flags#
Experimental features are gated by build tags via file pairs:
feature_default.go(build tag!viper_finder) — flag = falsefinder.go(build tagviper_finder) — flag = true
internal/features packages exposes the flag as a bool constant, making feature-conditional code readable with zero runtime cost:
if features.Finder { ... }No generics#
Viper targets Go 1.20+. There is no generic code. The decision is deliberate: map[string]any with type switches and the cast library provide the necessary type flexibility without generics. Generics would help with typed GetAs[T] accessors but would also change the public API surface significantly.