Skip to content

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.
  • ProvenanceExplain and Origin name the adapter as the source of a value, and flag where it shadows or is shadowed.
  • Coherent reads — a View is 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.FSembed.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); SecureString values 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: latest means 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, because BatchGetSecretValue returns 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/Encode pair; add one and every store feature comes with it for free.