The adapter ecosystem¶
One store, many sources. YAML files are the default config reads out of the box, but
they are not the boundary. A file in another format, or configuration that lives in a remote
system rather than a file at all, joins the store as an ordinary layer — with the same
precedence, the same per-key provenance, the same coherent snapshots and the same fail-closed
reload every other layer gets.
That is the difference that matters. A library that treats "remote config" as a bolt-on
special case gives you a value and little else; here there is no special case. Consul, a
parameter store or a secrets manager takes part in the merge exactly as /etc/app.yaml does,
and Explain("server.port") will name it as the source. This is what the previous
Viper-shaped world could not do.
Every adapter is its own sibling module, depended on only by the consumers who use it. Your dependency graph carries the one integration you reached for and nothing else: a consumer reading TOML never compiles the XML parser, and a consumer configuring from Consul never pulls a cloud SDK it does not touch.
What every adapter inherits¶
An adapter only teaches the store how to read (and, where it makes sense, write and watch) one kind of source. Everything else is the core's job, so it is identical across the whole family:
- Precedence — the adapter's layer sits wherever you place it in the source order.
- Provenance —
ExplainandOriginname the adapter as the source of a value, and flag where it shadows or is shadowed. - Coherent reads — a
Viewis pinned to one snapshot regardless of which adapters fed it. - Write fidelity — where an adapter supports writes, the change lands in the layer that owns the key, and structure (comments, order, quoting) is preserved.
- Fail-closed reload — a source that will not parse or fails your schema is rejected; last-known-good stays live.
- A ladder to its client, for every adapter that has one. You can inject the narrow interface, the built SDK client, or the provider's native config — and most also offer a zero-conf rung that resolves the ambient credential chain for you. Injection stays the default; see who owns the connection.
File & format adapters¶
Available now, each published and versioned. The adapter name links to its how-to guide.
| Adapter | Handles | Reads | Writes | Source |
|---|---|---|---|---|
config-json |
JSON & JSON Lines | ✓ | ✓ (structure-preserving) | repo · API |
config-toml |
TOML | ✓ | ✓ (structure-preserving) | repo · API |
config-hcl |
HCL (as a config format, not Terraform) | ✓ | ✓ | repo · API |
config-xml |
XML | ✓ | — | repo · API |
config-dotenv |
dotenv (.env) |
✓ | — | repo · API |
config-ini |
INI | ✓ | — | repo · API |
config-properties |
Java .properties |
✓ | — | repo · API |
The read-only format adapters (dotenv, ini, properties, xml) add no third-party
dependency — they parse their format in-module. And if the format you need is not here,
write a format adapter: a codec is a Decode/Encode pair, and
the store handles the rest.
Filesystem adapters¶
A format adapter decides how a config file is parsed; a filesystem adapter decides where it
lives. The two compose — you can read TOML out of an S3 bucket — because config imposes no
filesystem of its own: config.FS is six methods you can satisfy over the
real disk (config.OS()), a rooted directory (config.Dir), an embedded filesystem, a remote host
or a cloud object store. A filesystem adapter reads a configuration file that happens to live
somewhere other than local disk — which is what distinguishes it from a dynamic backend,
that maps a remote key–value namespace instead.
Two implementations ship in the core — config.OS() and config.Dir(path) — so you only reach for
an adapter when the file lives somewhere neither covers.
| Adapter | Where the file lives | Reads | Writes | Source |
|---|---|---|---|---|
config-afero |
an existing afero filesystem | ✓ | ✓ | repo · API |
config-iofs |
any io/fs.FS — embed.FS, zip, tar |
✓ | — | repo · API |
config-billy |
a go-billy filesystem (go-git) | ✓ | ✓ | repo · API |
config-sftp |
a remote host over SSH | ✓ | ✓ | repo · API |
config-iofs is read-only because io/fs is read-only by design, and adds no third-party
dependency. How reload, read-only capability and the object-store commit work across the family is
How filesystem adapters work; the
filesystem adapters spec is the umbrella
that governs it.
Cloud object stores¶
The three cloud object stores share one model — a config file that lives in a bucket, watched by polling, committed with the store's best atomic primitive — and differ only where the services do (GCS has a native atomic move; S3 and Azure Blob copy-then-delete). Each is its own approved spec, built and released independently.
| Adapter | Store | Commit | Status | Source |
|---|---|---|---|---|
config-aws-s3 |
AWS S3 | copy-then-delete | Released | repo · API |
config-gcp-gcs |
GCP Cloud Storage | native Move |
Released | repo · API |
config-azure-blob |
Azure Blob Storage | copy-then-delete | Released | repo · API |
All three are read+write, poll at a 60-second default (a poll is a billed object read;
WithPollInterval overrides), and are proven against a real emulator — LocalStack, fake-gcs-server
and Azurite — in a Docker-in-Docker job.
Dynamic backends¶
The next chapter, and the one that pulls furthest ahead of a file-only tool: configuration
fetched at runtime from a remote system, given full precedence, provenance and hot-reload
exactly as a file is. The seam already exists and is proven —
WithBackend takes anything satisfying a three-method Backend,
with writes and native watch as opt-in capabilities. The
dynamic backend adapters spec is the
umbrella that governs the whole family.
config-consul — the first¶
config-consul — released
(API) — reads and writes
configuration from HashiCorp Consul through config. You build and
configure the Consul client — every address, token, TLS and datacenter decision stays yours —
and hand it in with a prefix that scopes and is stripped from the keys:
import (
capi "github.com/hashicorp/consul/api"
"gitlab.com/phpboyscout/go/config"
configconsul "gitlab.com/phpboyscout/go/config-consul"
)
client, _ := capi.NewClient(capi.DefaultConfig())
store, err := config.NewStore(ctx,
config.WithFiles(fsys, "/etc/app.yaml"), // YAML defaults
config.WithBackend(configconsul.FromClient(client, "app/")), // Consul outranks them
)
A Consul layer takes part in precedence, per-key merge, provenance and hot-reload exactly as a
file does — and Explain will tell you when a value came from Consul rather than the file
beneath it. It is the reference implementation for everything that follows. Learn it by building
one in the Configure from Consul tutorial, reach for a specific operation in
the Read & write Consul how-to, or read How the Consul backend
works for the data, conflict and watch models behind it.
Parameter stores — released¶
Consul's siblings, the cloud parameter stores, are all released too. They share Consul's shape — injected client, prefix-scoped nested tree, values decoded through an injected codec — and differ where the systems do, which is what How dynamic backends work explains. In short: they poll rather than watch natively, and they split on compare-and-swap.
config-aws-ssm— AWS SSM Parameter Store. Read-only (SSM has no compare-and-swap);SecureStringvalues read decrypted and mark the layer sensitive so the leak guard protects them.config-azure-appconfig— Azure App Configuration. Read and write on per-key ETag compare-and-swap; scoped by a label.config-gcp-parameter— GCP Parameter Manager. Read-only, in two shapes: one parameter as a whole document, or a prefix of many parameters.
Secrets managers¶
Phase B holds the secrets managers, and they differ from the parameter stores in one way that
changes how they behave: they are Sensitive, so the core refuses to write a value they
provide into a plainer layer beneath (sensitive read-only
backends). The remote ones ship read-only —
a config tool writing to an audited secrets store is a rarer and riskier act than reading from one.
The local keychain is the exception, and the reason it is one is worth reading below.
config-vault— HashiCorp Vault KV v2. Read-only, statically sensitive, polled at 60 seconds. Reads one secret by default, or walks a whole prefix; a secret's field colliding with a child secret is refused rather than guessed. Unlike the byte-valued stores it needs no value codec — Vault returns already-structured JSON.config-filekv— a directory of single-value files, where each filename is a key. Not one system but a shape three of them share: a mounted Kubernetes ConfigMap or Secret, Docker and Podman secrets under/run/secrets, and systemd credentials. Read-only by default with opt-in writes, and it adds no module — which is much of why it exists, since the alternative for a ConfigMap is a 38-module Kubernetes client to read data the pod already has. Its integration suite proves the mount layout against a real cluster rather than against documentation.config-keychain— the local OS keychain, and the odd one out of this group in two ways. It is writable, because the token it holds is one the application just obtained on the user's own machine rather than something a separate audited process provisioned — so a first token is written into it and one already there can never reach the file beneath. And its key space is declared rather than discovered, because a keychain offers no way to enumerate itself. It exists to stop tokens landing in plaintext config files.config-gcp-secret— GCP Secret Manager. Read-only, statically sensitive, polled on version metadata so a quiet poll stays out of the data-access audit stream. Flat IDs verbatim, or one secret as a document. Its distinctive behaviour is version states:latestmeans most recently created, so a disabled newest version makes it unreadable and the adapter falls back to the newest enabled one — reporting that through a callback, because provenance cannot carry a per-key resolution. The heaviest adapter here at 39 modules.config-azure-keyvault— Azure Key Vault. Read-only, statically sensitive, polled at five minutes. The one store here with no hierarchy at all: names allow only letters, digits and hyphens, so a name is a key verbatim and structure comes from a document in one secret. Reading a vault is one request per secret, because the listing carries no values.config-aws-secrets— AWS Secrets Manager. Read-only, statically sensitive, polled. Reads a prefix by default — the reversal of Vault's shape, becauseBatchGetSecretValuereturns a whole prefix in one request — or one secret as a whole document, the RDS-managed shape. A partial read is refused rather than served with keys missing; a staging label can be selected, at the cost of the bulk read. Its SDK is five modules, the leanest backend adapter in the toolkit.
Roadmap¶
Each adapter below carries its own SDK, its own authentication and its own consistency and watch semantics, so — by the umbrella's headline rule — each gets its own approved spec before it is built. The grouping is a planned order, not a commitment date.
| Adapter | System | Phase | Status |
|---|---|---|---|
config-consul |
HashiCorp Consul | A — reference & parameter stores | Released |
config-aws-ssm |
AWS SSM Parameter Store | A | Released (read-only) |
config-azure-appconfig |
Azure App Configuration | A | Released |
config-gcp-parameter |
GCP Parameter Manager | A | Released (read-only) |
config-vault |
HashiCorp Vault | B — secrets managers | Released (read-only) |
config-aws-secrets |
AWS Secrets Manager | B | Released (read-only) |
config-azure-keyvault |
Azure Key Vault | B | Released (read-only; not yet exercised against a real vault) |
config-gcp-secret |
GCP Secret Manager | B | Released (read-only; not yet exercised against a real project) |
config-keychain |
OS keychain | — not in the umbrella | Released (read and write) |
config-filekv |
a directory of single-value files | — not in the umbrella | Released (read, opt-in write) |
config-etcd |
etcd v3 | C — cloud-native key–value | Released (read and write, native watch, atomic across keys) |
config-k8s |
Kubernetes ConfigMaps | C | Rejected — a ConfigMap already reaches a pod as a file or an environment variable (umbrella R4) |
Secrets managers ship read-only by default — a config tool writing a secret is a rarer and riskier thing than reading one, so write support for those is opt-in and specified per adapter. Feature-flag systems are deliberately out of scope.
Not every sibling module is a backend¶
An adapter teaches the store about a source. One module in the family does something else entirely, and it is easy to miss when scanning the tables above for a system name.
| Module | Provides | Source |
|---|---|---|
config-schema |
JSON Schema validation — FromJSON for a schema document, FromStruct for one derived from config: tags |
gitlab |
The core defines what validating a configuration means — the Schema interface, mounting a
contribution with WithSchemaAt, aggregating the results — and takes no position on how a
schema is written. config-schema supplies the JSON Schema dialect, its library and its
ingestion.
That split is the same dependency-footprint argument the adapters are built on, applied to a
capability rather than a source. Twenty-five adapters depend on config, and each pins its
footprint; a JSON Schema library linked into the core would widen every one of them for
something most do not use. Behind an interface it costs them nothing, and a consumer who wants
it adds one module.
The tag-derived config.NewSchema stays in the core, so the common case needs no extra module
at all. Reach for config-schema when the schema is a document — one you already publish,
share with a non-Go consumer, or generate.
Build your own¶
Nothing here is a closed set. The same two seams the family is built on are yours to use:
- Write a custom backend — make any remote system (a secrets manager, an HTTP endpoint, an internal service) an ordinary layer, walked end to end against a Consul-shaped example.
- Support a new file format — a codec is a
Decode/Encodepair; add one and every store feature comes with it for free.