Advanced: hand-written from_env
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_authn::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, Default)]pub struct IssuerConfig { // The org id each client acts as is the framework client's generic payload. 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 Config for IssuerConfig { fn from_env(env: &ConfigService) -> 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 => Vec::new(), }; let default_org_id = env.parse("DEFAULT_ORG_ID")?.unwrap_or(DEFAULT_ORG); Ok(Self { clients, default_org_id }) }}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 super::config::IssuerConfig;use super::service::OAuthFlow;
#[module( imports = [ConfigModule::for_feature::<IssuerConfig>(), UsersModule], providers = [OAuthFlow],)]pub struct OAuthModule;use nest_rs_config::ConfigModule;
#[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::http: bound 5 routes on 0.0.0.0:3000A bad value fails the boot before any port opens:
$ NESTRS_ISSUER__DEFAULT_ORG_ID=not-a-uuid nestrs run dev authError: configuration parse error variable: NESTRS_ISSUER__DEFAULT_ORG_ID reason : invalid character at 1: expected hyphenA failed validation runs after the load:
$ nestrs run dev auth # no clients configuredError: configuration validation failed for 'issuer' - clients: at_least_one_clientGoing further
Section titled “Going further”- Configuration — the typed
#[config]derive and the dual-path rule. - Testing configuration —
MapSource/ConfigService::with_vars.