This repository has been archived on 2026-03-14. You can view files and clone it, but cannot push or open issues or pull requests.
go-config/docs/architecture.md
Snider 67bffa5439 docs: add human-friendly documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:02:40 +00:00

6.2 KiB

title description
Architecture Internal design of go-config -- dual-viper layering, the Medium abstraction, and the framework service wrapper.

Architecture

Design Goals

  1. Layered resolution -- a single Get() call checks environment, file, and defaults without the caller needing to know which source won.
  2. Safe persistence -- environment variables must never bleed into the saved config file.
  3. Pluggable storage -- the file system is abstracted behind io.Medium, making tests deterministic and enabling future remote backends.
  4. Framework integration -- the package satisfies the core.Config interface, so any Core service can consume configuration without importing this package directly.

Key Types

Config

type Config struct {
    mu     sync.RWMutex
    v      *viper.Viper   // full configuration (file + env + defaults)
    f      *viper.Viper   // file-backed configuration only (for persistence)
    medium coreio.Medium
    path   string
}

Config is the central type. It holds two Viper instances:

  • v (full) -- contains everything: file values, environment bindings, and explicit Set() calls. All reads go through v.
  • f (file) -- contains only values that originated from the config file or were explicitly set via Set(). All writes (Commit()) go through f.

This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive SaveConfig() would serialise env vars into the YAML file. By maintaining f as a clean copy, Commit() only persists what should be persisted.

Option

type Option func(*Config)

Functional options configure the Config at creation time:

Option Effect
WithMedium(m) Sets the storage backend (defaults to io.Local)
WithPath(path) Sets the config file path (defaults to ~/.core/config.yaml)
WithEnvPrefix(p) Changes the environment variable prefix (defaults to CORE_CONFIG)

Service

type Service struct {
    *core.ServiceRuntime[ServiceOptions]
    config *Config
}

Service wraps Config as a framework-managed service. It embeds ServiceRuntime for typed options and implements two interfaces:

  • core.Config -- Get(key, out) and Set(key, v)
  • core.Startable -- OnStartup(ctx) triggers config file loading during the application lifecycle

The service is registered as a factory function:

core.New(core.WithService(config.NewConfigService))

The factory receives the *core.Core instance, constructs the service, and returns it. The framework calls OnStartup at the appropriate lifecycle phase, at which point the config file is loaded.

Env / LoadEnv

func Env(prefix string) iter.Seq2[string, any]
func LoadEnv(prefix string) map[string]any  // deprecated

Env returns a Go 1.23+ iterator over environment variables matching a given prefix, yielding (dotKey, value) pairs. The conversion logic:

  1. Filter os.Environ() for entries starting with prefix
  2. Strip the prefix
  3. Lowercase and replace _ with .

LoadEnv is the older materialising variant and is deprecated in favour of the iterator.

Data Flow

Initialisation (New)

New(opts...)
  |
  +-- create two viper instances (v, f)
  +-- set env prefix on v ("CORE_CONFIG_" default)
  +-- set env key replacer ("." <-> "_")
  +-- apply functional options
  +-- default medium to io.Local if nil
  +-- default path to ~/.core/config.yaml if empty
  +-- enable v.AutomaticEnv()
  +-- if config file exists:
        LoadFile(medium, path)
          +-- medium.Read(path)
          +-- detect config type from extension
          +-- f.MergeConfig(content)
          +-- v.MergeConfig(content)

Read (Get)

Get(key, &out)
  |
  +-- RLock
  +-- if key == "":
  |     v.Unmarshal(out)     // full config into a struct
  +-- else:
  |     v.IsSet(key)?
  |       yes -> v.UnmarshalKey(key, out)
  |       no  -> error "key not found"
  +-- RUnlock

Because v has AutomaticEnv() enabled, v.IsSet(key) returns true if the key exists in the file or as a CORE_CONFIG_* environment variable.

Write (Set + Commit)

Set(key, value)
  |
  +-- Lock
  +-- f.Set(key, value)   // track for persistence
  +-- v.Set(key, value)   // make visible to Get()
  +-- Unlock

Commit()
  |
  +-- Lock
  +-- Save(medium, path, f.AllSettings())
  |     +-- yaml.Marshal(data)
  |     +-- medium.EnsureDir(dir)
  |     +-- medium.Write(path, content)
  +-- Unlock

Note that Commit() serialises f.AllSettings(), not v.AllSettings(). This is intentional -- it prevents environment variable values from being written to the config file.

File Loading (LoadFile)

LoadFile supports both YAML and .env files. The config type is inferred from the file extension:

  • .yaml, .yml -- YAML
  • .json -- JSON
  • .toml -- TOML
  • .env (no extension, basename .env) -- dotenv format

Content is merged (not replaced) into both v and f via MergeConfig, so multiple files can be layered.

Concurrency

All public methods on Config and Service are safe for concurrent use. A sync.RWMutex protects the internal state:

  • Get, All take a read lock
  • Set, Commit, LoadFile take a write lock

The Medium Abstraction

File operations go through io.Medium (from forge.lthn.ai/core/go-io), not os directly. This means:

  • Tests use io.NewMockMedium() -- an in-memory filesystem with a Files map
  • Production uses io.Local -- the real local filesystem
  • Future backends (S3, embedded assets) can implement Medium without changing config code

Compile-Time Interface Checks

The package includes two compile-time assertions at the bottom of the respective files:

var _ core.Config = (*Config)(nil)   // config.go
var _ core.Config    = (*Service)(nil)  // service.go
var _ core.Startable = (*Service)(nil)  // service.go

These ensure that if the core.Config or core.Startable interfaces ever change, this package will fail to compile rather than fail at runtime.

Licence

EUPL-1.2