use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default = "default_log_level")]
pub log_level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig { host: default_host(), port: default_port() }
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
DatabaseConfig { url: String::new(), max_connections: default_max_connections() }
}
}
fn default_host() -> String { "127.0.0.1".to_string() }
fn default_port() -> u16 { 8080 }
fn default_log_level() -> String { "info".to_string() }
fn default_max_connections() -> u32 { 10 }
use crate::config::AppConfig;
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use std::path::Path;
impl AppConfig {
pub fn load(file_path: &str) -> Result<AppConfig, figment::Error> {
let mut figment = Figment::from(Serialized::defaults(AppConfig::default_seed()));
if Path::new(file_path).exists() {
figment = figment.merge(Toml::file(file_path));
}
// Env overrides everything; APP_SERVER__PORT -> server.port
figment = figment.merge(Env::prefixed("APP_").split("__"));
let cfg: AppConfig = figment.extract()?;
cfg.validate().map_err(figment::Error::from)?;
Ok(cfg)
}
fn default_seed() -> AppConfig {
AppConfig {
server: Default::default(),
database: Default::default(),
log_level: "info".to_string(),
}
}
fn validate(&self) -> Result<(), String> {
if self.database.url.trim().is_empty() {
return Err("database.url must not be empty".to_string());
}
if self.database.max_connections == 0 {
return Err("database.max_connections must be greater than 0".to_string());
}
if self.server.port == 0 {
return Err("server.port must be greater than 0".to_string());
}
Ok(())
}
}
mod config;
mod loader;
use config::AppConfig;
use std::process;
fn main() {
if let Err(e) = run() {
eprintln!("configuration error: {}", e);
process::exit(1);
}
}
fn run() -> Result<(), figment::Error> {
let path = std::env::var("APP_CONFIG_FILE")
.unwrap_or_else(|_| "config.toml".to_string());
let cfg = AppConfig::load(&path)?;
println!("log level: {}", cfg.log_level);
println!("listening on {}:{}", cfg.server.host, cfg.server.port);
println!(
"database: {} (max {} connections)",
cfg.database.url, cfg.database.max_connections
);
Ok(())
}
This snippet shows the twelve-factor pattern of building application configuration from multiple overlapping layers: hard-coded defaults, an optional TOML file, and process environment variables, with later layers overriding earlier ones. The goal is a single strongly-typed struct that the rest of the program reads, without scattering env::var calls or file parsing across the codebase.
In config.rs, the shape of the configuration is a plain serde Deserialize struct. AppConfig nests ServerConfig and DatabaseConfig so related keys stay grouped, which maps cleanly onto TOML tables and onto prefixed environment keys like APP_SERVER__PORT. The #[serde(default = ...)] attributes attach per-field default functions such as default_port, so a partial file or a missing variable still deserializes into a valid value rather than an error. This is the crucial trade-off of layered config: defaults live in code so the app boots even with no file and no env at all.
The layering itself lives in loader.rs and uses the figment crate. AppConfig::load composes a Figment by merging providers in priority order: Serialized::defaults seeds the baseline, Toml::file adds file values if the path exists, and Env::prefixed("APP_") pulls in environment variables last so they win. The split("__") call teaches figment to map a double-underscore into nested table access, which is how APP_SERVER__PORT reaches server.port. Calling .extract() turns the merged figment into the typed AppConfig, and any type mismatch surfaces as a figment::Error instead of a runtime panic later.
After extraction, validate enforces invariants that types alone cannot express, such as rejecting a zero max_connections or an empty database URL. Doing validation once at startup means the rest of the program can trust the config and fail fast with a clear message.
In main.rs, AppConfig::load is called from a fallible main that returns a Result, so a bad config prints a readable error and exits nonzero. Because everything funnels through one typed struct, callers read cfg.server.port directly. This approach shines for services that must run identically across local development and containerized environments, where files supply defaults and env vars override per deployment.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
package config
import (
"errors"
"os"
"strconv"
Config parsing with env defaults and strict validation
Share this code
Here's the card — post it anywhere.