Language: English · 中文
fastconf layers YAML / JSON / TOML files, environment variables, CLI
flags, remote KV stores, and on-the-fly generators into a single strongly
typed Go struct. A single-writer reload loop publishes new snapshots atomically
via atomic.Pointer; the hot read path is one atomic.Pointer.Load().
Status: pre-public. The API still moves where semantics demand it.
pkg.go.devand this README track the current truth of the codebase.
- Quick start
- Why FastConf
- Installation
- Core model
- Manager API
- Options reference
- Reload pipeline
- Profiles & overlays
- Provider system
- Codec & bridge
- Transformers & migration
- Watch, Subscribe, and Plan
- Provenance, history & rollback
- Observability
- Multi-tenant & presets
- Sub-module ecosystem
- Extension guide
- CLI tools
- Performance
- Development
- License
package main
import (
"context"
"log"
"github.com/fastabc/fastconf"
)
type AppConfig struct {
Server struct {
Addr string `json:"addr" yaml:"addr"`
} `json:"server" yaml:"server"`
Database struct {
DSN string `json:"dsn" yaml:"dsn"`
Pool int `json:"pool" yaml:"pool"`
} `json:"database" yaml:"database"`
}
func main() {
mgr, err := fastconf.New[AppConfig](context.Background(),
fastconf.WithDir("conf.d"),
fastconf.WithProfileEnv("APP_PROFILE"),
fastconf.WithDefaultProfile("dev"),
fastconf.WithWatch(true),
)
if err != nil {
log.Fatal(err)
}
defer mgr.Close()
cfg := mgr.Get() // *AppConfig — lock-free, O(1), zero-alloc
log.Println(cfg.Server.Addr, cfg.Database.Pool)
}Directory layout:
conf.d/
base/
00-app.yaml
overlays/
prod/
50-overrides.yaml
_patch.json
With APP_PROFILE=prod, FastConf merges base/* first, then
overlays/prod/*. The default decode bridge does a JSON round-trip, so if
your structs only carry yaml tags either add json tags or pass
fastconf.WithCodecBridge(fastconf.BridgeYAML) explicitly.
| Scenario | Recommended combo | Read next |
|---|---|---|
| Local file config, single service | New + WithDir + Get |
ExampleNew / docs/cookbook/introspect.md |
| Kubernetes hot-reload | PresetK8s + Subscribe + Errors |
docs/cookbook/k8s.md / docs/cookbook/reload-policy.md |
| Remote source / GitOps | WithProvider + Plan + Provenance |
docs/cookbook/vault.md / docs/cookbook/consul.md / docs/cookbook/plan.md |
For unit tests use PresetTesting; for sidecars PresetSidecar; for
region / zone / host axis overlays see PresetHierarchical and
WithMultiAxisOverlays.
- Strong typing on the read path.
mgr.Get().Server.Addris checked by the compiler. No dotted-path strings, no reflection, nointerface{}. - Lock-free hot reads.
Get()is anatomic.Pointer.Load()— O(1), zero-alloc, safe from any number of goroutines. - Fail-safe reload. Any pipeline stage that errors out keeps the old
*State[T]live; a broken config never reaches your read path. - Kustomize-style layering. base / overlays, RFC 6902 patches, and
policy-based
mergeKeysstrategic merge for lists of objects. - Opt-in extensions. Providers, transformers, secret resolvers, validators, policies, metrics, and tracing are all optional.
- Boundary-honest interface surface. Public contracts live under
contracts/; reusable primitives live underpkg/*; private helpers underinternal/*; CI enforces dependency direction.
go get github.com/fastabc/fastconf@latest
# Optional sub-modules:
go get github.com/fastabc/fastconf/observability/otel@latest
go get github.com/fastabc/fastconf/observability/metrics/prometheus@latest
go get github.com/fastabc/fastconf/policy/cue@latest
go get github.com/fastabc/fastconf/policy/opa@latest
go get github.com/fastabc/fastconf/providers/s3@latest
go get github.com/fastabc/fastconf/providers/s3events@latest
go get github.com/fastabc/fastconf/providers/nats@latest
go get github.com/fastabc/fastconf/providers/redisstream@latest
go get github.com/fastabc/fastconf/validate/cue/cuelang@latest
go get github.com/fastabc/fastconf/validate/playground@latestCommand-line tools (Go ≥ 1.26):
go install github.com/fastabc/fastconf/cmd/fastconfd@latest
go install github.com/fastabc/fastconf/cmd/fastconfctl@latest
go install github.com/fastabc/fastconf/cmd/fastconfgen@latestEach GitHub Release also ships prebuilt binaries for
linux/{amd64,arm64}, darwin/{amd64,arm64}, and windows/amd64 with
SHA256SUMS.
sources / generators / providers
│
▼
assemble preflight
│
▼
merge → migration → transform → secret → typed-hooks
→ decode → field-meta → validate → policy
│
fail ───┴─── keep old State[T]
│
success
▼
canonical hash → atomic swap → history → audit → subscribers
| Property | What it means |
|---|---|
| Typed read path | mgr.Get().Server.Addr, checked by the compiler |
| Single-writer reload | fsnotify, provider events, and manual Reload all serialize through one writer |
| Fail-safe | Any stage error keeps the old *State[T]; bad config never reaches business code |
| Kustomize-style layering | base / overlay, RFC 6902 patches, strategic merge with mergeKeys |
| Opt-in extensions | providers, transformers, secret resolvers, policies, metrics, tracer |
. (repo root — package fastconf)
manager.go Manager[T]: New / Get / Close / Reload / Snapshot
pipeline.go runStages[T] + Plan dry-run entry
pipeline_stages.go Merge / Assemble / Migrate / Transform / Decode / Validate stages
options.go All WithXxx options + public types
state.go State[T] + ReloadCause + Origins/Explain/Lookup + history
watch.go / watcher.go Subscribe, fsnotify, symlink handling
provider_watch.go Provider event subscription (exponential backoff + drop-on-full)
presets.go PresetK8s / PresetSidecar / PresetTesting / PresetHierarchical
registry.go RegisterProviderFactory / WithProviderByName
defaults.go fastconf:"default=…" struct tag + built-in hooks
secret.go fastconf:"secret" + SecretRedactor
feature.go FeatureRule / Eval / Sub
field_meta.go range / enum / required field-meta checks
obs_audit.go / obs_metrics.go / obs_tracer.go sinks
tenant.go TenantManager[T]
contracts/ Public stable interfaces: Provider / Codec / Event / Snapshot / Source / Priority
pkg/ Reusable primitives — importable by third-party authors
decoder/ YAML / JSON / TOML codec registry
discovery/ conf.d scanning + _meta.yaml parsing
feature/ feature-flag rule + EvalContext
flog/ zerolog-style fluent wrapper over *slog.Logger
generator/ contracts.Generator helpers
mappath/ dotted-path Get/Set/Delete utilities
merger/ Kustomize-style map[string]any layering
migration/ Chain + Step (From/To/Apply)
profile/ profile expression compiler (&, |, !, ())
provider/ built-in Env / CLI / Bytes / File / Labels providers
transform/ Defaults / SetIfAbsent / EnvSubst / DeletePaths / Aliases
validate/ Validator + ValidatorReport
internal/ Private helpers (debounce / obs / typeinfo / watcher)
providers/ First-party providers (consul / http / vault in root module; nats / redisstream / s3 as sub-modules)
integrations/ bus / render / log / openfeature adapters
observability/ metrics/prometheus + otel (independent sub-modules)
policy/ Policy interface; cue/opa backends as sub-modules
validate/ cue/cuelang + playground (independent sub-modules)
cmd/ fastconfd (root module); fastconfctl / fastconfgen (sub-modules)
fastconf → pkg/{discovery,decoder,flog,merger,provider,validate}
→ internal/watcher
→ contracts
pkg/* MUST NOT depend on each other except via this whitelist
(kept in sync with tools/check-deps.sh):
pkg/discovery → pkg/profile
pkg/generator → pkg/mappath
pkg/provider → pkg/decoder
pkg/provider → pkg/mappath
pkg/transform → pkg/mappath
internal/* MUST NOT depend on each other; only the standard library.
type Manager[T any] struct { /* unexported */ }
// Construction (first reload runs synchronously)
func New[T any](ctx context.Context, opts ...Option) (*Manager[T], error)
// Read path — lock-free, O(1), zero-alloc
func (m *Manager[T]) Get() *T
// Write path. ctx controls both enqueue/wait AND the pipeline itself:
// cancelling it aborts provider.Load / secret resolvers / transformers
// and surfaces as ctx.Err().
func (m *Manager[T]) Reload(ctx context.Context, opts ...ReloadOption) error
// Dry-run — never updates the live pointer; collects every ValidatorReport
func (m *Manager[T]) Plan() *PlanBuilder[T] // .WithHostname(...).Run(ctx) → *PlanResult[T]
// Current snapshot (State[T] + Sources + Origins)
func (m *Manager[T]) Snapshot() *State[T]
// Async failure stream — buffered 16, drop-on-full, closed by Close()
func (m *Manager[T]) Errors() <-chan ReloadError
// Sub-system accessors (zero-cost namespaces)
func (m *Manager[T]) Watcher() *Watcher[T] // .Pause() / .Resume() / .Paused()
func (m *Manager[T]) Replay() *Replay[T] // .List() / .Rollback(*State[T])
func (m *Manager[T]) Close() errorPackage-level generics — anything that derives a subtree M from *T
lives at the package level:
// Per-field subscribe; fires on every successful reload.
func Subscribe[T, M any](m *Manager[T], extract func(*T) *M, fn func(old, new *M)) (cancel func())
// Typed feature-flag evaluation; type-mismatch returns def.
func Eval[T, V any](m *Manager[T], key string, ctx feature.EvalContext, def V) V
// Read-only subtree alias.
func Sub[T, M any](s *State[T], extract func(*T) *M) *Mtype State[T any] struct {
Value *T // strongly typed config; Get() returns this
Hash [32]byte // global SHA-256 fingerprint
LoadedAt int64 // unix nanoseconds
Sources []SourceRef // every layer that contributed
Generation uint64 // monotonic version
Cause ReloadCause // why this reload ran + provider revisions
}
func (s *State[T]) Explain(path string) []Origin // oldest → newest override chain
func (s *State[T]) Lookup(path string) []Origin // alias of Explain
func (s *State[T]) LookupStrict(path string) ([]Origin, error)
func (s *State[T]) Origins() *OriginIndex
func (s *State[T]) Introspect() *Introspection // Keys / Settings / At
func (s *State[T]) Redacted() map[string]any // applies the SecretRedactor
func (s *State[T]) MarshalYAML(redactor SecretRedactor) ([]byte, error)
func (s *State[T]) Diff(other *State[T]) []string
func (s *State[T]) FeatureRules() map[string]feature.RuleSuggested reading order on pkg.go.dev:
New → Get → Subscribe / Errors → Plan → Replay. Runnable
examples: ExampleNew, ExampleSubscribe, ExampleManager_Errors,
ExampleManager_Plan, ExampleReplay_Rollback.
All WithXxx options return Option and may be composed in any order
when passed to New[T]. Later calls win for duplicates.
| Option | Purpose | Default |
|---|---|---|
WithDir(dir string) |
Config root directory | "conf.d" |
WithFS(fs.FS) |
Alternate fs.FS (testing) |
— |
WithStrict(bool) |
Error on unknown fields | false |
WithLogger(*slog.Logger) |
Inject a logger | io.Discard (opt-in) |
WithCodecBridge(BridgeJSON | BridgeYAML) |
Decode bridge | BridgeJSON |
WithMultiAxisOverlays(axes ...OverlayAxis) |
Multi-axis overlays (region / zone / host) | — |
WithRawMapAccess(fn) |
Read-only hook over the merged map before decode | — |
| Option | Purpose | Default |
|---|---|---|
WithWatch(bool) |
Enable fsnotify | false |
WithCoalesceQuiet(d) |
Quiet window after which a per-dir burst fires | 30ms |
WithCoalesceMaxLag(d) |
Hard upper bound on burst lifetime | 250ms |
WithCoalesceSwapHint(d) |
Tightened window once a K8s ..data swap is detected |
5ms |
WithCoalesceProfile(p) |
Apply a preset: ProfileK8s (default) or ProfileLocalDev |
ProfileK8s |
WithWatchPaths(paths...) |
Additional watch paths | — |
The watcher debounces fsnotify events per parent directory rather than
globally, so independent ConfigMaps (or watched dirs) never block each
other. When a K8s atomic-swap commit (..data rename/create) is observed,
the coalescer tightens the window to swapHint (5ms) instead of waiting
the full quiet window — typical reload latency drops from ~500ms (the
prior global debouncer default) to ~5–35ms.
| Option | Purpose |
|---|---|
WithProfile(p string) |
Explicit single profile |
WithProfiles(p ...string) |
Multi-profile mode (overlays match via _meta.yaml.match) |
WithProfileEnv(name string) |
Read profile from an environment variable |
WithDefaultProfile(p string) |
Fallback when the env var is empty |
WithProfileExpr(expr string) |
Global profile-matching expression |
FastConf splits the extension surface in two:
Source(pkg/source) — a byte-stream contributor (file, http, inline bytes). Paired with aParser(pkg/parser) at the call site, koanf-style, so the codec is named where the layer is declared.Provider(pkg/provider) — an already-structured contributor (env, cli, KV with one key per setting). No Parser needed.
| Option | Purpose |
|---|---|
WithSource(src, parser) |
Bind a byte-blob Source with a Parser. Pass nil Parser to auto-pick via content-type hint |
WithProvider(p) |
Register an already-structured provider |
WithProviderOrdered(p...) |
Auto-assigns CLI+100, +101, ... in call order; errors if input has non-zero priority |
WithProviderByName(name, cfg) |
Construct via factory registry (resolved after all options applied) |
WithProviderRegistry(r) |
Manager-local *ProviderRegistry — local wins, then global default |
WithGenerator(g) |
Synthesise a []RawLayer in the assemble stage (e.g. BuildInfo) |
WithDotEnvAuto(prefix) |
Auto-discover a .env file under WithDir |
pkg/source and pkg/parser factory functions:
import (
"github.com/fastabc/fastconf"
"github.com/fastabc/fastconf/pkg/parser"
"github.com/fastabc/fastconf/pkg/provider"
"github.com/fastabc/fastconf/pkg/source"
"github.com/fastabc/fastconf/pkg/transform"
)
fastconf.New[Cfg](ctx,
// Byte-blob layers — explicit Source × Parser pairing:
fastconf.WithSource(source.NewFile("/etc/app/config.yaml"), parser.YAML()),
fastconf.WithSource(source.NewHTTP("https://kv/config"), parser.JSON()),
fastconf.WithSource(source.NewBytes("inline", "yaml", data), nil), // nil = auto-bind by content-type
// Structured providers — no Parser slot:
fastconf.WithProvider(provider.NewEnv("APP_")), // APP_DATABASE__DSN → database.dsn
fastconf.WithProvider(provider.NewEnvReplacer("APP_", provider.DotReplacer)),// APP_DATABASE_DSN → database.dsn
fastconf.WithProvider(provider.NewCLI(cliMap)), // parsed CLI flag map
fastconf.WithProvider(provider.NewDotEnv("APP_", ".env")), // explicit .env paths
fastconf.WithProvider(provider.NewLabels(labels, provider.LabelOptions{})), // Traefik / Docker labels
fastconf.WithTransformers(transform.ExpandLabels(at, to, opts)),
)| Option | Purpose |
|---|---|
WithMigrations(func) |
Schema migration callback (before transformers) |
WithTransformers(t...) |
Post-merge, pre-decode transformation chain |
WithSecretResolver(r) |
Decrypt leaf secrets after transform, before decode |
WithTypedHook(h) |
Rewrite leaves before decode (built-in: time.Duration) |
WithoutDefaultTypedHooks() |
Disable built-in typed hooks |
WithStructDefaults[T]() |
Populate zero values via fastconf:"default=..." |
WithDefaulterFunc[T](fn) |
Custom defaulter for *T |
WithMergeKeys(map) |
Strategic merge for lists of objects |
WithValidator[T](fn) |
Typed validation after decode; failure preserves old state |
WithPolicy[T](p) |
Policy evaluation after validate; SeverityError aborts reload |
WithFeatureRules[T](extract) |
Attach a feature.Rule table to State for Eval |
| Option | Purpose |
|---|---|
WithMetrics(MetricsSink) |
Metrics sink (also supports ProviderMetricsSink / StageMetricsSink / RenderMetricsSink) |
WithAuditSink(AuditSink) |
Callback on every successful reload (multi-sink fan-out) |
WithDiffReporter(DiffReporter) |
Async push on non-empty diff; each reporter has its own bounded worker; drop-on-full emits EventDropped("diff-reporter") |
WithDiffReporterQueueCap(n int) |
Per-reporter queue depth (default 64) |
WithTracer(Tracer) |
OTel-compatible span tracer |
WithProvenance(level) |
ProvenanceOff / ProvenanceTopLevel / ProvenanceFull |
WithHistory(n) |
Keep the last n successful states (history ring) |
WithSecretRedactor(r) |
Redact secrets in logs and snapshots (paired with WithSecretResolver) |
| Option | Purpose |
|---|---|
WithSourceOverride(map) |
Inject a one-shot override layer |
WithReloadReason(s) |
Override the default "manual" reason for audit |
┌── fsnotify events → debounce 500ms ──┐
│ │
Reload(ctx, opts...) ─────┤ reloadCh chan reloadRequest ├──► reloadLoop
│ │ (single writer)
provider.Watch events ────┘── backoff + drop-on-full ──────────┘
reloadCh.recv(req)
│
├─ stageMerge: discovery.Scan(dir) → decode files → merger.Merge(layers)
│ apply _meta.yaml (appendSlices / profileEnv / match)
│ apply _patch.json (RFC 6902)
│
├─ stageAssemble: for each provider: Load(ctx) → merge by Priority
│
├─ stageMigrate: opts.migrationRun(merged) [optional]
├─ stageTransform: for each transformer: t.Transform(merged)
├─ stageDecode: json.Marshal(merged) → json.Unmarshal(→ *T)
│ apply fastconf:"default=…" struct tags
├─ stageFieldMeta: range / enum / required checks
├─ stageValidate: for each validator: v(*T)
├─ stagePolicy: for each policy: p.Evaluate(ctx, *T, reason, tenant)
│
└─ commit:
canonicalHashBytes(mergedJSON) → SHA-256 dedup
atomic.Pointer.Store(newState)
history.push(newState)
for each AuditSink: Audit(ctx, cause)
fireWatches(oldPartHashes, newPartHashes)
When any stage returns a non-nil error:
atomic.Pointeris not updated;Get()keeps returning the old value.Generationis not incremented.- The error is returned synchronously from
Reload(ctx); the same event is also broadcast asynchronously onErrors(). - No AuditSink fires — audit only triggers after a successful commit.
MetricsSink.ReloadFinished(ok=false, dur)is called.
The ctx passed to Reload(ctx) does more than control enqueue/wait — it
threads into the running pipeline:
assembleshort-circuits onctx.Err().- Each
provider.Load(ctx)shares the same ctx; slow providers bail out immediately on cancel. - Cancellation errors propagate as
context.Canceled/context.DeadlineExceeded(not wrapped inErrDecode), so callers canerrors.Is(err, context.Canceled)precisely.
Filesystem and provider watcher loops have no caller ctx; the framework
uses context.Background() for those paths to preserve event-driven
reload semantics.
conf.d/
base/ # shared defaults for every profile
00-defaults.yaml
10-feature-flags.yaml
overlays/
dev/ # applied when profile == "dev"
50-dev.yaml
prod/
50-prod.yaml
_meta.yaml # profile match expression
_patch.json # RFC 6902 patch
staging/
50-staging.yaml
_meta.yaml
schemaVersion: "1"
profileEnv: "APP_PROFILE" # env var to read profile (overridden by WithProfileEnv)
defaultProfile: "dev" # fallback profile
appendSlices: true # slices append instead of overwrite
match: "prod | staging" # boolean profile expression (&, |, !, () supported)match is compiled by pkg/profile:
| Syntax | Meaning |
|---|---|
prod |
profile set contains "prod" |
prod | staging |
contains prod or staging |
prod & !debug |
prod and not debug |
(eu-west | eu-east) & !debug |
composite |
Drop a _patch.json into any overlay directory; FastConf applies it
after the layer's files merge:
[
{ "op": "replace", "path": "/server/addr", "value": ":8443" },
{ "op": "add", "path": "/feature/darkMode", "value": true },
{ "op": "remove", "path": "/legacy/key" }
]mgr, err := fastconf.New[AppConfig](ctx,
fastconf.WithDir("conf.d"),
fastconf.WithProfiles("prod", "eu-west", "canary"),
)WithProfiles and WithProfile are mutually exclusive. In multi-profile
mode each overlay's _meta.yaml.match decides whether it applies.
Pair each Source with a Parser via WithSource(src, parser). Passing
nil Parser auto-binds via the content-type hint (file extension,
HTTP Content-Type header, or ContentType ctor argument).
| Source | Constructor | Notes |
|---|---|---|
| File | source.NewFile(path) |
Reads the file at load time; content-type from extension |
| HTTP | source.NewHTTP(url) |
Conditional GET with ETag short-circuit; content-type from Content-Type header |
| Bytes | source.NewBytes(name, contentType, data) |
In-memory layer (most common in tests) |
| Parser | Content-types claimed |
|---|---|
parser.YAML() |
yaml / .yaml / .yml / application/yaml / application/x-yaml / text/yaml |
parser.JSON() |
json / .json / application/json / text/json |
parser.TOML() |
toml / .toml / application/toml / text/toml |
Third-party parsers register their content-types via parser.Register.
These contribute map[string]any directly — no Parser needed.
| Provider | Constructor | Notes |
|---|---|---|
| Env | provider.NewEnv("APP_") |
APP_FOO__BAR → foo.bar (double underscore separator) |
| EnvReplacer | provider.NewEnvReplacer("APP_", provider.DotReplacer) |
Viper-style single underscore → dot |
| CLI | provider.NewCLI(map[string]any) |
Parsed CLI flag map |
| DotEnv | provider.NewDotEnv("APP_", paths...) |
Explicit .env paths |
| Labels | provider.NewLabels(labels, provider.LabelOptions{}) |
Traefik / Docker-style key=value strings |
| LabelMap | provider.NewLabelMap(labels, provider.LabelOptions{}) |
Kubernetes annotation-style map[string]string |
import (
vault "github.com/fastabc/fastconf/providers/vault"
consul "github.com/fastabc/fastconf/providers/consul"
httpprov "github.com/fastabc/fastconf/providers/http"
)
vp, _ := vault.New("https://vault.svc", "kv/data/myapp", os.Getenv("VAULT_TOKEN"))
cp, _ := consul.New("http://consul.svc:8500", "config/myapp")
hp, _ := httpprov.New("remote", "https://example.com/cfg.yaml", yamlCodec{})Trim them out at build time:
go build -tags no_provider_vault,no_provider_consul,no_provider_http ./...Sub-modules don't ship in the root go.mod; go get them only when
needed. All implement contracts.Provider.
// AWS S3 — load with ETag short-circuit, explicit static credentials.
import s3prov "github.com/fastabc/fastconf/providers/s3"
sp, err := s3prov.New(s3prov.Config{
Region: "us-east-1",
Bucket: "my-configs",
Key: "prod/app.yaml", // codec inferred from ".yaml"
AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"),
SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
// VersionID: "abc...", // pin to a specific object version
// Endpoint: "http://minio:9000", PathStyle: true, // for MinIO/LocalStack
})
if err != nil {
log.Fatal(err)
}
mgr, _ := fastconf.New[AppConfig](ctx, fastconf.WithProvider(sp))The S3 provider remembers the last ETag and sends If-None-Match on
every subsequent Load; AWS returns 304 when the object is unchanged
and the provider serves the cached map without re-decoding. That makes
repeated Reload() calls cheap and matches the no-spurious-reload
contract enforced by providers/http.
For "provider address as a config field" patterns, use the URL helper:
cfg, _ := s3prov.FromURL(
"s3://my-configs/prod/app.yaml?region=us-east-1",
s3prov.Credentials{
AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"),
SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
},
)
sp, _ := s3prov.New(cfg)FromURL accepts region, codec, endpoint, path_style,
version_id, and priority query parameters. Credentials are passed
separately so secrets never appear in URLs that may be logged.
For change-driven reloads, compose with providers/s3events (S3 →
EventBridge → SQS):
import (
s3prov "github.com/fastabc/fastconf/providers/s3"
s3events "github.com/fastabc/fastconf/providers/s3events"
)
loader, _ := s3prov.New(s3prov.Config{ /* ... */ })
notifier, _ := s3events.New(s3events.Config{
Region: "us-east-1",
QueueURL: "https://sqs.us-east-1.amazonaws.com/123/cfg-events",
Bucket: "my-configs",
KeyPrefix: "prod/", // optional filter
AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"),
SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
})
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithProvider(loader),
fastconf.WithProvider(notifier), // watch-only; Load returns empty map
)The notifier polls SQS with long-poll, filters EventBridge envelopes by
bucket and key prefix, deletes the matched messages, and emits a
contracts.Event that drives a Manager reload. The loader's ETag
short-circuit then makes the re-read free when the event fires for an
unrelated key in the same bucket.
NATS JetStream (providers/nats) and Redis Streams (providers/redisstream)
are event-driven providers that inject your existing nats.Conn / Redis
client adapter through a tiny interface — they pull in no upstream client
library.
Pick the right module in 30 seconds. "Watch" describes the native
change-notification mechanism; "Resumable" means the provider implements
contracts.Resumable.WatchFrom and survives reconnects without losing
events. "Codec" indicates whether the provider needs you to choose one.
| Provider | Module | Watch model | Resumable | Codec | Auth model | Build tag |
|---|---|---|---|---|---|---|
pkg/provider.Env / EnvReplacer |
root | load-only | — | n/a | env-var prefix | n/a |
pkg/provider.CLI |
root | load-only | — | n/a | n/a (in-memory) | n/a |
pkg/provider.File |
root | load-only | — | inferred from ext | filesystem | n/a |
pkg/provider.Bytes |
root | load-only | — | explicit | n/a (in-memory) | n/a |
pkg/provider.DotEnv |
root | load-only | — | n/a | filesystem | n/a |
pkg/provider.Labels / LabelMap |
root | load-only | — | n/a | n/a (in-memory) | n/a |
providers/http |
root | ETag + body-hash poll | — | required | static headers (Bearer, …) | no_provider_http |
providers/consul |
root | blocking query (X-Consul-Index) | — | optional (Mode KV/Blob) | ACL token | no_provider_consul |
providers/vault |
root | metadata-version poll | — | (JSON, built-in) | static token / WithAuth |
no_provider_vault |
providers/nats |
sub-module | JetStream subscribe | yes | required | inject nats.Conn adapter |
(sub-module) |
providers/redisstream |
sub-module | XREAD BLOCK |
yes | required | inject redis.Client adapter |
(sub-module) |
providers/s3 |
sub-module | load + ETag short-circuit | — | inferred from key ext or explicit | static AWS creds | no_provider_s3 |
providers/s3events |
sub-module | SQS long-poll (EventBridge) | — | n/a (watch-only) | static AWS creds | no_provider_s3events |
Notes:
- Load-only providers contribute a layer at every
Reload(ctx)but do not push change events. Pair them with a Manager-level trigger (mgr.Watcher(), fsnotify, an external scheduler) or a sibling event-source provider when you need change-driven reloads. - Resumable providers re-attach from the last observed
Event.Revisionon reconnect; non-resumable Watch providers cold-start on every reconnect (still correct, just chattier under network churn). - Build tags strip a provider from the binary entirely; sub-modules
achieve the same via
go.modexclusion (don'tgo getthem).
type Provider interface {
Name() string
Priority() int
Load(ctx context.Context) (map[string]any, error)
Watch(ctx context.Context) (<-chan Event, error) // (nil, nil) → no native notifications
}Merge order follows Priority() ascending — higher values overwrite lower:
| Constant | Value | Use |
|---|---|---|
PriorityDotEnv |
5 | .env fallback (lowest) |
PriorityStatic |
10 | Static / file layers |
PriorityOverlay |
20 | Overlay providers |
PriorityKV |
30 | Vault / Consul / HTTP / S3 / NATS / Redis Streams |
PriorityK8s |
40 | Kubernetes ConfigMap / Secret |
PriorityEnv |
50 | Process environment variables |
PriorityCLI |
60 | Command-line flag provider (highest) |
If picking a priority feels arbitrary, use
WithProviderOrdered(p1, p2, p3): each provider receives
PriorityCLI+100, +101, +102 ... in call order; later wins. A non-zero
explicit priority on an input is rejected to avoid silent override.
type Resumable interface {
// Empty lastRev acts like Watch (cold subscribe).
// Non-empty: deliver events strictly after that revision.
// If the revision was compacted, return ErrResumeUnsupported and the
// framework falls back to a cold Watch.
WatchFrom(ctx context.Context, lastRev string) (<-chan Event, error)
}The framework remembers each provider's last observed Event.Revision
and passes it back into WatchFrom on reconnect.
// Register at init or in TestMain.
fastconf.RegisterProviderFactory("vault", func(cfg map[string]any) (contracts.Provider, error) {
addr, _ := cfg["addr"].(string)
path, _ := cfg["path"].(string)
token, _ := cfg["token"].(string)
return vault.New(addr, path, token)
})
// Use — provider config can now come from YAML.
mgr, err := fastconf.New[AppConfig](ctx,
fastconf.WithProviderByName("vault", map[string]any{
"addr": "https://vault.svc",
"path": "kv/data/myapp",
"token": os.Getenv("VAULT_TOKEN"),
}),
)For multi-tenant / per-test isolation use a Manager-local registry:
local := fastconf.NewProviderRegistry()
local.Register("scoped", func(cfg map[string]any) (contracts.Provider, error) {
return myProvider(cfg)
})
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithProviderRegistry(local),
fastconf.WithProviderByName("scoped", map[string]any{...}),
)Local registry wins on name collision; global names remain resolvable.
YAML, JSON, and TOML are registered at init by pkg/decoder. You do
not need to call RegisterCodec for these formats — they are immediately
available to the discovery layer and to providers that take a Codec.
The decode bridge controls how the merged map[string]any becomes
*T:
fastconf.WithCodecBridge(fastconf.BridgeJSON) // default — uses encoding/json
fastconf.WithCodecBridge(fastconf.BridgeYAML) // uses gopkg.in/yaml.v3Use BridgeYAML when your struct fields only carry yaml tags. Use
BridgeJSON (the default) for structs with json tags or anything that
also goes through encoding/json elsewhere.
To register a custom codec (e.g. HCL, JSON5) at runtime:
fastconf.RegisterCodec("hcl", hclCodec{})
fastconf.RegisterCodecExt("hcl", "hcl") // .hcl files now route to that codectype Transformer interface {
Transform(root map[string]any) error
Name() string
}Transformers run after merge and before decode; they receive the merged
map[string]any and may safely mutate the tree.
import "github.com/fastabc/fastconf/pkg/transform"
fastconf.WithTransformers(
transform.Defaults(map[string]any{ // recursive merge — does not overwrite
"server": map[string]any{"timeout": "30s"},
}),
transform.SetIfAbsent("server.timeout", "30s"), // single-path default
transform.EnvSubst(), // ${VAR} / ${VAR:-default}
transform.DeletePaths("internal.debug"),
transform.Aliases(map[string]string{ // old path → new path
"db.url": "database.dsn",
"server.port": "server.addr",
}),
)type AppConfig struct {
Server struct {
Addr string `json:"addr" fastconf:"default=:8080"`
Timeout time.Duration `json:"timeout" fastconf:"default=30s"`
} `json:"server"`
Database struct {
DSN string `json:"dsn" fastconf:"secret"` // redacted in logs/snapshots
} `json:"database"`
}
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithStructDefaults[AppConfig](),
fastconf.WithSecretRedactor(fastconf.DefaultSecretRedactor),
)fastconf:"default=…" runs after decode and before validate, only
populating zero values. Field-meta tags (range=, enum=, required)
are checked in the same stage.
import "github.com/fastabc/fastconf/pkg/migration"
chain := migration.NewChain(
migration.Step{From: "1", To: "2", Apply: migrateV1toV2},
migration.Step{From: "2", To: "3", Apply: migrateV2toV3},
)
fastconf.WithMigrations(chain.Migrate)Or inline:
fastconf.WithMigrations(func(root map[string]any) error {
if v, ok := root["db_url"]; ok {
db, _ := root["database"].(map[string]any)
if db == nil { db = map[string]any{}; root["database"] = db }
if _, has := db["dsn"]; !has { db["dsn"] = v }
delete(root, "db_url")
}
return nil
})mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithDir("conf.d"),
fastconf.WithWatch(true),
// Defaults to ProfileK8s (quiet=30ms / maxLag=250ms / swapHint=5ms).
// Switch presets, or tweak one knob:
fastconf.WithCoalesceQuiet(50*time.Millisecond),
)
// Kubernetes ConfigMap ..data symlink atomic swaps are handled correctly
// by watching the parent directory; swap-commit detection tightens the
// burst window to swapHint (5ms), and per-dir keying prevents multiple
// ConfigMaps from blocking each other.cancel := fastconf.Subscribe(mgr,
func(app *AppConfig) *DatabaseConfig { return &app.Database },
func(old, neu *DatabaseConfig) {
if old != nil && *old == *neu { return } // caller-side diff
reconnect(neu.DSN)
},
)
defer cancel()Subscribe callbacks fire synchronously on the reload goroutine (a
recover() shields the loop from a panicking subscriber). For long
work, spawn a goroutine yourself.
err := mgr.Reload(ctx,
fastconf.WithReloadReason("admin-cli"),
fastconf.WithSourceOverride(map[string]any{
"server": map[string]any{"addr": ":9999"},
}),
)mgr.Watcher().Pause()
applyBatchUpdate()
mgr.Watcher().Resume()result, err := mgr.Plan().WithHostname("ci-runner-7").Run(ctx)
if err != nil {
log.Fatal("plan failed:", err)
}
for _, r := range result.Validators {
if r.Err != nil {
log.Printf("validator %s failed: %v", r.Name, r.Err)
}
}
for _, v := range result.Policies {
log.Printf("[%s] %s @ %s — %s", v.Severity, v.Rule, v.Path, v.Message)
}Plan never updates the atomic pointer; SeverityError policy
violations are downgraded to warnings in dry-run mode so CI can collect
every problem in a single pass.
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithDir("conf.d"),
fastconf.WithProvenance(fastconf.ProvenanceFull),
)
origins := mgr.Snapshot().Explain("server.addr")
for _, o := range origins {
fmt.Printf("layer=%s priority=%d value=%v\n", o.Source.Name, o.Source.Priority, o.Value)
}
// Strict lookup — distinguishes "provenance not enabled" from "path not found".
origins, err := mgr.Snapshot().LookupStrict("database.dsn")| Level | Cost | What you can trace |
|---|---|---|
ProvenanceOff |
zero | nothing |
ProvenanceTopLevel |
O(top-level keys) | which layer set each top-level field |
ProvenanceFull |
O(leaves) | full override chain per leaf, with each layer's raw value |
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithDir("conf.d"),
fastconf.WithHistory(10),
)
history := mgr.Replay().List() // []*State[T], oldest → newest
target := history[len(history)-2] // previous version
_ = mgr.Replay().Rollback(target)Rollback re-publishes a historic *State[T] to the atomic pointer; it
does not re-run the pipeline and does not bump Generation, but it does
fire Subscribe callbacks (filter on the caller side if you care).
go func() {
for re := range mgr.Errors() {
slog.Error("reload failed", "reason", re.Reason, "err", re.Err, "when", re.When)
}
}()Buffer 16, drop-on-full. The "keep old state on failure" contract is unchanged regardless of whether anyone reads this channel.
type AuditSink interface {
Audit(ctx context.Context, cause ReloadCause) error
}
sink := fastconf.NewJSONAuditSink(os.Stderr) // built-in JSON-lines sink
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithAuditSink(sink),
fastconf.WithAuditSink(remoteSink), // multiple sinks fan out
)
// Output: {"reason":"watcher","at":"2026-05-14T08:00:00Z","revisions":{"vault":"42"}}type MetricsSink interface {
ReloadStarted()
ReloadFinished(ok bool, dur time.Duration)
// Optional extensions: ProviderMetricsSink / StageMetricsSink / RenderMetricsSink
}A Prometheus implementation lives in a separate sub-module:
import prommetrics "github.com/fastabc/fastconf/observability/metrics/prometheus"
mgr, _ := fastconf.New[AppConfig](ctx, fastconf.WithMetrics(prommetrics.New()))Default is no-op. OTel SDK integration lives in a sub-module:
import fastconfotel "github.com/fastabc/fastconf/observability/otel"
tracer := fastconfotel.NewTracer(otel.GetTracerProvider())
mgr, _ := fastconf.New[AppConfig](ctx, fastconf.WithTracer(tracer))Build with -tags fastconf_otel to enable enriched span attributes.
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithDiffReporter(fastconf.DiffReporterFunc(
func(ctx context.Context, ev fastconf.DiffEvent) error {
return slack.Post(ctx, ev.Diff) // runs async; never blocks reload
},
)),
fastconf.WithDiffReporterQueueCap(128), // default 64
)Each reporter has its own bounded-queue worker:
- Enqueue is non-blocking; reload never waits on a slow reporter.
- Queue full → event dropped,
MetricsSink.EventDropped("diff-reporter")fires. Manager.Close()drains workers viabgWG.Wait()— no leaks.
import "github.com/fastabc/fastconf/policy"
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithPolicy(policy.Func[AppConfig]{
N: "deny-debug-in-prod",
Fn: func(_ context.Context, in policy.Input[AppConfig]) ([]policy.Violation, error) {
if in.Config.Env == "prod" && in.Config.Debug {
return []policy.Violation{{
Rule: "deny-debug-in-prod",
Path: "debug",
Message: "debug mode must be false in prod",
Severity: policy.SeverityError, // aborts reload
}}, nil
}
return nil, nil
},
}),
)CUE and OPA implementations live in policy/cue and policy/opa.
| Severity | Plan behaviour | Reload behaviour |
|---|---|---|
SeverityWarning |
logged, continues | logged, continues |
SeverityError |
downgraded to warning (dry-run collects everything) | aborts reload; old state preserved |
tm := fastconf.NewTenantManager[AppConfig]()
mgrA, _ := tm.Add(ctx, "tenant-a",
fastconf.WithDir("/etc/config/tenant-a"),
fastconf.WithProfileEnv("TENANT_A_PROFILE"),
)
mgrB, _ := tm.Add(ctx, "tenant-b",
fastconf.WithDir("/etc/config/tenant-b"),
fastconf.WithProvider(tenantBVaultProvider),
)
app, err := tm.Get("tenant-a") // *AppConfig, error (fastconf.ErrUnknownTenant)
_ = tm.Remove("tenant-a") // calls the underlying Manager.Close()
tm.Close()Each tenant is fully isolated; AuditSink receives Cause.Tenant = id.
// Standard Kubernetes ConfigMap deployment.
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.PresetK8s(fastconf.K8sOpts{
Dir: "/etc/config", ProfileEnv: "APP_PROFILE", Default: "default", Watch: true,
}),
fastconf.WithStrict(false), // override the preset's strict=true
)
// fastconfd sidecar.
fastconf.PresetSidecar(fastconf.SidecarOpts{
Dir: "/etc/fastconfd", HistoryN: 16, Watch: true, Strict: false,
})
// Test fixture: an in-process fs.FS for a known profile.
fastconf.PresetTesting(fastconf.TestingOpts{
FS: memFS, // fs.FS
Profile: "testing",
})
// Region / zone / host axis overlays.
fastconf.PresetHierarchical(fastconf.HierarchicalOpts{ /* ... */ })| Package | Path | Notes |
|---|---|---|
| contracts | contracts |
Public interfaces: Provider / Codec / Source / Event |
| pkg/* | pkg/{decoder,discovery,feature,flog,generator,mappath,merger,migration,profile,provider,transform,validate} |
Reusable primitives |
| internal/* | internal/{debounce,obs,typeinfo,watcher} |
Compile-time API boundary |
| http | providers/http |
HTTP / SSE provider (build tag no_provider_http) |
| vault | providers/vault |
HashiCorp Vault KV v2 (build tag no_provider_vault) |
| consul | providers/consul |
Consul KV (build tag no_provider_consul) |
| policy | policy |
Policy interface + Func adapter |
| integrations/bus | integrations/bus |
Configuration change bus |
| integrations/render | integrations/render |
Template render extension |
| cmd/fastconfd | cmd/fastconfd |
Sidecar HTTP + SSE service |
| Sub-module | Path | Tag prefix | Primary dependency |
|---|---|---|---|
| validate/playground | validate/playground |
validate/playground/vX.Y.Z |
go-playground/validator |
| prometheus | observability/metrics/prometheus |
observability/metrics/prometheus/vX.Y.Z |
prometheus/client_golang |
| otel | observability/otel |
observability/otel/vX.Y.Z |
OpenTelemetry SDK |
| cue-policy | policy/cue |
policy/cue/vX.Y.Z |
cuelang.org/go |
| opa-policy | policy/opa |
policy/opa/vX.Y.Z |
open-policy-agent/opa |
| cue-validate | validate/cue/cuelang |
validate/cue/cuelang/vX.Y.Z |
cuelang.org/go |
| log/phuslu | integrations/log/phuslu |
integrations/log/phuslu/vX.Y.Z |
phuslu/log |
| log/zerolog | integrations/log/zerolog |
integrations/log/zerolog/vX.Y.Z |
rs/zerolog |
| nats provider | providers/nats |
providers/nats/vX.Y.Z |
root module only (caller injects nats.Conn) |
| redis-streams provider | providers/redisstream |
providers/redisstream/vX.Y.Z |
root module only (caller injects redis client) |
| s3 provider | providers/s3 |
providers/s3/vX.Y.Z |
AWS SDK v2 (load + ETag short-circuit, FromURL helper) |
| s3events provider | providers/s3events |
providers/s3events/vX.Y.Z |
AWS SDK v2 SQS (EventBridge S3 → SQS watch sibling) |
| openfeature | integrations/openfeature |
integrations/openfeature/vX.Y.Z |
OpenFeature SDK |
| cmd/fastconfctl | cmd/fastconfctl |
cmd/fastconfctl/vX.Y.Z |
root module only |
| cmd/fastconfgen | cmd/fastconfgen |
cmd/fastconfgen/vX.Y.Z |
yaml.v3 |
Tag every sub-module at once via tools/tag-release.sh:
./tools/tag-release.sh vX.Y.Z # local tags only
./tools/tag-release.sh vX.Y.Z --push # push and trigger release.yml
./tools/tag-release.sh vX.Y.Z --force --pushtype RedisProvider struct {
client *redis.Client
key string
ch chan contracts.Event
}
func (p *RedisProvider) Name() string { return "redis:" + p.key }
func (p *RedisProvider) Priority() int { return contracts.PriorityKV }
func (p *RedisProvider) Load(ctx context.Context) (map[string]any, error) {
raw, err := p.client.Get(ctx, p.key).Bytes()
if err != nil { return nil, err }
var out map[string]any
return out, json.Unmarshal(raw, &out)
}
func (p *RedisProvider) Watch(ctx context.Context) (<-chan contracts.Event, error) {
go p.watchLoop(ctx)
return p.ch, nil
}
func init() {
fastconf.RegisterProviderFactory("redis", func(cfg map[string]any) (contracts.Provider, error) {
return NewRedisProvider(cfg["addr"].(string), cfg["key"].(string))
})
}type PrefixTransformer struct{ Prefix string }
func (t PrefixTransformer) Name() string { return "prefix:" + t.Prefix }
func (t PrefixTransformer) Transform(root map[string]any) error {
if v, ok := root["app_name"].(string); ok {
root["app_name"] = t.Prefix + "-" + v
}
return nil
}
fastconf.WithTransformers(PrefixTransformer{Prefix: "myorg"})YAML, JSON, and TOML are registered automatically. Register a new format like this:
fastconf.RegisterCodec("hcl", hclCodec{})
fastconf.RegisterCodecExt("hcl", "hcl") // .hcl files route to "hcl"| Need | Use |
|---|---|
| Add a data source | implement contracts.Provider |
| Rewrite the merged tree | implement Transformer |
| Decrypt leaves before decode | implement SecretResolver |
| Type-rewrite leaves before decode | implement decoder.TypedHook |
| Assert after decode | WithValidator / WithPolicy |
| Act on successful publish | AuditSink / DiffReporter |
| Add a file format | implement contracts.Codec + RegisterCodec |
fastconfd --dir=/etc/config --profile=prod --addr=:8081| Endpoint | Method | Description |
|---|---|---|
/healthz |
GET | {"status":"ok","generation":N} |
/version |
GET | Current state version (Hash + Generation) |
/config |
GET | Current config JSON (secrets redacted) |
/reload |
POST | Trigger a manual reload; accepts {"request_id":"…"} |
/events |
GET | SSE stream of ReloadCause JSON on every successful reload |
fastconfctl snapshot --addr=:8081
fastconfctl reload --addr=:8081 --request-id=deploy-123
fastconfctl plan --addr=:8081
fastconfctl rollback --addr=:8081 --generation=42
fastconfctl sources --addr=:8081fastconfgen generate --input=conf.d/base/00-app.yaml --pkg=config --out=config/config_gen.goMost recent benchmark run: Apple M2 / darwin-arm64 / Go 1.26.2.
| Benchmark | median |
|---|---|
BenchmarkGet |
0.52 ns/op |
BenchmarkReloadNoop |
15.1 µs/op |
BenchmarkReloadCommitSmall |
16.5 µs/op |
BenchmarkReloadManySubscribers/50 |
17.5 µs/op |
BenchmarkIntrospectCold |
1.67 µs/op |
BenchmarkExplainDeep |
219 ns/op |
Full baseline, command lines, and explanation: docs/design/perf.md.
The contract is: hot reads are essentially free; reload may fail but never publishes a half-built state; subscriber fan-out never blocks the read path.
# Dependencies
go mod tidy
# Build / test / lint
make build
make test # go test -race -count=1 ./...
make test-all # includes sub-modules
make lint # requires golangci-lint
# Examples
go test ./... -run '^Example' -v
# Benchmarks
go test -bench=BenchmarkGet -benchmem ./...
# CI guards
bash tools/check-layout.sh
bash tools/check-doc-symbols.sh
bash tools/check-deps.sh
bash tools/bench-guard.sh
bash tools/loc-budget.sh
bash tools/total-loc-budget.sh
# Code-review dependency graph
bash tools/code-review-graph.sh| Doc | Purpose |
|---|---|
docs/cookbook/README.md |
Single entry point for every recipe |
docs/design/spec.md |
Runtime model, concurrency, module boundaries |
docs/design/perf.md |
Latest benchmark baseline |
CHANGELOG.md |
Release notes |
pkg.go.dev |
godoc and runnable examples |
Common recipes:
k8s·reload-policy·planvault·consul·cross-process·provider-timeoutssecrets·features·openfeaturediff-reporter·policy·otelintrospect·field-meta·typed-hookslabels·strategic-merge·generatorstenant·sidecar·dump·log
MIT License
Copyright (c) 2026 FastAbc
See LICENSE.