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
- Layered resolution -- a single
Get()call checks environment, file, and defaults without the caller needing to know which source won. - Safe persistence -- environment variables must never bleed into the saved config file.
- Pluggable storage -- the file system is abstracted behind
io.Medium, making tests deterministic and enabling future remote backends. - Framework integration -- the package satisfies the
core.Configinterface, 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 explicitSet()calls. All reads go throughv.f(file) -- contains only values that originated from the config file or were explicitly set viaSet(). All writes (Commit()) go throughf.
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)andSet(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:
- Filter
os.Environ()for entries starting withprefix - Strip the prefix
- 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,Alltake a read lockSet,Commit,LoadFiletake 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 aFilesmap - Production uses
io.Local-- the real local filesystem - Future backends (S3, embedded assets) can implement
Mediumwithout 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