A Go data-in and configuration package: a typed registry that surveys every channel a program receives data through — environment variables, .env files, configuration files (YAML / TOML / JSONC / JSON), command-line flags, standard input, in-memory maps and buffers, programmatic defaults and overrides, and remote configuration stores — and resolves them through a documented precedence chain with first-class live reload, schema validation, and per-key provenance.
This package is used as the default configuration and data-in package for rotini.
- One-line setup:
recon.New(...)wires the go-rotini family defaults — YAML / TOML / JSONC / JSON / Dotenv codecs, the go-rotini/fs-backed file watcher, the go-rotini/jsonschema-backed validator — and you're loading config - Generic, type-safe API:
Get[T],Bind,Live[T],PerSourceFor[T],Configs— typed values where the type is statically known - Pluggable seams:
Codec,SchemaValidator,WatcherFactory,FlagAdapter,RemoteBackend, andSourceitself — every default is replaceable behind its interface with a one-line option - Built-in sources:
NewOSEnvSource,NewFileSource/NewFileSourceFS,NewYAMLSource/NewTOMLSource/NewJSONCSource/NewJSONSource/NewDotenvSource(format-named convenience),NewBufferSource,NewMapSource,NewStdinSource,NewFlagSource,NewRemoteSource - Documented precedence chain (explicit → flags → env → config → remote → stdin → defaults) with per-source
WithPrecedence, per-keyPinSource, alias graphs with cycle detection - Hierarchical keys (
Path) with bracket-escaping for dotted segments, configurable delimiter, case-sensitive by default - Aggregated multi-error reporting with per-field attribution (
*MultiError) and aFormatError(r, err)pretty-printer that surfaces path, source provenance, and the precedence chain;WithErrorBehaviortogglesFailCollect(default) andFailFast context.Contextpropagation throughReloadContextandBindContext- Atomic, lock-free reads on reload via
Live[T]andsync/atomic.Pointer— readers always observe a complete, validated snapshot - Per-key change detection across reloads (
Event.Changedcovers added / removed / modified cases);Event.Warningscarries non-fatalDeprecationWarningvalues out of band - Per-source provenance via
Describe/DescribeKey/KeyDescription— every key knows which source supplied it and which other sources had a value for the same key - Struct-tag system:
required,notEmpty,default=,secret,immutable,inline,base64,hex,layout=,separator=,kvSeparator=, plus recon-specificpath=,source=,aliases=,transform= immutable-tagged fields are baselined at firstBind; subsequent reload candidates that change a baselined value are rejected (the old / new pair is redacted viaWithSecretRedactorwhen the field is alsosecret)- Schema validation via go-rotini/jsonschema; supply raw bytes via
WithSchema(rawJSON)for the one-line case orWithValidator(...)for a pre-builtSchemaValidator— including a custom one behind the same interface - Secret redaction:
Secret[T]is a type alias ofenv.Secret[T]for free interop; thesecretstruct tag andMarkSecret(key)both feedDescribeandSaveredaction; customizable redactor viaWithSecretRedactor - Format-agnostic encode:
Save/SaveTowrite the current resolved view back to any registered codec;GenerateTemplateemits a stub configuration document populated from defaults — the "myapp config init" path - Path expansion (POSIX shell-style:
~,$VAR,${VAR}); first-match-wins multi-path lookup viaWithSearchPaths;WithOptionalfor missing-file tolerance - Built-in support for
time.Duration,time.Time,[]byte(raw / base64 / hex), arrays and maps - Multi-named-config orchestration:
Configsholds named registries (per the rotini spec'sconfiguration_files[]) and multiplexes their events through a single<-chan NamedEvent io/fs.FS-backedNewFileSourceFSfor testing withtesting/fstest.MapFSand for loading fromembed.FSbundles- Remote-backend adapters (etcd / consul / vault / awsssm / k8s) live in their own modules — opt-in by
go get; the core shipsNewInMemoryBackendas a reference and for tests - Minimal third-party footprint: composes the go-rotini family
go get github.com/go-rotini/reconRequires Go 1.26 or later.
package main
import (
"fmt"
"log"
"time"
"github.com/go-rotini/recon"
)
type Config struct {
Port int `recon:"server.port,default=8080"`
DBURL string `recon:"database.dsn,required,secret"`
Timeout time.Duration `recon:"http.timeout,default=30s"`
}
func main() {
envSrc := recon.NewOSEnvSource()
fileSrc, err := recon.NewYAMLSource("config.yaml", recon.WithOptional(true))
if err != nil {
log.Fatal(err)
}
r, err := recon.New(
recon.WithSource(envSrc),
recon.WithSource(fileSrc),
)
if err != nil {
log.Fatal(err)
}
defer r.Close()
var cfg Config
if err := r.Bind(&cfg); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", cfg)
}Source chains are explicit and ordered. The first source to return ok=true for a key wins, with Set (programmatic override) always sitting above every source and SetDefault always below:
envSrc := recon.NewOSEnvSource(recon.WithEnvPrefix("APP_"))
localSrc, _ := recon.NewDotenvSource(".env.local", recon.WithOptional(true))
fileSrc, _ := recon.NewYAMLSource("config.yaml")
r, err := recon.New(
recon.WithSource(envSrc), // wins by default
recon.WithSource(localSrc), // dev overrides
recon.WithSource(fileSrc), // baseline
)Per-key overrides are first-class: RegisterAlias makes one path resolve to another (cycle-checked), and PinSource(key, sourceName) forces a key to resolve only from a named source.
Live[T] wraps the registry in an atomic.Pointer[T]-backed handle. Reads are O(1) and lock-free; readers always observe a complete, validated snapshot. Any source that implements Watcher participates; file-backed sources get watching from the registry's WatcherFactory (the bundled FSWatcher is backed by go-rotini/fs and is atomic-rename aware, debounced, multi-backend).
type Config struct {
Port int `recon:"server.port,default=8080"`
LogLvl string `recon:"log.level,default=info"`
}
live, err := recon.NewLive[Config](r)
if err != nil {
log.Fatal(err)
}
defer live.Close()
go func() {
for ev := range live.Events() {
if ev.Err != nil {
log.Printf("reload failed (source %q): %v", ev.Source, ev.Err)
continue
}
log.Printf("reload: changed=%v", ev.Changed)
}
}()
for {
cfg := live.Get() // *Config — never nil after NewLive succeeds
serve(cfg)
}The reload pipeline rebuilds the snapshot, computes the changed-key delta, optionally validates, and atomic-swaps the pointer; a failed candidate retains the previous value and emits an Event with Err set.
When a single key needs custom precedence — env-only in containers, config-first for daemons — PerSourceFor[T] returns each source's contribution separately so the caller picks a winner:
ps, _ := recon.PerSourceFor[int](r, "server.port")
if inContainer() {
if e := ps.BySource("env"); e.IsSet {
return e.Value
}
}
return ps.Resolved.Value // what Get[int] would have returnedEvery entry carries its own IsSet + Err, so "source supplied an unparseable value" is distinguishable from "source had nothing".
FormatError(r, err) renders a *MultiError (or any single typed error) into a multi-line summary with path, reason, source attribution, and — when the registry is non-nil — the full precedence chain for each failing key. Drop-in printable output for log.Fatal:
if err := r.Bind(&cfg); err != nil {
log.Fatal(recon.FormatError(r, err))
}Schema validation is opt-in. WithSchema(bytes) is the one-line form for raw JSON Schema; for YAML / TOML / JSONC schemas or pre-compiled *jsonschema.Schema values, build the validator explicitly and pass it via WithValidator:
r, err := recon.New(
recon.WithSource(fileSrc),
recon.WithSchema(schemaBytes),
)Validation failures during reload are reported on the Registry.Events() channel and via Live.LastError(); the previous snapshot is retained so live config keeps working. For per-struct validation, implement Validator (or ValidatorContext) on the bind target — the decoder calls it after every field is populated.
For applications with multiple independent configuration namespaces — each with its own precedence, schema, and watch policy — Configs holds named registries and multiplexes their reload events:
cs := recon.NewConfigs()
defer cs.Close()
_ = cs.Register("database", dbRegistry)
_ = cs.Register("server", srvRegistry)
go func() {
for ev := range cs.Events() {
log.Printf("%s reloaded: changed=%v err=%v", ev.Name, ev.Changed, ev.Err)
}
}()Registries added via Register after Events() has been called are folded into the stream automatically; Remove(name) tears the per-name forwarder down cleanly.
Describe() returns the full per-key view — which source supplied each value, which other sources had a value, whether the key is secret:
for _, k := range r.Describe().Keys {
fmt.Printf("%s = %s (from %s; aliases: %v)\n",
k.Path, k.Value, k.Source, k.Aliases)
}Describe redacts secret-tagged values automatically. The data feeds straight into a myapp config show / myapp config sources subcommand without further plumbing.
Save writes the current resolved view to an io.Writer; SaveTo writes to a file path and atomic-renames into place. Default policy is safe-to-pipe-anywhere — secret-marked keys are redacted, default-only keys are omitted; opt back in with WithSaveIncludeSecrets / WithSaveIncludeDefaults:
// Dump current config to disk.
_ = r.SaveTo("snapshot.yaml")
// Dump just one sub-tree.
_ = r.SaveTo("server.yaml",
recon.WithSaveOnly("server"),
recon.WithSaveFormat(recon.FormatYAML),
)GenerateTemplate(format) emits a stub document populated from the registered defaults — the "myapp config init" entry point. Secret keys are redacted unless WithSaveIncludeSecrets is passed:
out, _ := r.GenerateTemplate(recon.FormatYAML)
_ = os.WriteFile("config.example.yaml", out, 0o644)Full API reference is available on pkg.go.dev.
See CONTRIBUTING.md for guidelines on how to contribute to this project.
This project follows a code of conduct to ensure a welcoming community. See CODE_OF_CONDUCT.md.
To report a vulnerability, see SECURITY.md.
This project is licensed under the MIT License. See LICENSE for details.