Alternative sources
Plug Vault, a K8s ConfigMap, or AWS Parameter Store behind ConfigSource — the trait ConfigService reads through.
ConfigService reads its raw values through a small trait:
ConfigSource. The default EnvSource returns process env vars after
the .env cascade has merged. Swap it for anything that can answer
“what’s the value of NESTRS_SEAORM__URL?” and the same Config
struct loads from there — HashiCorp Vault, a K8s ConfigMap, AWS Parameter
Store, an in-process map for tests.
The trait
Section titled “The trait”pub trait ConfigSource: Send + Sync + 'static { fn get(&self, var: &str) -> Option<String>;}One method. var is the fully-qualified name (e.g.
"NESTRS_SEAORM__URL") — namespacing happens in ConfigService, the
source just answers lookups. Empty strings count as unset, same rule as
EnvSource.
The trait is sync on purpose: Config::from_env runs synchronously
at boot. A remote source pre-fetches its keys into an in-memory map
during a factory phase, then serves get from that map.
A custom source — by example
Section titled “A custom source — by example”A Vault source that hits the API once at startup and caches the result:
use std::collections::HashMap;use std::sync::Arc;use nest_rs::config::{ConfigService, ConfigSource};
pub struct VaultSource { snapshot: HashMap<String, String>,}
impl VaultSource { pub async fn fetch(client: &VaultClient, path: &str) -> anyhow::Result<Self> { let response = client.read(path).await?; let snapshot = response .data .into_iter() .map(|(k, v)| (format!("NESTRS_{}", k.to_ascii_uppercase()), v)) .collect(); Ok(Self { snapshot }) }}
impl ConfigSource for VaultSource { fn get(&self, var: &str) -> Option<String> { self.snapshot.get(var).cloned() }}get is the only required method. The trait also carries
get_from_deployment, which defaults to get — that is the tier allowed to
outrank a value pinned in code, and a
custom source counts as deployment-supplied unless it says otherwise. That
default is the safe direction: a Vault secret is never shadowed by a struct
literal. Override it only if your source also serves values that should lose
to a pin.
Wire it in by building the ConfigService directly and calling
Config::from_env yourself — bypassing ConfigModule::for_feature:
use nest_rs::config::ConfigService;
let vault = Arc::new( VaultSource::fetch(&vault_client, "secret/api").await?,);let env = ConfigService::with_source("seaorm", vault);let db_config = SeaOrmConfig::from_env(&env, SeaOrmConfig::defaults())?;
App::builder() .module::<AppModule>() .provide(db_config) .build() .await?;A seeded config wins over ConfigModule::for_feature::<SeaOrmConfig>(),
so the rest of the app sees the Vault-loaded value without changing any
downstream code. The pattern matches Repo::scoped:
build the seam where the source lives, hand the result to the framework.
When EnvSource is fine
Section titled “When EnvSource is fine”The default source is the right answer for most apps:
- Local dev reads from the
.envcascade. - Production reads from real env vars set by the orchestrator.
- A secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) typically already injects env vars into the running container.
Reach for a custom ConfigSource only when:
- The secrets manager cannot inject env vars (a sidecar pattern that serves values over HTTP).
- A K8s ConfigMap mounts as a file tree rather than env vars.
- A test needs a programmatic source decoupled from
std::env.
What fails if you get it wrong — hermeticity and the cascade
Section titled “What fails if you get it wrong — hermeticity and the cascade”ConfigService::for_namespace(ns) is shorthand for with_source(ns, Arc::new(EnvSource)). Reading through it mutates nothing. EnvSource
resolves the real process environment first, then falls back to the .env
cascade parsed into a map inside the crate — std::env::set_var is unsound
against a concurrent getenv on another thread, so no read path may write.
One function publishes the cascade into the process environment:
Environment::init(), which every scaffolded main calls on its first line.
It exists for the consumers that only know std::env::var and never see a
ConfigService — the framework’s own NESTRS_LOG* setup,
OpenTelemetry::init, your migrate/seed binaries. It writes set-if-absent
(the real env still wins) and belongs at the top of main precisely because
being single-threaded there is what makes the write sound.
What this means in practice:
- Resolving config is side-effect-free, whichever source you use. Neither
for_namespacenorConfigModule::for_root()publishes anything;for_root’s collect registers the activeEnvironmentand stops there. TestApp::builder()does publish the cascade. The e2e harness reads backend URLs throughstd::envbefore anyConfigServiceexists, so it callsload_cascade(the explicit form of whatEnvironment::initdoes) once per process. The merge is bounded — set-if-absent, real env still wins, and.env.localis skipped underNESTRS_ENV=test— and afor_rootpin still outranks every cascade value. Reach forwith_source(or.provide(cfg)) when a test must not see a committed.envat all: it decides where the values come from, which is the stronger guarantee.- In an app, assume the cascade is in
std::env, becausemainput it there. That is the point: a.envvalue reaches code the DI graph never sees.
What the source sees
Section titled “What the source sees”var is always the fully-qualified NESTRS_<NS>__<KEY> string. A few
implications:
- The source receives the uppercased namespace + key — the casing
conversion happens in
ConfigService::varbefore the lookup. - The source has no idea which
Configstruct is being loaded. It’s a flat key-value store. If you need per-namespace routing (e.g.databasefrom Vault,httpfrom env), wrap a couple of sources in a dispatching one. - Empty strings are unset. A Vault entry with an empty value will
not blank an in-code default — return
Noneinstead.
A dispatching source for two backends:
pub struct Dispatch { vault: VaultSource, env: EnvSource,}
impl ConfigSource for Dispatch { fn get(&self, var: &str) -> Option<String> { if var.starts_with("NESTRS_SEAORM__") || var.starts_with("NESTRS_ISSUER__") { self.vault.get(var) } else { self.env.get(var) } }}Going further
Section titled “Going further”- Configuration overview — the typed-struct contract.
- The .env cascade — load order, hermeticity, file naming.
- Overriding in tests — the
figment::Jail-isolated path for unit tests,.provide()for e2e.