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 the fsnotify.Watcher and adds the config directory; inner goroutine at viper.go:306 runs 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 own eventsWG to signal completion when the events channel closes. This two-level WaitGroup prevents WatchConfig from 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 in remote/remote.go:59
  • Example: viper.go:308 — multiplexes watcher.Events and watcher.Errors channels.
  • Assessment: Classic Go channel demultiplexing via select. Correct use of the two-value receive event, ok := <-ch to detect channel closure. No goroutine leak: both arms call eventsWG.Done() on exit.

Remote watch channel adaptation (remote module)#

  • Usage: remote/remote.go:47–78
  • Example: Converts crypt.Response channel to viper.RemoteResponse channel; uses a quit chan bool for shutdown signalling.
  • Assessment: Simple adapter goroutine bridging two channel types. quit channel pattern (rather than context.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: RWMutex guards the codecs map[string]Codec. Writes (RegisterCodec) take the write lock; reads (Encoder, Decoder, codec()) take the full write lock (same call to r.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.Do initialises the codecs map on first use. Combined with a public NewCodecRegistry() constructor that calls init() 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:

TypeKindPurpose
FileLookupErrorInterfaceMarker interface for all file-lookup failures
FileNotFoundFromSearchErrorStructFile not found in any search path
ConfigFileNotFoundErrorStructDeprecated wrapper — delegates via Unwrap()
FileNotFoundErrorStructSpecific file path not found
ConfigFileAlreadyExistsErrorString typeFile already exists on write
ConfigMarshalErrorStruct (wraps error)Marshalling failure
UnsupportedConfigErrorString typeUnknown format string
ConfigParseErrorStruct (util.go)YAML/JSON parse failure
UnsupportedRemoteProviderErrorString typeUnknown remote backend
RemoteConfigErrorString typeGeneric 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:1681

Configuration 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 to afero.OsFs
  • EncoderRegistry / DecoderRegistry / CodecRegistry — swappable codec backends
  • slog.Logger — observability
  • Finder — 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 = false
  • finder.go (build tag viper_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.