config/docs/architecture.md
Virgil 411c82a6d4 fix(config): align public docs with Set semantics
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:31:04 +00:00

188 lines
6.3 KiB
Markdown

---
title: Architecture
description: Internal design of 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
```go
type Config struct {
mu sync.RWMutex
full *viper.Viper // full configuration (file + env + defaults)
file *viper.Viper // file-backed configuration only (for persistence)
medium coreio.Medium
path string
}
```
`Config` is the central type. It holds **two** Viper instances:
- **`full`** -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `full`.
- **`file`** -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `file`.
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 `file` as a clean copy, `Commit()` only persists what should be persisted.
### Option
```go
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
```go
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:
```go
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
```go
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 (full, file)
+-- set env prefix on full ("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 full.AutomaticEnv()
+-- if config file exists:
LoadFile(medium, path)
+-- medium.Read(path)
+-- detect config type from extension
+-- file.MergeConfig(content)
+-- full.MergeConfig(content)
```
### Read (`Get`)
```
Get(key, &out)
|
+-- RLock
+-- if key == "":
| full.Unmarshal(out) // full config into a struct
+-- else:
| full.IsSet(key)?
| yes -> full.UnmarshalKey(key, out)
| no -> error "key not found"
+-- RUnlock
```
Because `full` has `AutomaticEnv()` enabled, `full.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
+-- file.Set(key, value) // track for persistence
+-- full.Set(key, value) // make visible to Get()
+-- Unlock
Commit()
|
+-- Lock
+-- Save(medium, path, file.AllSettings())
| +-- yaml.Marshal(data)
| +-- medium.EnsureDir(dir)
| +-- medium.Write(path, content)
+-- Unlock
```
Note that `Commit()` serialises `file.AllSettings()`, not `full.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file.
### File Loading (`LoadFile`)
`LoadFile` supports YAML, JSON, TOML, 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 `full` and `file` 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:
```go
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