← All posts

Configuration should be live by default

A configuration client has two jobs: get the current value of a key, and keep it current as the configuration changes. Most clients do the first well and the second poorly — they fetch at startup and never update, or they poll on a timer, leaving you to choose between restarting the service and being minutes out of date.

The smplkit Config SDK assumes the opposite: configuration should be as live as possible. resolve() returns a snapshot; subscribe() returns a proxy that always reflects the current state.

The two modes

The SDK’s primary interface is client.config.resolve():

# Returns a dict with all keys resolved (inherited keys included)
config = client.config.resolve("payments-service", environment="production")
timeout = config["timeout.api"]

And subscribe(), which returns a live proxy:

# Returns a live proxy — reads always reflect current state
config = client.config.subscribe("payments-service", environment="production")

# This reads the current value, not a cached snapshot
timeout = config["timeout.api"]

resolve() fetches once and hands you an immutable dict. subscribe() establishes a WebSocket subscription and hands you an object where every read returns the current value.

Choosing between them is not a matter of taste. The question to ask: if this value changed mid-run, would that be a feature or a bug? A timeout an operator might need to lower during an incident — subscribe(); that’s the entire point of the product. A connection-pool size you read once at startup and then build a pool on top of — resolve(), because a value that shifts underneath an invariant you’ve already constructed isn’t “live,” it’s a race condition.

Lazy initialization

Like the Flags SDK, the Config SDK doesn’t touch the network until the first resolve() or subscribe(), which establishes the WebSocket connection and fetches the current state of all configs for the environment. Same rationale: deferred connectivity behaves better in application startup sequences, and unit tests that never read config never trigger a network call.

Typed models

resolve() accepts a Pydantic model class, for teams that want validation instead of a dict:

class PaymentsConfig(BaseModel):
    database_host: str
    database_port: int
    timeout_api: int
    stripe_webhook_secret: str

config = client.config.resolve(
    "payments-service",
    environment="production",
    model=PaymentsConfig
)

# config is now a PaymentsConfig instance with full type annotations
timeout = config.timeout_api  # int, not str

The SDK fetches the raw dict, maps dot-notation keys to Pydantic’s field naming convention (dots become underscores), and constructs the model. Pydantic validates the types and coerces where appropriate — strings to integers and so on.

The payoff is at startup: a missing key or a value of the wrong type fails model construction right there, with a clear error — instead of surfacing later as a KeyError or a runtime type error somewhere deep in application logic.

Reacting to changes: @on_change

Reading the latest value isn’t always enough; some changes require doing something. For those, the @on_change decorator:

@client.config.on_change("payments-service", environment="production")
def config_changed(new_config: dict, old_config: dict) -> None:
    connection_pool.resize(int(new_config["database.pool_size"]))

The decorated function is called whenever the config changes, with both the new and old resolved configs — for the changes that need an active response: resizing a connection pool, invalidating a cache, reconnecting to a service with new credentials.

One operational warning: the handler runs on the SDK’s background thread. It must be thread-safe and it must not block — long-running work belongs on a thread pool the handler dispatches to.

What “resolved” means

resolve() returns the fully resolved config: every inherited key included, every environment override applied. The returned dict contains what the caller would expect regardless of whether a key was defined directly on the named config or inherited from a parent — because application code needs the effective configuration, not the partial one.

The SDK caches the resolved result, and invalidates the cache when a change notification arrives for any config in the resolution chain (the named config or any of its ancestors). A change to a parent common-config invalidates the cache in every SDK subscribed to a child config that inherits from it, which is exactly what inheritance is supposed to mean.

Context scope override

For resolving a config against a different context than the default, mostly testing:

with client.set_context([Context("environment", "staging")]):
    staging_config = client.config.resolve("payments-service")
# Back to default context outside the block

Inside the with block, all SDK operations use the overridden context; outside, the default is restored. The main use is verifying that the staging config resolves correctly from a test, without the test infrastructure needing a real staging environment.

What we’d revisit

An async proxy. The subscribe() proxy is synchronous — reading a key blocks until the latest value is available. In a heavily async application that’s unwelcome; an async-native proxy with awaited reads would fit better in async Python or TypeScript.

A diff on change. @on_change hands you new and old configs and leaves the comparison to you. A built-in diff helper would matter most exactly where the handler pattern is most useful: configs with many keys where only one has changed.

History. “What was timeout.api three hours ago?” is an incident-investigation question, and the SDK can’t answer it — it only knows the current value. Historical values require querying the audit service, which stores config snapshots per change.