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: first-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
- Transformers & migration
- Watch, Subscribe, and Plan
- Provenance, history & rollback
- Observability
- Multi-tenant & presets
- Sub-module ecosystem
- CLI tools
- Performance
- Development
- License
package main
import (
"context"
"log"
"github.com/fastabc/fastconf"
"github.com/fastabc/fastconf/pkg/provider"
)
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.WithProfile(fastconf.ProfileOptions{
EnvVar: "APP_PROFILE",
Default: "dev",
}),
fastconf.WithProvider(provider.NewEnv("APP_")),
fastconf.WithWatch(fastconf.WatchOptions{Enabled: 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
# conf.d/base/00-app.yaml
server:
addr: ":8080"
database:
dsn: "postgres://localhost/app"
pool: 10Run with an environment override:
APP_PROFILE=prod APP_DATABASE_POOL=20 go run .APP_DATABASE_POOL=20 maps to database.pool (single _ separator,
Viper/Spring Boot style). With APP_PROFILE=prod, FastConf merges base/*
first, then overlays/prod/*.
| Scenario | Recommended combo | Read more | Runnable example |
|---|---|---|---|
| Local file config | New + WithDir + Get |
Quickstart | examples/basic |
| Kubernetes hot-reload | PresetK8s + Subscribe + Errors |
k8s cookbook | examples/sidecar |
| Remote source / GitOps | WithProvider + Plan + Provenance |
Vault / Consul | examples/external_source |
- 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 strategic merge for lists of objects.
- Opt-in extensions. Providers, transformers, secret resolvers, validators, policies, metrics, and tracing are all optional.
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/cue@latest
go get github.com/fastabc/fastconf/policy/opa@latest
go get github.com/fastabc/fastconf/providers/s3@latestCommand-line tools (Go ≥ 1.22):
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@latest| Item | Supported |
|---|---|
| Go toolchain | 1.22, 1.23, 1.24, 1.25, 1.26 (no toolchain pin in go.mod) |
| OS / arch | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 (binaries published on each tag) |
| Module form | one root module + independent sub-modules (cue, policy/opa, validate/playground, observability/{otel,metrics/prometheus}, providers/s3, integrations/{cli/pflag,log/phuslu,log/zerolog}, cmd/{fastconfctl,fastconfd,fastconfgen}) |
| Pre-release contract | semantic-version tags follow vMAJOR.MINOR.PATCH. The current line (v0.18) is the first public release and the rename / bucketed-Options boundary is locked in — see migration-v0.18.md. |
- Tags follow
vMAJOR.MINOR.PATCH. The root module and every sub-module receive the same tag throughtools/tag-release.sh vX.Y.Z. - Major-version
0is reserved for the pre-1.0 cycle. Breaking changes may still land between minor versions until v1.0, but each release ships with an explicit migration recipe underdocs/cookbook/so the call-site delta is mechanical. - The internal package set under
internal/*is implementation detail and not covered by the SemVer contract — root re-exports (type aliases or wrappers) are the only stable surface. - The reusable primitives under
pkg/*keep a unidirectional dependency shape (see the whitelist inCLAUDE.md);tools/check-deps.shstatically enforces it in CI so consumers can pull in a singlepkg/*subpackage without dragging in hidden lateral dependencies. - When sub-modules tag independently the tag is module-path-prefixed
(e.g.
cue/vX.Y.Z); the README mostly hides this because a single release pushes the same version across the root and every sub-module. - Before each release we run
make testplus seven guard scripts undertools/{check-layout,check-deps,check-doc-symbols,audit-phase-comments, check-cjk-comments,loc-budget,total-loc-budget}.sh, so directory layout, dependency direction, public symbols, comment archaeology and LOC budgets are all enforced before a tag is pushed.
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 |
// 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
// Trigger a reload; ctx controls the full pipeline.
func (m *Manager[T]) Reload(ctx context.Context, opts ...ReloadOption) error
// Dry-run — never updates the live pointer
func (m *Manager[T]) Plan() *PlanBuilder[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
func (m *Manager[T]) Watcher() *Watcher[T] // .Pause() / .Resume()
func (m *Manager[T]) Replay() *Replay[T] // .List() / .Rollback(*State[T])
func (m *Manager[T]) Close() errorPackage-level generics:
// 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.
func Eval[T, V any](m *Manager[T], key string, ctx feature.EvalContext, def V) VState[T] carries Value *T, Hash [32]byte, Generation uint64,
Sources []SourceRef, and provenance helpers (Explain, Diff, Redacted).
All WithXxx options return Option and may be composed in any order.
The full reference is in docs/readme/02-core-model.md.
| Option | Purpose | Default |
|---|---|---|
WithDir(dir) |
Config root directory | "conf.d" |
WithFS(fs.FS) |
Alternate fs.FS (testing) |
— |
WithWatch(WatchOptions{...}) |
Enable fsnotify; bundles Enabled / Paths / Coalesce / CoalesceProfile |
Enabled:false |
WithProfile(ProfileOptions{...}) |
Profile selection bundle: Single, Multi, Expr, EnvVar, Default |
— |
WithCoalesce(CoalesceOptions{...}) |
Tune watcher Quiet / MaxLag / SwapHint independently of WithWatch |
— |
WithProvider(p) |
Register a structured provider | — |
WithSource(src, parser) |
Byte-blob source + parser | — |
WithMigrations(fn) |
Schema migration callback | — |
WithTransformers(t...) |
Post-merge transform chain | — |
WithSecretResolver(r) |
Decrypt leaves before decode | — |
WithValidator[T](fn) |
Typed validation after decode | — |
WithPolicy[T](p) |
Policy evaluation after validate | — |
WithHistory(n) |
Keep last n successful states |
— |
WithProvenance(level) |
Off / TopLevel / Full |
Off |
WithMetrics(sink) |
Metrics sink | — |
WithAuditSink(sink) |
Audit on each successful reload | — |
WithTracer(tracer) |
OTel-compatible tracer | — |
WithLogger(*slog.Logger) |
Inject a logger | io.Discard |
WithStructDefaults[T]() |
Populate zero values via fastconf:"default=…" tags |
— |
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)
├─ stageTransform: for each transformer: t.Transform(merged)
├─ stageDecode: json.Marshal(merged) → json.Unmarshal(→ *T)
├─ stageFieldMeta: range / enum / required checks
├─ stageValidate: for each validator: v(*T)
├─ stagePolicy: for each policy: p.Evaluate(ctx, *T, reason, tenant)
└─ commit:
canonical SHA-256 dedup
atomic.Pointer.Store(newState) → history → audit → subscribers
When any stage errors: atomic.Pointer is not updated, Generation
is not incremented, the error surfaces on Errors(), no AuditSink fires.
conf.d/
base/ # applied for every profile
00-defaults.yaml
overlays/
prod/
50-prod.yaml
_meta.yaml # profile match expression
_patch.json # RFC 6902 patch
schemaVersion: "1"
profileEnv: "APP_PROFILE"
defaultProfile: "dev"
appendSlices: true
match: "prod | staging" # &, |, !, () supported[
{ "op": "replace", "path": "/server/addr", "value": ":8443" },
{ "op": "add", "path": "/feature/darkMode", "value": true },
{ "op": "remove", "path": "/legacy/key" }
]Multi-profile mode: WithProfile(ProfileOptions{Multi: []string{"prod", "eu-west", "canary"}})
— each overlay's _meta.yaml.match decides whether it applies.
| Provider | Constructor | Notes |
|---|---|---|
| Env | provider.NewEnv("APP_") |
APP_FOO_BAR → foo.bar; chain .WithReplacer, .At, .WithCoerce |
| CLI | provider.NewCLI(map) |
Pass only explicitly changed flags; files/env stay authoritative |
| DotEnv | provider.NewDotEnv("APP_", paths...) |
.env fallback; process env wins |
| Labels | provider.NewDottedLabels(labels, opts) / NewRoutingLabels(labels, opts) |
Config and routing DSL labels |
| K8s Downward | k8s.NewDefault() |
/etc/podinfo/{labels,annotations} |
First-party KV providers (root module, trim via build tag):
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{})
// Build tag to exclude: -tags no_provider_vault,no_provider_consul,no_provider_httpSub-module providers (go get as needed): S3 (providers/s3), NATS
(providers/nats), Redis Streams (providers/redisstream).
Merge order follows Priority() ascending — higher values overwrite lower:
| Constant | Value | Use |
|---|---|---|
PriorityDotEnv |
5 | .env fallback |
PriorityStatic |
10 | Static / file layers |
PriorityKV |
30 | Vault / Consul / HTTP / S3 |
PriorityK8s |
40 | Kubernetes ConfigMap / Secret |
PriorityEnv |
50 | Process environment variables |
PriorityCLI |
60 | Command-line flags (highest) |
Use WithProviderOrdered(p1, p2, p3) to auto-assign priorities in call order.
type Provider interface {
Name() string
Priority() int
Load(ctx context.Context) (map[string]any, error)
Watch(ctx context.Context) (<-chan Event, error)
}fastconf.WithTransformers(
transform.Defaults(map[string]any{"server": map[string]any{"timeout": "30s"}}),
transform.SetIfAbsent("server.timeout", "30s"),
transform.EnvSubst(), // ${VAR} / ${VAR:-default}
transform.DeletePaths("internal.debug"),
transform.Aliases(map[string]string{"db.url": "database.dsn"}),
)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"`
}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
})For multi-step schema migrations use pkg/migration.NewChain.
cancel := fastconf.Subscribe(mgr,
func(app *AppConfig) *DatabaseConfig { return &app.Database },
func(old, neu *DatabaseConfig) {
reconnect(neu.DSN)
},
)
defer cancel()err := mgr.Reload(ctx,
fastconf.WithReloadReason("admin-cli"),
fastconf.WithSourceOverride(map[string]any{
"server": map[string]any{"addr": ":9999"},
}),
)result, err := mgr.Plan().WithHostname("ci-runner-7").Run(ctx)
// result.Validators — validation errors
// result.Policies — policy violations (SeverityError downgraded to warning in dry-run)mgr.Watcher().Pause()
applyBatchUpdate()
mgr.Watcher().Resume()mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithDir("conf.d"),
fastconf.WithProvenance(fastconf.ProvenanceFull),
)
origins := mgr.Snapshot().Explain("server.addr")
// each Origin: Source.Name, Source.Priority, Value| 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 |
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithHistory(10),
)
history := mgr.Replay().List() // []*State[T], oldest → newest
_ = mgr.Replay().Rollback(history[len(history)-2])go func() {
for re := range mgr.Errors() {
slog.Error("reload failed", "reason", re.Reason, "err", re.Err)
}
}()// JSON-lines audit on each successful reload
mgr, _ := fastconf.New[AppConfig](ctx,
fastconf.WithAuditSink(fastconf.NewJSONAuditSink(os.Stderr)),
fastconf.WithDiffReporter(fastconf.DiffReporterFunc(
func(ctx context.Context, ev fastconf.DiffEvent) error {
return slack.Post(ctx, ev.Diff) // async, never blocks reload
},
)),
)Prometheus metrics and OpenTelemetry tracing live in sub-modules:
import prommetrics "github.com/fastabc/fastconf/observability/metrics/prometheus"
import fastconfotel "github.com/fastabc/fastconf/observability/otel"
fastconf.WithMetrics(prommetrics.New())
fastconf.WithTracer(fastconfotel.NewTracer(otel.GetTracerProvider()))Policy violations abort reload at SeverityError; SeverityWarning logs
and continues. CUE and OPA implementations in cue/policy and policy/opa.
// Multi-tenant: each tenant is a fully isolated Manager[T]
tm := fastconf.NewTenantManager[AppConfig]()
mgrA, _ := tm.Add(ctx, "tenant-a", fastconf.WithDir("/etc/config/tenant-a"))
app, err := tm.Get("tenant-a") // fastconf.ErrUnknownTenant if absent
tm.Close()// Presets
fastconf.PresetK8s(fastconf.K8sOpts{Dir: "/etc/config", Watch: true})
fastconf.PresetSidecar(fastconf.SidecarOpts{Dir: "/etc/fastconfd", HistoryN: 16})
fastconf.PresetTesting(fastconf.TestingOpts{FS: memFS, Profile: "testing"})| Package | Path |
|---|---|
| contracts | contracts — public interfaces |
| reusable primitives | pkg/{decoder,discovery,feature,flog,generator,merger,migration,provider,transform,validate} |
| http / vault / consul | providers/{http,vault,consul} — build tags: no_provider_{http,vault,consul} |
| policy | policy — Func adapter |
| sidecar service | cmd/fastconfd |
| Sub-module | Path | Primary dependency |
|---|---|---|
| validate/playground | validate/playground |
go-playground/validator |
| prometheus | observability/metrics/prometheus |
prometheus/client_golang |
| otel | observability/otel |
OpenTelemetry SDK |
| cue (validation + policy) | cue |
cuelang.org/go |
| opa-policy | policy/opa |
open-policy-agent/opa |
| cli/pflag | integrations/cli/pflag |
spf13/pflag |
| nats provider | providers/nats |
root module (inject nats.Conn) |
| redis-streams provider | providers/redisstream |
root module (inject redis client) |
| s3 provider | providers/s3 |
AWS SDK v2 |
| openfeature | integrations/openfeature |
root module |
| fastconfctl | cmd/fastconfctl |
root module |
| fastconfgen | cmd/fastconfgen |
yaml.v3 |
Tag all sub-modules at once: ./tools/tag-release.sh vX.Y.Z [--push]
fastconfd --dir=/etc/config --profile=prod --addr=:8081| Endpoint | Method | Description |
|---|---|---|
/healthz |
GET | {"status":"ok","generation":N} |
/config |
GET | Current config JSON (secrets redacted) |
/reload |
POST | Trigger a manual reload |
/events |
GET | SSE stream of ReloadCause on each successful reload |
fastconfctl snapshot --addr=:8081
fastconfctl reload --addr=:8081 --request-id=deploy-123
fastconfctl rollback --addr=:8081 --generation=42fastconfgen 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 |
Full baseline: docs/design/perf.md.
go mod tidy
make build
make test # go test -race -count=1 ./...
make test-all # includes sub-modules
make lint # requires golangci-lint
go test ./... -run '^Example' -v
go test -bench=BenchmarkGet -benchmem ./...| Doc | Purpose |
|---|---|
| docs/readme/ | In-depth chapters: core model, pipeline, extensions, operations |
| docs/cookbook/README.md | Ready recipes ordered by user journey |
| docs/design/spec.md | Runtime model, concurrency, module boundaries |
| docs/cookbook/migration-v0.18.md | v0.18 rename / bucketed-Options migration table |
| GitHub Releases | Release notes and prebuilt CLI binaries |
| pkg.go.dev | godoc and runnable examples |
Common recipes: k8s · vault · consul · secrets · features · policy · otel · tenant · sidecar · plan
MIT License, See LICENSE.
Copyright (c) 2026 FastAbc