Flat Keys, Real Inheritance: How Smpl Config Works
Configuration management at scale has two competing failure modes. The first is configuration sprawl — the same value defined in forty places, nobody sure which one is authoritative. The second is configuration coupling — a hierarchy so deep that changing a root value has consequences you can’t fully trace.
Smpl Config is our answer to both. Its design centers on two decisions that look simple but have significant consequences: flat dot-notation keys (no deep merge), and explicit inheritance through a parent field.
Why Flat Keys
The most common configuration model uses nested objects: a YAML file where database.host means the host key inside a database namespace. Deep merge lets you override specific nested keys without restating the whole object.
Flat dot-notation keys express the same namespacing — database.host, database.port — without the object nesting. The key string is the full path. There’s no nesting in the stored data, just strings that happen to contain dots.
The difference matters when you override. With deep merge and nested objects, overriding database.host at the service level while inheriting database.port from the parent requires the merge logic to understand object traversal. Different tools (Helm, Kustomize, environment-specific YAML overlays) implement deep merge slightly differently, and the edge cases accumulate. Which level wins? What happens if a child adds a key the parent doesn’t have? What if a child wants to delete a parent key?
Flat keys make override semantics explicit and simple. Each key is either defined at this config or inherited from the parent. There’s no ambiguity about whether database.host at the service level overrides or merges with database.host at the parent level — it overrides it, completely. One key, one value, one owner at each level.
The trade-off: you can’t override “everything under database.*” with a single operation. You need to list each key explicitly. In practice, we’ve found this makes overrides more intentional rather than less convenient.
The Inheritance Model
Each config has an optional parent field referencing another config by key. When the SDK resolves a config, it builds a resolved view: for each key defined in the config, use that value; for each key defined in the parent (recursively) but not in this config, inherit the parent’s value.
The canonical use case: a common-config defines shared values — database connection strings, external API endpoints, feature flag defaults — that every service starts with. Service-specific configs inherit from common-config and override only what’s different for their service.
common-config
├── database.host: db.example.internal
├── database.port: 5432
├── logging.level: INFO
└── timeout.api: 5000
payments-service (parent: common-config)
└── timeout.api: 15000 # payments needs longer timeout; inherits rest
The payments-service config defines one key and inherits four. When the payments service resolves its config, it gets database.host, database.port, logging.level from the parent, and its own timeout.api.
Inheritance is recursive but shallow by design: we don’t support deep chains (parent of parent of parent) because they make the inheritance chain hard to reason about. A two-level chain (service → common) covers the overwhelming majority of real-world cases.
Per-Environment Overrides
Config values exist per environment. The full representation of a key is a map from environment name to value: {"production": "db.prod.example.internal", "staging": "db.staging.example.internal"}.
The console shows this as a grid: configs as rows, environments as columns. Each cell is the value for that key in that environment, or an empty cell indicating “inherit from parent” (or use the key’s default, if it has one).
When an SDK resolves a config, it passes the environment name. The service returns the most specific applicable value: the key’s environment-specific value if set, otherwise the parent’s environment-specific value, otherwise the key’s default.
This model makes environment promotion explicit. Promoting a value from staging to production means editing the production cell of that key to match staging’s value. There’s no “copy entire environment” operation that could accidentally carry over values you didn’t intend.
Types and Descriptions
Each key has a type (STRING, NUMBER, BOOLEAN, JSON) and a human-readable description. These are metadata, not enforcement — the stored value is always a string internally, and the SDK coerces it to the declared type at resolution time.
The description field is underused by many configuration systems. We made it first-class because configuration values without context become cargo-cult artifacts: everyone knows the value is 5000 but nobody remembers why, and nobody is willing to change it because they don’t know what breaks.
Descriptions appear in the console next to every key. An operator seeing timeout.api: 5000 for the first time can read “API request timeout in milliseconds. Payments increased to 15000 for Stripe calls.” and understand the intent without hunting down the PR that set it.
The Resolution API
The SDK’s resolution surface is deliberately simple:
# Returns a dict of key → value, coerced to declared types
config = client.config.resolve("payments-service", environment="production")
db_host = config["database.host"]
# Returns a typed Pydantic model if a model class is provided
config = client.config.resolve("payments-service", model=PaymentsConfig)
db_host = config.database_host
The resolve() call fetches the full resolved config for the specified config key and environment — all inherited keys included, all overrides applied. The SDK caches the result and subscribes to change notifications over WebSocket. If any key in the resolved config changes, the SDK re-resolves and notifies the application.
The subscribe() variant returns a live proxy: a dict-like object that always reflects the current resolved state. Accessing a key from the proxy reads the latest value, not a snapshot.
live_config = client.config.subscribe("payments-service", environment="production")
# This always reads the current value, even after the config changes:
timeout = live_config["timeout.api"]
The {"value": ...} Wrapper
One API detail worth explaining: config values are returned in a {"value": "5000"} wrapper, not as bare strings. This is a JSON:API convention artifact — in JSON:API, resource attribute values can’t be bare primitives.
The wrapper looks odd until you’ve worked with config APIs that return bare primitives and then need to distinguish “the key is absent” from “the key has an empty string value” from “the key has a null value.” The wrapper makes these cases unambiguous.
The SDK handles the unwrapping transparently. Application code sees the unwrapped value; the API layer sees the wrapped representation. This is an implementation detail that leaks only if you call the API directly.
What We’d Revisit
Config validation at set time. We validate types at resolution time (when the SDK reads the value), not at set time (when an operator writes it). A typo in a numeric field — "500o" instead of "5000" — passes validation in the console and fails only when an SDK reads it. Catching this at set time would be better.
Config diff between environments. A common operator question: “what’s different between my staging and production configs?” We don’t have a native diff view. You can compare the two environments manually in the console, but it’s not as ergonomic as a side-by-side diff.
Native secret-value support. Smpl Config is designed for application configuration, not credential management. We recommend using a dedicated secrets manager for credentials and Smpl Config for non-sensitive configuration. Native first-class secret handling is on our long-term roadmap.
Smpl Config uses flat dot-notation keys with explicit parent inheritance, per-environment overrides, and type metadata. SDKs resolve the full inherited config and receive live updates over WebSocket.