Advanced: hand-written from_env
When the #[config] derive is not enough — a hand-written from_env with custom validation and nested/dynamic config.
The derive covers per-field rules and FromStr covers scalar parsing.
Two things push a config past them: a field carrying a structured
payload (a JSON blob in one variable) and a rule spanning the whole
struct. This is the real IssuerConfig from features/oauth (signs
the OAuth clients accepted by apps/auth) — it needs both:
use nest_rs::oauth::server::RegisteredClient;use nest_rs::config::{config, Config, ConfigError, ConfigService};use uuid::Uuid;use validator::{Validate, ValidationError, ValidationErrors};
const DEFAULT_ORG: Uuid = Uuid::from_u128(0x0000_0000_0000_7000_8000_0000_0000_ac3e);
#[config(namespace = "issuer")]#[derive(Clone, Debug, Default)]pub struct IssuerConfig { pub clients: Vec<RegisteredClient<Uuid>>, pub default_org_id: Uuid,}
impl Validate for IssuerConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); if self.clients.is_empty() { errors.add("clients", ValidationError::new("at_least_one_client")); } if errors.is_empty() { Ok(()) } else { Err(errors) } }}
impl Default for IssuerConfig { fn default() -> Self { Self { clients: Vec::new(), default_org_id: DEFAULT_ORG } }}
impl Config for IssuerConfig { fn from_env(env: &ConfigService, base: Self) -> nest_rs::config::Result<Self> { let clients = match env.get("CLIENTS") { Some(raw) => serde_json::from_str(&raw) .map_err(|e| ConfigError::parse(env.var_name("CLIENTS"), e.to_string()))?, None => base.clients, }; let default_org_id = env.parse("DEFAULT_ORG_ID")?.unwrap_or(base.default_org_id); Ok(Self { clients, default_org_id }) }}basesupplies the fallback, even for the structured field. An unsetNESTRS_ISSUER__CLIENTSkeepsbase.clientsrather than resetting to an empty list, so a deployment can override the org id without also wiping the clients afor_rootpinned in code.clientsis a JSON array in a single variable.env.get("CLIENTS")returns the raw string andserde_json::from_strdoes the structured parse — mapping the failure throughConfigError::parse(env.var_name("CLIENTS"), …)so the boot error still namesNESTRS_ISSUER__CLIENTS. The element type is the framework’sRegisteredClient<P>(fromnest-rs-authn); the app fills the genericpayload— hereUuid, the org each client acts as — so the framework owns the credential shape and the app owns only what it authorizes on.- “At least one client” is a struct-level rule, so
impl Validateis written by hand, building the sameValidationErrorsthe derive would. It runs after the load and aborts boot exactly like a derived rule. Per-field rules stay on the derive, as in the lead example.
Wiring and consumption do not change — the same for_feature import,
the same Arc<IssuerConfig> injection:
use nest_rs::config::ConfigModule;use nest_rs::core::module;use super::config::IssuerConfig;use super::service::OAuthFlow;
#[module( imports = [ConfigModule::for_feature::<IssuerConfig>(), UsersModule], providers = [OAuthFlow],)]pub struct OAuthModule;use nest_rs::config::ConfigModule;use nest_rs::core::module;
#[module( imports = [ ConfigModule::for_root(), OAuthHttpModule, ],)]pub struct AuthModule;#[injectable]pub struct OAuthFlow { #[inject] config: Arc<IssuerConfig>,}
impl OAuthFlow { pub fn issue(&self, client_id: &str) -> Result<AccessTokenDto, AuthError> { let client = self.config.clients .iter() .find(|c| c.client_id == client_id) .ok_or(AuthError::UnknownClient)?; // ... }}Run it
Section titled “Run it”$ NESTRS_ISSUER__CLIENTS='[{"client_id":"web","client_secret":"...","payload":"00000000-0000-0000-0000-000000000000","scopes":["read"]}]' \ nestrs run dev auth2026-06-03T10:42:11Z INFO nest_rs::config: loaded issuer (2 fields)2026-06-03T10:42:11Z INFO nest_rs::routes: mounted route controller="TokenController" method="POST" path="/oauth/token" handler="token"A bad value fails the boot before any port opens:
$ NESTRS_ISSUER__DEFAULT_ORG_ID=not-a-uuid nestrs run dev authError: invalid value for NESTRS_ISSUER__DEFAULT_ORG_ID: invalid character: found `n` at 0A failed validation runs after the load:
$ nestrs run dev auth # no clients configuredError: configuration validation failed for 'issuer' - clients: at_least_one_clientA profile-dependent baseline
Section titled “A profile-dependent baseline”from_env overlays the environment onto a base, and that base is
Config::defaults() when nothing was pinned at the import site. Override it
when a field’s safe value differs by profile — the framework does this for
three of its own:
use nest_rs::config::Environment;
/// Two lines, written per crate: the framework's own copies are private, so/// there is nothing to import.fn dev_profile() -> bool { !matches!(Environment::from_env(), Environment::Production | Environment::Staging)}
impl Config for StorageConfig { fn defaults() -> Self { let d = Self::default(); if dev_profile() { return d; } // Outside dev/test: no sentinel credentials, no plain HTTP. Self { access_key: String::new(), secret_key: String::new(), allow_http: false, ..d } }
fn from_env(env: &ConfigService, base: Self) -> Result<Self> { /* … */ }}It belongs here rather than inside from_env: from_env also runs over a
value pinned in code, and a profile rule there would silently rewrite a
deliberate choice. Default stays the plain, pin-friendly struct value.
Going further
Section titled “Going further”- Configuration — the typed
#[config]derive and the dual-path rule. - Testing configuration —
MapSource/ConfigService::with_vars.