Skip to content

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:

crates/features/src/oauth/config.rs (from the demo, abridged)
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 })
}
}
  • base supplies the fallback, even for the structured field. An unset NESTRS_ISSUER__CLIENTS keeps base.clients rather than resetting to an empty list, so a deployment can override the org id without also wiping the clients a for_root pinned in code.
  • clients is a JSON array in a single variable. env.get("CLIENTS") returns the raw string and serde_json::from_str does the structured parse — mapping the failure through ConfigError::parse(env.var_name("CLIENTS"), …) so the boot error still names NESTRS_ISSUER__CLIENTS. The element type is the framework’s RegisteredClient<P> (from nest-rs-authn); the app fills the generic payload — here Uuid, 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 Validate is written by hand, building the same ValidationErrors the 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:

crates/features/src/oauth/module.rs (from the demo)
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;
apps/auth/src/module.rs (from the demo)
use nest_rs::config::ConfigModule;
use nest_rs::core::module;
#[module(
imports = [
ConfigModule::for_root(),
OAuthHttpModule,
],
)]
pub struct AuthModule;
crates/features/src/oauth/service.rs (from the demo)
#[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)?;
// ...
}
}
Terminal window
$ NESTRS_ISSUER__CLIENTS='[{"client_id":"web","client_secret":"...","payload":"00000000-0000-0000-0000-000000000000","scopes":["read"]}]' \
nestrs run dev auth
2026-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:

Terminal window
$ NESTRS_ISSUER__DEFAULT_ORG_ID=not-a-uuid nestrs run dev auth
Error: invalid value for NESTRS_ISSUER__DEFAULT_ORG_ID: invalid character: found `n` at 0

A failed validation runs after the load:

Terminal window
$ nestrs run dev auth # no clients configured
Error: configuration validation failed for 'issuer'
- clients: at_least_one_client

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:

apps/auth/src/config.rs
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.