Patterns & Style
What using this well looks like from Node, and the mistakes that read fine. The Rust book's Patterns & Style covers the ones about the engine; these are the ones about an event loop.
Read current() where you use it — never at boot
app.get("/", (request, response) => {
const { rateLimit } = config.current() // here
...
})
Not app.locals.config = config.current(), not a module-level
const CONFIG = config.current(). Both copy the document that was in
force at startup, and every later reload lands somewhere nobody reads.
current() is a property read on a cached object — cheaper than the
closure you would write to avoid it.
Per framework, the same rule wearing three hats: an Express handler reads it, a Fastify decorator holds the object, a Nest provider injects the object. Web Frameworks has each.
One configuration per subsystem
const db = new DynamicConfig({ key: "db", validate: zodValidator(Database) })
const flags = new DynamicConfig({ key: "flags" }) // no schema: product keys
Three sections in one file are three objects here, and they fail independently: a flags section somebody broke leaves the database's document serving.
await init() in main, not at module scope
async function main() {
await config.file("config.toml").env("APP_").init()
app.listen(3000)
}
void main()
Every load is asynchronous — why —
so a module that wants configuration at import time wants top-level
await (ESM) or a factory that returns a promise. A Nest useFactory and
a Next.js server component are both already async, which is why those
examples read as they do.
changes() for work, onReload for a note
for await (const document of config.changes()) {
await pool.resize(document.pool.maxSize) // an await is fine here
}
config.onReload((document) => log.info({ generation: config.generation }))
A hook runs when the document installs, and anything slow in it holds the
next reload. changes() yields on the loop after the install, so an
await in the loop costs the caller and nobody else. Break out of the
loop and the subscription is removed for you.
onReloadAsync is the third shape, and the one to reach for when the work
belongs next to the thing it rebuilds rather than in the service's own
loop: it awaits the hook, keeps only the newest install by default, and
reports a rejection instead of leaving it unhandled.
One lifecycle for the configurations that share one
const group = new ConfigGroup(db, cache, flags)
await group.running(async () => {
await serve()
})
Five configurations mean five init() calls, five watchers and five
handles to stop in the right order — none of which is application logic.
When a deployment moves them together, group.reloadAtomic() is the call
that refuses to leave two of them new and one of them old.
The group is lifecycle only: db.current() is still the read path,
because nothing should sit between a program and its values.
One rule bounds what atomic means: the writes are all-or-nothing, the
reads are not — two current() calls are two reads, and a reader
landing between the group's installs can see new db beside old
cache for an instant. Two values that must always be read together
belong in one configuration, where a single snapshot carries both.
Validate with what the program already has
Zod if you use Zod, Ajv if you use JSON Schema, a function if you use
neither, nothing at all if the keys are a product decision. The generic
parameter follows whatever the validator returns, so current() is your
type with nothing cast. Schemas.
Testing
const answer = await config.overrides({ rateLimit: 1 }, async () => {
return await somethingThatReads()
})
No filesystem, no environment, and it restores what it found. Two more
doors: load() resolves and validates a candidate without installing it,
and replace(document) hands the configuration over directly — for the
test that does not want a file at all, and for configuration that came
from somewhere this library does not know about.
Health, and what to do about a failure
app.get("/healthz", (_request, response) => {
const status = config.status()
response.status(status.consecutiveFailures === 0 ? 200 : 503).json(status)
})
A failed reload is recorded, and recorded means somebody has to look.
Two numbers matter: consecutiveFailures, and how old the serving
document is (snapshot().loadedAtAgoMs). A store that is briefly
unreachable should not be a startup gate — that is what the last-known-good
cache is for.
TypeScript
- Give the generic parameter:
new DynamicConfig<Database>({...}), or let a validator that returnsDatabaseinfer it.unknownat the call site means the parameter was lost somewhere. tryCurrent()at a boundary where the configuration may not be installed yet: it isT | undefined, which is whatstrictwants.get<V>(path, fallback)names the type it expects, so a schemaless read is notanyby accident.
Shutting down
config.stopWatching()
Not required for a process to exit — nothing here keeps the event loop alive — but it is what a test wants between cases, and what a long-lived worker wants when it hands its configuration back.