Skip to content

Configuration

Configuration in nestrs is a typed struct, one file per concern, mapped explicitly to NESTRS_<NAMESPACE>__<KEY> environment variables. The crate is hand-rolled (no figment at runtime): you write from_env, the framework loads it once at boot, validates it, and registers Arc<MyConfig> into the container. Consumers inject it like any other provider.

The whole pipeline reads from environment variables and a small .env cascade the framework merges before any from_env runs. No TOML loader, no YAML, no remote source baked in — but a ConfigSource trait lets you ship one when you need Vault, K8s, or AWS Parameter Store.

Terminal window
cargo add nest-rs-config

The common case is three short blocks: a #[config] struct, a derived Validate, and a from_env that maps one line per variable.

src/posts/config.rs
use nest_rs_config::{config, Config, ConfigService};
use validator::Validate;
#[config(namespace = "posts")]
#[derive(Clone, Validate)]
pub struct PostsConfig {
#[validate(range(min = 1, max = 500))]
pub page_size: u32,
pub cache_enabled: bool,
}
impl Config for PostsConfig {
fn from_env(env: &ConfigService) -> nest_rs_config::Result<Self> {
Ok(Self {
page_size: env.parse("PAGE_SIZE")?.unwrap_or(50),
cache_enabled: env.flag("CACHE_ENABLED", false)?,
})
}
}

Three pieces do the heavy lifting:

  • #[config(namespace = "posts")] binds the struct to the NESTRS_POSTS__* scheme. The macro wires the Namespaced trait — no manual constant to write.
  • impl Config { fn from_env(env: &ConfigService) } is the hand-written contract: one line per variable. A reviewer reading this file knows exactly which env vars exist, with which defaults, and what happens when one is malformed.
  • #[derive(Validate)] plus #[validate(...)] attributes run after the load. A failure aborts boot naming the field. A rule the derive cannot express is the one case for a hand-written impl Validate — see the Advanced section at the end of this page.

env.parse("PAGE_SIZE")? reads + parses into the field’s type; unset returns None, set-but-unparseable returns Err naming the variable — boot-fatal, no silent fallback. env.flag accepts the usual boolean spellings.

Every field a module’s config exposes must be reachable two ways:

  1. The pinned struct, passed to Module::for_root(MyConfig { ... }) — programmatic, type-checked, comes from code.
  2. The NESTRS_<NS>__<KEY> environment variable, mapped explicitly in from_env.

That is the framework-wide rule for every nest-rs-* module. An env-only field can’t be set from a config file; a code-only field can’t be overridden in deployment. Both, always.

The reader handed to from_env is a thin wrapper around the namespace prefix. Five methods, all sync.

MethodReads
env.get("KEY")Option<String>NESTRS_<NS>__KEY as a raw string
env.parse::<T>("KEY")?Option<T>Same, parsed via FromStr
env.flag("KEY", default)?bool1/true/yes/on and their negatives (case-insensitive)
env.list("KEY")Vec<String>Comma-separated, trimmed, empties dropped
env.var_name("KEY")StringThe full env-var name (for error messages)

Empty strings count as unset, so FOO= in a .env file does not blank an in-code default.

The wiring is two module imports. The feature owns its config; the root module imports ConfigModule::for_root() once.

src/posts/module.rs
use nest_rs_config::ConfigModule;
use super::config::PostsConfig;
use super::service::PostsService;
#[module(
imports = [ConfigModule::for_feature::<PostsConfig>()],
providers = [PostsService],
)]
pub struct PostsModule;
src/module.rs
use nest_rs_config::ConfigModule;
#[module(
imports = [
ConfigModule::for_root(),
PostsHttpModule,
],
)]
pub struct AppModule;
  • ConfigModule::for_root() goes first in the root module. It merges the .env cascade once (real env vars always win) and registers Arc<Environment> so every later Config::load sees the merged environment.
  • ConfigModule::for_feature::<C>() queues a factory in the boot’s factory phase. The factory calls C::load() (= from_env + validate); on failure the boot aborts with the variable named.

A consumer injects Arc<PostsConfig> like any provider — the type is the token.

src/posts/service.rs
#[injectable]
pub struct PostsService {
#[inject]
config: Arc<PostsConfig>,
}
impl PostsService {
pub async fn list(&self) -> Result<Vec<Post>, ServiceError> {
let page = self.config.page_size;
// ...
}
}

A bad value fails the boot before any port opens:

Terminal window
$ NESTRS_POSTS__PAGE_SIZE=lots nestrs run dev api
Error: invalid value for NESTRS_POSTS__PAGE_SIZE: invalid digit found in string

A violated #[validate] rule aborts the same way after the load, naming the field.

Auto-deserializing from env vars sounds convenient, but the contract becomes implicit (which vars exist? what are their types?). With from_env written explicitly:

  • The full env contract of a feature is one impl block, in one file.
  • A missing variable falls back to a code default, not silently to Default::default().
  • Unparseable values fail loudly, naming the variable.
  • A reviewer reads the contract by reading ten lines, not by chasing serde attributes.

Hand-writing from_env (for custom validation, or a field carrying a structured payload, or a rule spanning the whole struct) is covered on Advanced configuration with the real IssuerConfig as the worked example.

  • crates/features/src/oauth/config.rs — the full real example.
  • crates/nest-rs-config/#[config], Config, ConfigService, ConfigModule, the .env cascade.