Skip to content
Code Card

Immutable Configuration Snapshot and Layer Priority

A Configuration Snapshot is a pattern that consolidates multiple configuration sources (defaults, files, environment variables, and command-line arguments) into a single runtime configuration at startup, performing type conversion and validation once before freezing the result.

Practice memo · 37 min read · Medium

A Configuration Snapshot is a pattern that consolidates multiple configuration sources (defaults, configuration files, environment variables, and command-line arguments) into a single fixed runtime configuration at startup, completing type conversion and validation in one pass. Application code then reads only the finished AppConfig rather than accessing process.env, Environment.GetEnvironmentVariable, or configuration file dictionaries directly. The idea of separating per-deployment configuration from code aligns with the Twelve-Factor App's config principle.1 The intent of this pattern is to separate configuration interpretation from execution logic. When environment variables are read throughout a service, the same setting can be interpreted with different defaults and parsing rules in different places. String handling for values like "8", "08", "true", and "TRUE" can vary across call sites. Misconfigured settings may go undetected until an actual feature executes, sometimes surfacing as production failures hours after deployment. Configuration layers need an explicit priority order. (This is the key point.) This example uses the following order. .NET's default configuration providers also read in file, environment variable, and command-line order, with later providers overriding earlier values.2 This is not a standard imposed on every program; this document adopts that order as the application's contract.

Text
Defaults < Config File < Environment < Command Line

Later layers override earlier values. However, overriding is performed only at the raw string stage, and type conversion and cross-field validation are performed exactly once after all layers are merged.

Parsing each layer separately can cause a problem where an invalid value in a lower layer prevents a valid override in a higher layer from ever being reached. Environment variables are ultimately strings.

Node.js also interprets 0, true, and JSON-shaped values from .env all as text.3 Therefore, the final configuration must not be a plain dictionary but a typed value with URL syntax, integer ranges, timeout units, and boolean semantics all resolved.

Unknown keys are also rejected at startup rather than silently ignored. It is better for a deployment to fail immediately than for a mistyped maxConcurreny to be silently replaced by a default. Integer syntax must also be locked down to a single rule. The canonical integer in this document is ASCII decimal without leading zeros, meaning 0 or [1-9][0-9]*. All four language examples use the same rule, so "08" does not silently pass as 8 in any of them. Core mnemonic:

Text
Configuration Snapshot
= Ordered Raw Layers
→ Canonical Key Merge
→ Field Parse and Validation
→ Optional Cross-Field Validation
→ Immutable AppConfig

Precedence
= Defaults < File < Environment < CLI

Runtime Rule
= Running code does not read raw configuration sources directly.

1. Problem scenario

Suppose you are building a batch service that synchronizes data from an external API. (This is a common type of service, and a situation frequently encountered in SI projects. Personally, I found this technique useful.) Four configuration values are required.

Text
apiBaseUrl
= HTTPS base URL for external API

maxConcurrency
= Number of requests to run concurrently, 1–64

requestTimeoutMs
= Timeout for individual requests, 100–60,000 ms

dryRun
= Whether to actually apply external changes or not

In a production environment, values are supplied as follows.

Text
Defaults:
maxConcurrency=4
requestTimeoutMs=5000
dryRun=true

Config file:
apiBaseUrl=https://api.example.com
maxConcurrency=8

Environment variable:
maxConcurrency=12

Command line:
dryRun=true

The final configuration should be as follows.

Text
apiBaseUrl=https://api.example.com
maxConcurrency=12
requestTimeoutMs=5000
dryRun=true

The problem addressed here is taking multiple configuration layers and producing a single validated, immutable runtime configuration.


2. Core implementation

The common input model is a configuration layer that has already been converted to canonical keys.

Text
ConfigLayer:
- sourceName
- values: key → raw string
Known Keys:
- apiBaseUrl
- maxConcurrency
- requestTimeoutMs
- dryRun

Mapping an environment variable like SYNC_MAX_CONCURRENCY to maxConcurrency is the responsibility of the source adapter. The core loader has no knowledge of environment variable names or file formats.

C#

C#
using System;
using System.Collections.Generic;
using System.Globalization;
public sealed record AppConfig(
    Uri ApiBaseUrl,
    int MaxConcurrency,
    TimeSpan RequestTimeout,
    bool DryRun);
public sealed record ConfigLayer(
    string SourceName,
    IReadOnlyDictionary<string, string> Values);
public sealed record ConfigError(
    string Code,
    string Key,
    string SourceName,
    string Message);
public abstract record ConfigLoadResult
{
    public sealed record Loaded(AppConfig Config) : ConfigLoadResult;
    public sealed record Rejected(ConfigError Error) : ConfigLoadResult;
}
public static class AppConfigLoader
{
    private static readonly HashSet<string> KnownKeys =
    [
        "apiBaseUrl",
        "maxConcurrency",
        "requestTimeoutMs",
        "dryRun",
    ];
    public static ConfigLoadResult Load(
        IReadOnlyList<ConfigLayer> layers)
    {
        ArgumentNullException.ThrowIfNull(layers);
        Dictionary<string, RawConfigValue> merged =
            new(StringComparer.Ordinal);
        foreach (ConfigLayer layer in layers)
        {
            ConfigError? error = MergeLayer(merged, layer);
            if (error is not null)
            {
                return new ConfigLoadResult.Rejected(error);
            }
        }
        return ParseSnapshot(merged);
    }
    private static ConfigError? MergeLayer(
        IDictionary<string, RawConfigValue> merged,
        ConfigLayer layer)
    {
        foreach ((string key, string value) in layer.Values)
        {
            if (!KnownKeys.Contains(key))
            {
                return CreateError(
                    "unknown_key",
                    key,
                    layer.SourceName,
                    "Unsupported configuration key.");
            }
            merged[key] = new RawConfigValue(
                value,
                layer.SourceName);
        }
        return null;
    }
    private static ConfigLoadResult ParseSnapshot(
        IReadOnlyDictionary<string, RawConfigValue> values)
    {
        ConfigFieldReader reader = new(values);
        ConfigReadResult<Uri> url =
            reader.ReadHttpsUri("apiBaseUrl");
        ConfigReadResult<int> concurrency =
            reader.ReadInteger("maxConcurrency", 1, 64);
        ConfigReadResult<int> timeout =
            reader.ReadInteger("requestTimeoutMs", 100, 60_000);
        ConfigReadResult<bool> dryRun =
            reader.ReadBoolean("dryRun");
        ConfigError? error = FirstError(
            url.Error,
            concurrency.Error,
            timeout.Error,
            dryRun.Error);
        if (error is not null)
        {
            return new ConfigLoadResult.Rejected(error);
        }
        AppConfig config = new(
            url.Value!,
            concurrency.Value,
            TimeSpan.FromMilliseconds(timeout.Value),
            dryRun.Value);
        return new ConfigLoadResult.Loaded(config);
    }
    private static ConfigError? FirstError(
        params ConfigError?[] errors)
    {
        foreach (ConfigError? error in errors)
        {
            if (error is not null)
            {
                return error;
            }
        }
        return null;
    }
    private static ConfigError CreateError(
        string code,
        string key,
        string sourceName,
        string message)
    {
        return new ConfigError(
            code,
            key,
            sourceName,
            message);
    }
    private sealed record RawConfigValue(
        string Value,
        string SourceName);
    private readonly record struct ConfigReadResult<T>(
        T Value,
        ConfigError? Error);
    private sealed class ConfigFieldReader
    {
        private readonly IReadOnlyDictionary<string, RawConfigValue> values;
        public ConfigFieldReader(
            IReadOnlyDictionary<string, RawConfigValue> values)
        {
            this.values = values;
        }
        public ConfigReadResult<Uri> ReadHttpsUri(string key)
        {
            if (!this.values.TryGetValue(key, out RawConfigValue? raw))
            {
                return this.Missing<Uri>(key);
            }
            bool valid = Uri.TryCreate(
                raw.Value,
                UriKind.Absolute,
                out Uri? uri)
                && uri.Scheme == Uri.UriSchemeHttps
                && !string.IsNullOrWhiteSpace(uri.Host)
                && string.IsNullOrEmpty(uri.UserInfo)
                && string.IsNullOrEmpty(uri.Query)
                && string.IsNullOrEmpty(uri.Fragment);
            return valid
                ? new ConfigReadResult<Uri>(uri!, null)
                : this.Invalid<Uri>(
                    key,
                    raw,
                    "An HTTPS URL without user info, query, or fragment is required.");
        }
        public ConfigReadResult<int> ReadInteger(
            string key,
            int minimum,
            int maximum)
        {
            if (!this.values.TryGetValue(key, out RawConfigValue? raw))
            {
                return this.Missing<int>(key);
            }
            if (!IsCanonicalDecimal(raw.Value))
            {
                return this.Invalid<int>(
                    key,
                    raw,
                    "An ASCII decimal integer without leading zeros is required.");
            }
            bool valid = int.TryParse(
                    raw.Value,
                    NumberStyles.None,
                    CultureInfo.InvariantCulture,
                    out int value)
                && value >= minimum
                && value <= maximum;
            return valid
                ? new ConfigReadResult<int>(value, null)
                : this.Invalid<int>(
                    key,
                    raw,
                    $"An integer between {minimum} and {maximum} (inclusive) is required.");
        }
        public ConfigReadResult<bool> ReadBoolean(string key)
        {
            if (!this.values.TryGetValue(key, out RawConfigValue? raw))
            {
                return this.Missing<bool>(key);
            }
            if (raw.Value == "true")
            {
                return new ConfigReadResult<bool>(true, null);
            }
            if (raw.Value == "false")
            {
                return new ConfigReadResult<bool>(false, null);
            }
            return this.Invalid<bool>(
                key,
                raw,
                "A value of true or false is required.");
        }
        private static bool IsCanonicalDecimal(string value)
        {
            if (value.Length == 0)
            {
                return false;
            }
            if (value == "0")
            {
                return true;
            }
            if (value[0] == '0')
            {
                return false;
            }
            foreach (char c in value)
            {
                if (c < '0' || c > '9')
                {
                    return false;
                }
            }
            return true;
        }
        private ConfigReadResult<T> Missing<T>(string key)
        {
            return new ConfigReadResult<T>(
                default!,
                CreateError(
                    "missing_key",
                    key,
                    "merged",
                    "Required configuration is missing."));
        }
        private ConfigReadResult<T> Invalid<T>(
            string key,
            RawConfigValue raw,
            string message)
        {
            return new ConfigReadResult<T>(
                default!,
                CreateError(
                    "invalid_value",
                    key,
                    raw.SourceName,
                    message));
        }
    }
}

In C#, a record is used to represent the final configuration as an immutable value, and the raw dictionary is used only inside the loader. Integer and boolean syntax are unified under a single rule across all four languages.

Integers accept only ASCII decimal without leading zeros (0 or [1-9][0-9]*), and booleans accept only lowercase true and false. Because int.TryParse accepts "08" as 8, the syntax is filtered with IsCanonicalDecimal before parsing. The call site receives a successful AppConfig and never reads environment variables again. In real .NET applications, the same startup validation boundary can also be established using the Options pattern with ValidateOnStart().4

TypeScript

TypeScript
type ConfigKey =
  | "apiBaseUrl"
  | "maxConcurrency"
  | "requestTimeoutMs"
  | "dryRun";
type ConfigLayer = Readonly<{
  sourceName: string;
  values: Readonly<Record<string, string>>;
}>;
declare const httpsUrlBrand: unique symbol;
type HttpsUrl = string & Readonly<{
  [httpsUrlBrand]: true;
}>;
type AppConfig = Readonly<{
  apiBaseUrl: HttpsUrl;
  maxConcurrency: number;
  requestTimeoutMs: number;
  dryRun: boolean;
}>;
type ConfigError = Readonly<{
  code: "unknownKey" | "missingKey" | "invalidValue";
  key: string;
  sourceName: string;
  message: string;
}>;
type ConfigLoadResult =
  | Readonly<{
      kind: "loaded";
      config: AppConfig;
    }>
  | Readonly<{
      kind: "rejected";
      error: ConfigError;
    }>;
type RawConfigValue = Readonly<{
  value: string;
  sourceName: string;
}>;
const knownKeys: ReadonlySet<string> = new Set<ConfigKey>([
  "apiBaseUrl",
  "maxConcurrency",
  "requestTimeoutMs",
  "dryRun",
]);
export function loadAppConfig(
  layers: readonly ConfigLayer[],
): ConfigLoadResult
{
  const merged = new Map<string, RawConfigValue>();
  for (const layer of layers) {
    const error = mergeLayer(merged, layer);
    if (error !== undefined) {
      return {
        kind: "rejected",
        error,
      };
    }
  }
  return parseSnapshot(merged);
}
function mergeLayer(
  merged: Map<string, RawConfigValue>,
  layer: ConfigLayer,
): ConfigError | undefined
{
  for (const [key, value] of Object.entries(layer.values)) {
    if (!knownKeys.has(key)) {
      return {
        code: "unknownKey",
        key,
        sourceName: layer.sourceName,
        message: "Unsupported configuration key.",
      };
    }
    merged.set(key, {
      value,
      sourceName: layer.sourceName,
    });
  }
  return undefined;
}
function parseSnapshot(
  values: ReadonlyMap<string, RawConfigValue>,
): ConfigLoadResult
{
  const apiBaseUrl = readHttpsUrl(values, "apiBaseUrl");
  const maxConcurrency = readInteger(
    values,
    "maxConcurrency",
    1,
    64,
  );
  const requestTimeoutMs = readInteger(
    values,
    "requestTimeoutMs",
    100,
    60_000,
  );
  const dryRun = readBoolean(values, "dryRun");
  if (apiBaseUrl.kind === "error") {
    return {
      kind: "rejected",
      error: apiBaseUrl.error,
    };
  }
  if (maxConcurrency.kind === "error") {
    return {
      kind: "rejected",
      error: maxConcurrency.error,
    };
  }
  if (requestTimeoutMs.kind === "error") {
    return {
      kind: "rejected",
      error: requestTimeoutMs.error,
    };
  }
  if (dryRun.kind === "error") {
    return {
      kind: "rejected",
      error: dryRun.error,
    };
  }
  return {
    kind: "loaded",
    config: Object.freeze({
      apiBaseUrl: apiBaseUrl.value,
      maxConcurrency: maxConcurrency.value,
      requestTimeoutMs: requestTimeoutMs.value,
      dryRun: dryRun.value,
    }),
  };
}
type ReadResult<TValue> =
  | Readonly<{
      kind: "value";
      value: TValue;
    }>
  | Readonly<{
      kind: "error";
      error: ConfigError;
    }>;
function readHttpsUrl(
  values: ReadonlyMap<string, RawConfigValue>,
  key: ConfigKey,
): ReadResult<HttpsUrl>
{
  const raw = values.get(key);
  if (raw === undefined) {
    return missingKey(key);
  }
  try {
    const url = new URL(raw.value);
    if (url.protocol !== "https:"
      || url.hostname === ""
      || url.username !== ""
      || url.password !== ""
      || url.search !== ""
      || url.hash !== "") {
      return invalidValue(
        key,
        raw.sourceName,
        "An HTTPS URL with no userinfo, query, or fragment is required.",
      );
    }
    return {
      kind: "value",
      // URL objects are mutable, so only the normalized string is stored in the snapshot.
      value: url.href as HttpsUrl,
    };
  } catch {
    return invalidValue(
      key,
      raw.sourceName,
      "A valid URL is required.",
    );
  }
}
function readInteger(
  values: ReadonlyMap<string, RawConfigValue>,
  key: ConfigKey,
  minimum: number,
  maximum: number,
): ReadResult<number>
{
  const raw = values.get(key);
  if (raw === undefined) {
    return missingKey(key);
  }
  if (!/^(0|[1-9][0-9]*)$/.test(raw.value)) {
    return invalidValue(
      key,
      raw.sourceName,
      "An ASCII decimal integer with no leading zeros is required.",
    );
  }
  const value = Number(raw.value);
  if (!Number.isSafeInteger(value)
    || value < minimum
    || value > maximum) {
    return invalidValue(
      key,
      raw.sourceName,
      `An integer between ${minimum} and ${maximum} (inclusive) is required.`,
    );
  }
  return {
    kind: "value",
    value,
  };
}
function readBoolean(
  values: ReadonlyMap<string, RawConfigValue>,
  key: ConfigKey,
): ReadResult<boolean>
{
  const raw = values.get(key);
  if (raw === undefined) {
    return missingKey(key);
  }
  if (raw.value === "true" || raw.value === "false") {
    return {
      kind: "value",
      value: raw.value === "true",
    };
  }
  return invalidValue(
    key,
    raw.sourceName,
    "`true` or `false` is required.",
  );
}
function missingKey(key: ConfigKey): ReadResult<never>
{
  return {
    kind: "error",
    error: {
      code: "missingKey",
      key,
      sourceName: "merged",
      message: "Required configuration is missing.",
    },
  };
}
function invalidValue(
  key: ConfigKey,
  sourceName: string,
  message: string,
): ReadResult<never>
{
  return {
    kind: "error",
    error: {
      code: "invalidValue",
      key,
      sourceName,
      message,
    },
  };
}

new URL() is a parsing boundary, so exceptions are converted to results only inside that helper. Try/catch blocks for configuration parsing do not proliferate into the application's general execution logic. The integer regex ^(0|[1-9][0-9]*)$ is the canonical rule for this document, and the other three languages are aligned to match it.

Personal note: Converting all the languages for real is exhausting. I'm confident with C# and TypeScript, but writing Rust and Python on the basis of C# is brutal. The Rust developer experience seems terrible.

Here, a validated and normalized branded string is stored instead of a URL object. TypeScript's readonly only prevents reassignment during type checking; it does not recursively make the runtime object immutable.5 Object.freeze() is also applied shallowly, and objects referenced by a frozen object can still be mutated.6 In fact, even after Object.freeze(new URL(...)), the pathname setter can change the URL's internal state. If a mutable URL is embedded directly in a configuration object and it is called an "immutable snapshot," the immutability is in name only.

Python

Python
from __future__ import annotations
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import Literal, Mapping, TypeAlias
from urllib.parse import urlsplit
ConfigKey: TypeAlias = Literal[
    "apiBaseUrl",
    "maxConcurrency",
    "requestTimeoutMs",
    "dryRun",
]
@dataclass(frozen=True)
class ConfigLayer:
    source_name: str
    values: Mapping[str, str]
@dataclass(frozen=True)
class AppConfig:
    api_base_url: str
    max_concurrency: int
    request_timeout_ms: int
    dry_run: bool
@dataclass(frozen=True)
class ConfigError:
    code: Literal[
        "unknown_key",
        "missing_key",
        "invalid_value",
    ]
    key: str
    source_name: str
    message: str
@dataclass(frozen=True)
class ConfigLoaded:
    config: AppConfig
ConfigLoadResult: TypeAlias = ConfigLoaded | ConfigError
@dataclass(frozen=True)
class RawConfigValue:
    value: str
    source_name: str
_KNOWN_KEYS = frozenset(
    {
        "apiBaseUrl",
        "maxConcurrency",
        "requestTimeoutMs",
        "dryRun",
    }
)
_DECIMAL_RE = re.compile(r"0|[1-9][0-9]*")
def load_app_config(
    layers: tuple[ConfigLayer, ...],
) -> ConfigLoadResult:
    merged: dict[str, RawConfigValue] = {}
    for layer in layers:
        error = _merge_layer(merged, layer)
        if error is not None:
            return error
    readonly_values = MappingProxyType(merged)
    return _parse_snapshot(readonly_values)
def _merge_layer(
    merged: dict[str, RawConfigValue],
    layer: ConfigLayer,
) -> ConfigError | None:
    for key, value in layer.values.items():
        if key not in _KNOWN_KEYS:
            return ConfigError(
                code="unknown_key",
                key=key,
                source_name=layer.source_name,
                message="Unsupported configuration key.",
            )
        merged[key] = RawConfigValue(
            value=value,
            source_name=layer.source_name,
        )
    return None
def _parse_snapshot(
    values: Mapping[str, RawConfigValue],
) -> ConfigLoadResult:
    api_base_url = _read_https_url(
        values,
        "apiBaseUrl",
    )
    max_concurrency = _read_integer(
        values,
        "maxConcurrency",
        1,
        64,
    )
    request_timeout_ms = _read_integer(
        values,
        "requestTimeoutMs",
        100,
        60_000,
    )
    dry_run = _read_boolean(
        values,
        "dryRun",
    )
    error = _first_error(
        api_base_url,
        max_concurrency,
        request_timeout_ms,
        dry_run,
    )
    if error is not None:
        return error
    return ConfigLoaded(
        config=AppConfig(
            api_base_url=api_base_url,
            max_concurrency=max_concurrency,
            request_timeout_ms=request_timeout_ms,
            dry_run=dry_run,
        )
    )
def _read_https_url(
    values: Mapping[str, RawConfigValue],
    key: ConfigKey,
) -> str | ConfigError:
    raw = values.get(key)
    if raw is None:
        return _missing_key(key)
    try:
        parsed = urlsplit(raw.value)
        # Accessing `port` may raise a `ValueError` on malformed port syntax.
        _ = parsed.port
        valid = (
            parsed.scheme == "https"
            and parsed.hostname is not None
            and parsed.username is None
            and parsed.password is None
            and parsed.query == ""
            and parsed.fragment == ""
        )
        # Unlike the examples in other languages, the parsed value is stored rather than the raw string.
        # Note that the stdlib's URL normalization is more lightweight than WHATWG URL or the `url` crate.
        normalized = parsed.geturl()
    except ValueError:
        valid = False
        normalized = ""
    if not valid:
        return _invalid_value(
            key,
            raw.source_name,
            "An HTTPS URL without user info, query, or fragment is required.",
        )
    return normalized
def _read_integer(
    values: Mapping[str, RawConfigValue],
    key: ConfigKey,
    minimum: int,
    maximum: int,
) -> int | ConfigError:
    raw = values.get(key)
    if raw is None:
        return _missing_key(key)
    if _DECIMAL_RE.fullmatch(raw.value) is None:
        return _invalid_value(
            key,
            raw.source_name,
            "An ASCII decimal integer without leading zeros is required.",
        )
    value = int(raw.value)
    if value < minimum or value > maximum:
        return _invalid_value(
            key,
            raw.source_name,
            f"A value between {minimum} and {maximum} (inclusive) is required.",
        )
    return value
def _read_boolean(
    values: Mapping[str, RawConfigValue],
    key: ConfigKey,
) -> bool | ConfigError:
    raw = values.get(key)
    if raw is None:
        return _missing_key(key)
    if raw.value == "true":
        return True
    if raw.value == "false":
        return False
    return _invalid_value(
        key,
        raw.source_name,
        "`true` or `false` is required.",
    )
def _first_error(
    *values: object,
) -> ConfigError | None:
    for value in values:
        if isinstance(value, ConfigError):
            return value
    return None
def _missing_key(key: ConfigKey) -> ConfigError:
    return ConfigError(
        code="missing_key",
        key=key,
        source_name="merged",
        message="A required configuration value is missing.",
    )
def _invalid_value(
    key: ConfigKey,
    source_name: str,
    message: str,
) -> ConfigError:
    return ConfigError(
        code="invalid_value",
        key=key,
        source_name=source_name,
        message=message,
    )

In Python, a frozen=True dataclass is used to treat the final snapshot as an immutable value. The Python documentation also describes frozen=True as a simulation of read-only behavior rather than a "truly immutable object."7 It is therefore better not to put mutable collections directly inside AppConfig. Because isdecimal() passes other Unicode digits and leading zeros as well, this example uses the regex 0|[1-9][0-9]* to allow only ASCII decimal without leading zeros, matching the syntax of the other languages. URLs are also stored as values that have gone through urlsplit (geturl()), not as raw strings.

Personal note: The original code this started from was Python. It does one simple thing: a string passes through a function

Note, however, that the stdlib's URL normalization is lighter than JS's URL or Rust's url crate, so it does not perform normalization such as appending a trailing slash.

Rust

Rust
use std::collections::{BTreeMap, BTreeSet};
use url::Url;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigLayer {
    source_name: String,
    values: BTreeMap<String, String>,
}

impl ConfigLayer {
    pub fn new(
        source_name: String,
        values: BTreeMap<String, String>,
    ) -> Self {
        Self {
            source_name,
            values,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppConfig {
    api_base_url: Url,
    max_concurrency: usize,
    request_timeout_ms: u64,
    dry_run: bool,
}

impl AppConfig {
    pub fn get_api_base_url(&self) -> &Url {
        &self.api_base_url
    }
    pub fn get_max_concurrency(&self) -> usize {
        self.max_concurrency
    }
    pub fn get_request_timeout_ms(&self) -> u64 {
        self.request_timeout_ms
    }
    pub fn get_dry_run(&self) -> bool {
        self.dry_run
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigErrorCode {
    UnknownKey,
    MissingKey,
    InvalidValue,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError {
    pub code: ConfigErrorCode,
    pub key: String,
    pub source_name: String,
    pub message: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct RawConfigValue {
    value: String,
    source_name: String,
}

pub fn load_app_config(
    layers: &[ConfigLayer],
) -> Result<AppConfig, ConfigError> {
    let known_keys = known_keys();
    let mut merged = BTreeMap::new();
    for layer in layers {
        merge_layer(
            &mut merged,
            layer,
            &known_keys,
        )?;
    }
    parse_snapshot(&merged)
}

fn merge_layer(
    merged: &mut BTreeMap<String, RawConfigValue>,
    layer: &ConfigLayer,
    known_keys: &BTreeSet<&'static str>,
) -> Result<(), ConfigError> {
    for (key, value) in &layer.values {
        if !known_keys.contains(key.as_str()) {
            return Err(ConfigError {
                code: ConfigErrorCode::UnknownKey,
                key: key.clone(),
                source_name: layer.source_name.clone(),
                message: "Unsupported configuration key.".to_owned(),
            });
        }
        merged.insert(
            key.clone(),
            RawConfigValue {
                value: value.clone(),
                source_name: layer.source_name.clone(),
            },
        );
    }
    Ok(())
}

fn parse_snapshot(
    values: &BTreeMap<String, RawConfigValue>,
) -> Result<AppConfig, ConfigError> {
    let api_base_url = read_https_url(
        values,
        "apiBaseUrl",
    )?;
    let max_concurrency = read_usize(
        values,
        "maxConcurrency",
        1,
        64,
    )?;
    let request_timeout_ms = read_u64(
        values,
        "requestTimeoutMs",
        100,
        60_000,
    )?;
    let dry_run = read_bool(
        values,
        "dryRun",
    )?;
    Ok(AppConfig {
        api_base_url,
        max_concurrency,
        request_timeout_ms,
        dry_run,
    })
}

fn read_https_url(
    values: &BTreeMap<String, RawConfigValue>,
    key: &str,
) -> Result<Url, ConfigError> {
    let raw = get_required(values, key)?;
    let parsed = Url::parse(&raw.value)
        .map_err(|_| invalid_value(
            key,
            raw,
            "Valid URL required.",
        ))?;
    if parsed.scheme() != "https"
        || parsed.host_str().is_none()
        || !parsed.username().is_empty()
        || parsed.password().is_some()
        || parsed.query().is_some()
        || parsed.fragment().is_some()
    {
        return Err(invalid_value(
            key,
            raw,
            "HTTPS URL required without userinfo, query, or fragment.",
        ));
    }
    Ok(parsed)
}

fn read_usize(
    values: &BTreeMap<String, RawConfigValue>,
    key: &str,
    minimum: usize,
    maximum: usize,
) -> Result<usize, ConfigError> {
    let raw = get_required(values, key)?;
    if !is_canonical_decimal(&raw.value) {
        return Err(invalid_value(
            key,
            raw,
            "Canonical ASCII decimal integer required (no leading zeros).",
        ));
    }
    let value = raw.value
        .parse::<usize>()
        .map_err(|_| invalid_value(
            key,
            raw,
            "Integer required.",
        ))?;
    if !(minimum..=maximum).contains(&value) {
        return Err(invalid_value(
            key,
            raw,
            "Value out of allowed range.",
        ));
    }
    Ok(value)
}

fn read_u64(
    values: &BTreeMap<String, RawConfigValue>,
    key: &str,
    minimum: u64,
    maximum: u64,
) -> Result<u64, ConfigError> {
    let raw = get_required(values, key)?;
    if !is_canonical_decimal(&raw.value) {
        return Err(invalid_value(
            key,
            raw,
            "Canonical ASCII decimal integer required (no leading zeros).",
        ));
    }
    let value = raw.value
        .parse::<u64>()
        .map_err(|_| invalid_value(
            key,
            raw,
            "Integer required.",
        ))?;
    if !(minimum..=maximum).contains(&value) {
        return Err(invalid_value(
            key,
            raw,
            "Value out of allowed range.",
        ));
    }
    Ok(value)
}

fn read_bool(
    values: &BTreeMap<String, RawConfigValue>,
    key: &str,
) -> Result<bool, ConfigError> {
    let raw = get_required(values, key)?;
    match raw.value.as_str() {
        "true" => Ok(true),
        "false" => Ok(false),
        _ => Err(invalid_value(
            key,
            raw,
            "true or false required.",
        )),
    }
}

fn get_required<'a>(
    values: &'a BTreeMap<String, RawConfigValue>,
    key: &str,
) -> Result<&'a RawConfigValue, ConfigError> {
    values.get(key).ok_or_else(|| ConfigError {
        code: ConfigErrorCode::MissingKey,
        key: key.to_owned(),
        source_name: "merged".to_owned(),
        message: "Required configuration is missing.".to_owned(),
    })
}

fn is_canonical_decimal(value: &str) -> bool {
    if value.is_empty() {
        return false;
    }
    if value == "0" {
        return true;
    }
    let bytes = value.as_bytes();
    if bytes[0] == b'0' {
        return false;
    }
    bytes.iter().all(|byte| byte.is_ascii_digit())
}

fn invalid_value(
    key: &str,
    raw: &RawConfigValue,
    message: &str,
) -> ConfigError {
    ConfigError {
        code: ConfigErrorCode::InvalidValue,
        key: key.to_owned(),
        source_name: raw.source_name.clone(),
        message: message.to_owned(),
    }
}

fn known_keys() -> BTreeSet<&'static str> {
    BTreeSet::from([
        "apiBaseUrl",
        "maxConcurrency",
        "requestTimeoutMs",
        "dryRun",
    ])
}

Required dependencies:

TOML
[dependencies]
url = "2"

In Rust, Result<AppConfig, ConfigError> is the natural form, and the fields of the completed AppConfig are kept private.

parse::<usize>() and parse::<u64>() also accept "08", so is_canonical_decimal is used to filter out leading zeros before parsing. If configuration needs to change at runtime, create a new snapshot and replace the old one rather than mutating the existing object. Note, however, that Arc<T> provides thread-safe shared ownership, not atomic replacement of the Arc variable itself.8 The replacement site requires separate synchronization such as RwLock<Arc<AppConfig>> or ArcSwap.


3. Call site

I/O adapters read files, environment variables, and command-line arguments and convert them into canonical layers. The core loader has no knowledge of where those layers came from.

TypeScript
type SyncService = Readonly<{
  runAsync: (
    config: AppConfig,
    signal?: AbortSignal,
  ) => Promise<void>;
}>;
export async function startApplicationAsync(
  service: SyncService,
  fileValues: Readonly<Record<string, string>>,
  commandLineValues: Readonly<Record<string, string>>,
  signal?: AbortSignal,
): Promise<number>
{
  const result = loadAppConfig([
    createDefaultLayer(),
    {
      sourceName: "configFile",
      values: fileValues,
    },
    createEnvironmentLayer(process.env),
    {
      sourceName: "commandLine",
      values: commandLineValues,
    },
  ]);
  if (result.kind === "rejected") {
    writeConfigError(result.error);
    return 2;
  }
  await service.runAsync(result.config, signal);
  return 0;
}
function createDefaultLayer(): ConfigLayer
{
  return {
    sourceName: "defaults",
    values: {
      maxConcurrency: "4",
      requestTimeoutMs: "5000",
      dryRun: "true",
    },
  };
}
function createEnvironmentLayer(
  environment: NodeJS.ProcessEnv,
): ConfigLayer
{
  const values: Record<string, string> = {};
  assignIfPresent(
    values,
    "apiBaseUrl",
    environment.SYNC_API_BASE_URL,
  );
  assignIfPresent(
    values,
    "maxConcurrency",
    environment.SYNC_MAX_CONCURRENCY,
  );
  assignIfPresent(
    values,
    "requestTimeoutMs",
    environment.SYNC_REQUEST_TIMEOUT_MS,
  );
  assignIfPresent(
    values,
    "dryRun",
    environment.SYNC_DRY_RUN,
  );
  return {
    sourceName: "environment",
    values,
  };
}
function assignIfPresent(
  target: Record<string, string>,
  key: ConfigKey,
  value: string | undefined,
): void
{
  if (value !== undefined) {
    target[key] = value;
  }
}
function writeConfigError(error: ConfigError): void
{
  console.error(JSON.stringify({
    eventName: "application.configuration.rejected",
    code: error.code,
    key: error.key,
    sourceName: error.sourceName,
    message: error.message,
  }));
}

Separation of responsibilities:

Text
File Adapter
= File I/O and file format parsing

Environment Adapter
= Converts environment variable names to canonical keys

CLI Adapter
= Converts command line syntax to canonical keys

AppConfigLoader
= Priority merge, rejects unknown keys, type conversion

AppConfig
= Immutable configuration used by running code

Startup Policy
= Converts configuration failures to exit codes and operational logs

SyncService
= Performs actual business I/O using already validated configuration

Inside SyncService, process.env.SYNC_MAX_CONCURRENCY is never read again. Knowing this alone means you understand how to properly establish a snapshot boundary. Here, loadAppConfig(layers) is a low-level function. Changing the array order changes the priority, but the function cannot detect that mistake. For this reason, this function is not exposed directly to general call sites; instead, a composition root like startApplicationAsync() above fixes the order defaults -> file -> environment -> commandLine in one place. If you want a stronger guarantee, create a loadStandardAppConfig() that takes the four sources as named fields instead of an array, and assemble the array only inside that function. sourceName is also better served by a closed enum or union than a free string. Including file paths or secret names as source names would make provenance logs another channel for information leakage.

Personal note: What is a Composition Root? It simply means assembling all required dependencies at the top-level root. Put plainly, it is the place where the program gathers everything it needs at startup, verifies it, and distributes the necessary pieces to each object. No need to overthink it. It is just about calling things early and checking quickly. Think of it like writing in your school planner and getting your supplies checked by the teacher. That should make it easy to understand.

For an American audience, think of the Back-to-School Supply List and Homeroom. I've heard that in the United States there is a culture of receiving a School Supply List before the school year starts, then opening your backpack during Homeroom on the first day to verify that every supply is properly in order. Rather than discovering mid-day in math class that you forgot an eraser, everything is checked before the day's activities fully begin (at the Entry Point).


4. How to read the code

Text
What are the configuration sources?
→ Is each source converted to a canonical key?
→ Is the priority ordering of configuration layers explicitly defined?
→ Is overriding performed at the raw layer stage?
→ Are unknown keys rejected?
→ Are all fields converted with type and range validation?
→ If there are cross-field rules, are cross-invariants checked?
→ Is the completed configuration an immutable object?
→ Does the runtime code avoid re-reading raw sources?
→ Does configuration failure occur before actual I/O begins?

Configuration code should be read with a focus on when values are interpreted and from what point they become fixed, rather than on getter calls. The four fields in this example are independent of each other, so there are no contrived cross-field rules. Accordingly, the validations the example actually performs cover only required-field checks, syntax checks, and range checks. If domain rules arise, such as jointly constraining retryCount and requestTimeoutMs or requiring a separate approval token when dryRun=false, those should be checked in a validateWholeConfig(candidate) step after each field is parsed. It is better to document the current example's guarantee scope than to invent rules and add them to the code. (A blog post ultimately cannot include the full production codebase.) The example also returns only the first error. That is sufficient for halting startup immediately, but if operators want to fix multiple typos in one pass, collecting all non-sensitive errors and returning them together is more convenient. Whichever approach you choose, keep error ordering deterministic and never include raw secret values in error messages.


5. Boundaries and misconceptions

A Configuration Snapshot is well suited for settings that do not need to change at runtime.

Text
Suitable:
- API base URL
- timeout
- connection pool size
- worker count
- feature startup mode
- path and format selection
Values requiring separate design:
- real-time feature flags
- dynamic rate limits
- certificates replaced during operation
- per-tenant policies
- kill switches that must take effect immediately

For dynamic configuration, rather than sharing a global mutable dictionary, it is better to create a new object in the form ConfigSnapshot v1 → ConfigSnapshot v2 and replace it atomically after validation. Within a single snapshot, all fields must belong to the same version. However, "replace atomically" is not something the language guarantees automatically.

Real synchronization primitives are required, such as C#'s Interlocked.Exchange, Java's AtomicReference, or Rust's RwLock or ArcSwap. Defaults must also be chosen carefully. If it is not clear what the safe behavior is when a setting is missing, it is better not to provide a default at all.

Text
Suitable for defaults:
- Conservative timeout
- Low worker count
- `dryRun=true` to prevent unintended external changes
Candidates to avoid defaults for:
- Billing target accounts
- production API URL
- Encryption keys
- Whether data deletion is permitted
- Tenant identifiers

It is particularly dangerous when a missing critical setting causes production behavior to start with an arbitrary default, which is fail-open. Settings related to deletion, payments, or permissions are safer with a fail-closed approach. Secrets must also be distinguished from ordinary configuration strings. Passing an API token as a command-line argument can expose it in process listings, and MITRE classifies this as CWE-214.9 Environment variables can also be visible in other processes, logs, and system dumps, so OWASP does not recommend them as a secret delivery mechanism when other options are available.10 Where possible, treat sensitive values via secret manager identifiers or a dedicated credential provider, and exclude them from logs and error messages. Storing a simple hash of a secret as a fingerprint is also unsafe. If the set of candidate values is small, the original value can be guessed by hashing each candidate. A policy of rejecting unknown keys can conflict with migrations. If a deployment structure has an older application reading a newer configuration file, the schema version or compatibility range must be explicitly specified; otherwise some instances in a rolling deployment may fail to start because of newly added keys. Also, the environment adapter in this example selects only known SYNC_* names to build the canonical layer, so unrelated environment variables are not rejected as unknown keys. To catch unknown keys in configuration files and CLI inputs as well, the source parser must forward unfamiliar entries to the loader rather than silently discarding them. Keys silently dropped by an adapter cannot be discovered by the core loader. HTTPS syntax validation is also not destination authorization. Since https://127.0.0.1, link-local addresses, and private-network hosts can be syntactically valid, programs where external input determines the URL must handle host allowlisting, DNS rebinding, redirect policy, and actual connection IP verification as a separate SSRF boundary. Pitfalls that cause real-world failures:

Text
- Repeatedly reading environment variables from inside the service
- Using different default values and parsing rules per source
- Priority ordering existing only implicitly in code order
- Silently ignoring unknown keys, hiding configuration typos
- Allowing `0`, `-1`, or excessively large timeout values
- Only some fields being replaced first during configuration file reload
- Injecting a raw dictionary throughout the application core
- Printing secrets to the command line and startup logs
- Default values automatically permitting dangerous production behavior
- Some background workers already started after a configuration failure

6. Bad example

TypeScript
async function synchronizeBadAsync(): Promise<void>
{
  const apiBaseUrl =
    process.env.SYNC_API_BASE_URL
    ?? "https://production.example.com";
  const maxConcurrency = Number(
    process.env.SYNC_MAX_CONCURRENCY ?? "1000",
  );
  const dryRun =
    process.env.SYNC_DRY_RUN === "true";
  await runSynchronization({
    apiBaseUrl,
    maxConcurrency,
    dryRun,
  });
}
async function runSynchronization(
  config: {
    apiBaseUrl: string;
    maxConcurrency: number;
    dryRun: boolean;
  },
): Promise<void>
{
  const timeoutMs = Number(
    process.env.SYNC_REQUEST_TIMEOUT_MS ?? "0",
  );
  console.log("Execution Configuration", process.env);
  // Actual Synchronization Task
  void config;
  void timeoutMs;
}

Why it is bad:

Text
Configuration is read in two functions at different points in time.
A single execution can see different environment variable snapshots.
An aggressive default value of `maxConcurrency=1000` is used.
The result of a `Number` conversion is not validated even when it is `NaN`.
The meaning of `timeoutMs=0` is ambiguous: it is unclear whether it means immediate timeout or no timeout.
A missing production URL is silently hidden behind a default value.
The entire `process.env` is printed, which can leak secrets.
Application code is directly coupled to raw configuration sources.
There is no way to trace which source provided the final value.

Configuration should be a validated runtime input whose processing is completed at the startup boundary, not a value read on demand wherever it is needed.


7. Production extensions

The key production extension is configuration provenance and priority contract testing. Recording not just the final value but also which layer provided it in the snapshot metadata makes operational diagnostics much easier.

TypeScript
type ConfigSourceName =
  | "defaults"
  | "configFile"
  | "environment"
  | "commandLine";
type ConfigSnapshot = Readonly<{
  config: AppConfig;
  sourceByKey: Readonly<Record<ConfigKey, ConfigSourceName>>;
}>;
type MergeResult = Readonly<{
  values: ReadonlyMap<string, RawConfigValue>;
  sourceByKey: Readonly<Record<ConfigKey, ConfigSourceName>>;
}>;

Operational logs should record only the source and a safe summary, not the actual values.

TypeScript
function createConfigStartupSummary(
  snapshot: ConfigSnapshot,
): Readonly<Record<string, unknown>>
{
  return {
    eventName: "application.configuration.loaded",
    apiBaseUrlHost: new URL(snapshot.config.apiBaseUrl).host,
    maxConcurrency: snapshot.config.maxConcurrency,
    requestTimeoutMs: snapshot.config.requestTimeoutMs,
    dryRun: snapshot.config.dryRun,
    sourceByKey: snapshot.sourceByKey,
  };
}

Secret values are not included in the summary. For ordinary URLs, if the query string might contain a token, record only the validated host rather than the full string.

Priority contract tests:

TypeScript
import assert from "node:assert/strict";
import test from "node:test";
test("CLI overrides environment variables, files, and defaults", () =>
{
  const result = loadAppConfig([
    {
      sourceName: "defaults",
      values: {
        apiBaseUrl: "https://default.example.com",
        maxConcurrency: "4",
        requestTimeoutMs: "5000",
        dryRun: "true",
      },
    },
    {
      sourceName: "configFile",
      values: {
        maxConcurrency: "8",
      },
    },
    {
      sourceName: "environment",
      values: {
        maxConcurrency: "12",
        dryRun: "false",
      },
    },
    {
      sourceName: "commandLine",
      values: {
        maxConcurrency: "16",
      },
    },
  ]);
  assert.equal(result.kind, "loaded");
  if (result.kind !== "loaded") {
    return;
  }
  assert.equal(result.config.maxConcurrency, 16);
  assert.equal(result.config.dryRun, false);
});
test("Unknown keys are rejected at startup", () =>
{
  const result = loadAppConfig([
    {
      sourceName: "defaults",
      values: {
        apiBaseUrl: "https://api.example.com",
        maxConcurrency: "4",
        requestTimeoutMs: "5000",
        dryRun: "true",
        maxConcurreny: "32",
      },
    },
  ]);
  assert.deepEqual(result, {
    kind: "rejected",
    error: {
      code: "unknownKey",
      key: "maxConcurreny",
      sourceName: "defaults",
      message: "Unsupported configuration key.",
    },
  });
});
test("A valid value from a higher-priority layer replaces the value from a lower-priority layer", () =>
{
  const result = loadAppConfig([
    {
      sourceName: "defaults",
      values: {
        apiBaseUrl: "https://api.example.com",
        maxConcurrency: "not-a-number",
        requestTimeoutMs: "5000",
        dryRun: "true",
      },
    },
    {
      sourceName: "environment",
      values: {
        maxConcurrency: "8",
      },
    },
  ]);
  assert.equal(result.kind, "loaded");
  if (result.kind !== "loaded") {
    return;
  }
  assert.equal(result.config.maxConcurrency, 8);
});
test("Reject the final merged result if it is out of range", () =>
{
  const result = loadAppConfig([
    {
      sourceName: "defaults",
      values: {
        apiBaseUrl: "https://api.example.com",
        maxConcurrency: "4",
        requestTimeoutMs: "5000",
        dryRun: "true",
      },
    },
    {
      sourceName: "commandLine",
      values: {
        maxConcurrency: "1000",
      },
    },
  ]);
  assert.equal(result.kind, "rejected");
});
test("Integers with leading zeros are rejected", () =>
{
  const result = loadAppConfig([
    {
      sourceName: "defaults",
      values: {
        apiBaseUrl: "https://api.example.com",
        maxConcurrency: "08",
        requestTimeoutMs: "5000",
        dryRun: "true",
      },
    },
  ]);
  assert.equal(result.kind, "rejected");
});

The third test is important. If each layer is parsed individually and then merged, a "not-a-number" value in a lower layer can cause the entire merge to be rejected. This pattern merges raw values first and parses only the final selected value. The last test verifies that an integer with a leading zero such as "08" is consistently rejected across all four languages. Operational metrics observe the startup outcome rather than the values themselves.

Text
application.config.loaded.count
application.config.rejected.count
application.config.rejected.by_code
application.config.reload.succeeded.count
application.config.reload.rejected.count
application.config.snapshot.version

Values with high cardinality or high sensitivity, such as apiBaseUrl, tenant IDs, and secret names, should not be used as metric labels. Metric costs can grow with the number of distinct attribute combinations rather than with request count.11

snapshot.version should also not be attached as a string hash label on every metric; instead, record a monotonically increasing number within the process as a gauge value or log field.


8. C# / TypeScript / Python / Rust comparison notes

Language

Idiomatic expression

Notes

C#

immutable record, options binding, startup validation

Do not inject IConfiguration throughout the application core

TypeScript

readonly object, discriminated result, branded primitive

compile-time readonly and Object.freeze do not provide deep immutability

Python

frozen dataclass, Mapping, explicit loader

Do not place mutable list/dict inside a frozen object

Rust

private field struct, Result, ownership transfer

Hot reload requires a new snapshot and a separately synchronized holder

Language

C#

Idiomatic expression

immutable record, options binding, startup validation

Notes

Do not inject IConfiguration throughout the application core

Language

TypeScript

Idiomatic expression

readonly object, discriminated result, branded primitive

Notes

compile-time readonly and Object.freeze do not provide deep immutability

Language

Python

Idiomatic expression

frozen dataclass, Mapping, explicit loader

Notes

Do not place mutable list/dict inside a frozen object

Language

Rust

Idiomatic expression

private field struct, Result, ownership transfer

Notes

Hot reload requires a new snapshot and a separately synchronized holder

In C#, even when using the framework's configuration binder, it is better to validate the binding result into a separate domain configuration object before passing it to the application core. IConfiguration allows string key lookups from anywhere, so the boundary tends to spread easily. TypeScript's Readonly<T> is a type-checking-level constraint.

Fixing nested objects passed in from outside requires a copy-and-validate policy. This example keeps the reference graph small by storing only numbers, booleans, and validated URL strings in the snapshot. Python's frozen dataclass similarly does not recursively make inner objects immutable. If a collection is needed in configuration, representations such as tuple, frozenset, or mapping proxy should be considered.

In Rust, fields are private by default and immutable bindings are the default, so representing a snapshot is natural. When dynamic reload is needed, do not modify the existing value in place; create a completed new configuration and replace the shared pointer in a synchronized holder. Timeouts are expressed slightly differently across the four languages. C# carries the unit in the type via TimeSpan, while the others use a millisecond integer with the name suffix Ms to fix the unit. The essential principle is to hold a value whose unit is definite rather than a raw string. The common invariants generally look like this:

Text
Configuration is not consumed as a string dictionary;
at the startup boundary, it is converted into an immutable type with confirmed semantics and units.

9. Further considerations

  • Can this configuration be fixed at application startup, or does it need to change at runtime?

  • Does the default value safely compensate for a missing setting, or does it silently permit dangerous production behavior?

  • Are raw layers merged first and then parsed, or is each layer parsed individually in a way that prevents higher-priority overrides from taking effect?

  • When unknown keys are rejected, how is compatibility with rolling deployments and schema versioning maintained?

  • Is it acceptable to handle secret values and ordinary configuration through the same object, logs, and command-line paths?

  • If a dynamic reload fails, should the existing snapshot be retained or the process terminated?


10. Summary

  • A Configuration Snapshot consolidates multiple raw configuration layers into a single validated, immutable runtime configuration.

  • Priority order must be explicitly fixed in both code and tests.

  • It is safer to merge raw values first and apply type conversion only to the final selected values.

  • Integer syntax (ASCII decimal without leading zeros) and boolean syntax are unified under a single rule across all four languages.

  • Unknown keys and out-of-range values must be rejected before any actual business I/O begins.

  • Execution code should use only AppConfig rather than reading environment variables or configuration dictionaries again.

  • Even for dynamic configuration, rather than partially mutating an existing object, replace it with a validated new snapshot, and ensure actual atomicity through separate synchronization.

One-line mnemonic:

Text
Don't read configuration piecemeal whenever you need it;
instead, merge and validate everything at startup, then carry it forward as a single immutable value.

Personal note: Writing Rust code is too painful from a developer experience standpoint. I sometimes wonder whether I'm learning it too early when I don't have a real use case for it yet.

I've been studying and organizing codebases for seven years. Going through it again, I'm reminded that a surprisingly large number of codebases I wrote or reviewed required quite a few theoretical foundations.

Footnotes

  1. Adam Wiggins. The Twelve-Factor App: Config, Build, release, run.
  2. Microsoft Learn. .NET configuration providers. Providers are applied in the order they are added, and for the same key, a later provider overwrites the value from an earlier one.
  3. Node.js Documentation. Environment Variables. All .env values are interpreted as strings in Node.js.
  4. Microsoft Learn. Options pattern in ASP.NET Core: ValidateOnStart. Options validation can be run at application startup rather than at the point of first use.
  5. TypeScript Handbook. `readonly` properties. readonly restricts property reassignment during type checking but does not guarantee immutability of nested objects.
  6. MDN Web Docs. `Object.freeze()`. Freeze is a shallow operation that applies only to immediate properties.
  7. Python Documentation. `dataclasses`: Frozen instances. Python simulates read-only behavior using frozen=True.
  8. Rust Standard Library. `std::sync::Arc`. The "atomic" in Arc refers to reference count management and does not substitute for arbitrarily replacing the held value.
  9. MITRE CWE. CWE-214: Invocation of Process Using Visible Sensitive Information.
  10. OWASP Cheat Sheet Series. Secrets Management Cheat Sheet.
  11. OpenTelemetry. Metrics: Cardinality limits.