What typed settings are and why monoliths need them
Typed settings are a single schema that describes every configuration input the application accepts: its name, type, default, and constraints. At startup, the process reads its sources once, validates them against that schema, and produces an immutable settings object. Application code depends on that object, never on the raw environment. Environment variables remain the transport, as 12-factor config recommends, but they stop being the interface. Code receives a validated integer timeout, not a string it has to parse and trust.
Monoliths accumulate configuration debt in a particular way. In a microservice, the inputs are few and one team owns them. In a monolith, billing, search, notifications and auth share one process, and each domain adds os.getenv or process.env reads wherever it needs them. After a few years, those reads are spread across hundreds of files. Each one parses values its own way and falls back to its own default. No one can list everything the process needs to boot, so a missing or malformed variable often shows up only when the code path that reads it runs, possibly hours after a deploy.
Backend and platform engineers on large, multi-team monoliths benefit most. Platform teams get one authoritative inventory of inputs for every environment. Feature teams get type errors at boot instead of incidents in production.
The pattern is worth adopting when any of the following is true:
- No one can say which variables production requires.
- Config-related incidents recur.
- Onboarding a new environment means grepping the codebase.
How it works: schema, layering and load-once startup
At startup, the application builds one settings object from a fixed sequence of sources. Each layer overrides the one before it:
- Code defaults for values that are safe everywhere, such as timeouts and page sizes.
- An optional local file, loaded only when the process is explicitly in development mode. It is a convenience for laptops.
- Real environment variables and mounted secret files, which are what the deployment platform actually controls.
- Validation of the merged result: types, ranges, required keys, and cross-field rules such as "TLS cert path is required when TLS is enabled."
- A frozen object that is passed to modules through constructors or a container. The process binds its port only after this succeeds. If validation fails, the process exits before it takes traffic.
Precedence has to be written down, deterministic, and covered by tests. When someone asks why staging used a particular value, the answer should come from reading the rules. It should not depend on load order or on which file happened to exist on that machine.
For that reason, production must never read a dotfile. A stray file baked into an image can silently override the environment, and nobody reviewing the deployment manifest will see it.
What 12-factor actually asks for
The 12-factor rule is narrow. Values that vary between deploys belong in the environment: credentials, hostnames, and feature toggles per tenant. Constants that are the same everywhere belong in code, where they get reviewed and versioned.
Named profiles like staging or perf are where teams drift from this rule. Each profile tends to become a hidden bundle of overrides, and the number of profiles grows over time. Keep profiles few, and limit them to selecting defaults. Let explicit variables decide everything else.
+--------------------------------------------------------------+
| 1. Code defaults: timeouts, page sizes (lowest precedence) |
+--------------------------------------------------------------+
v overridden by
+--------------------------------------------------------------+
| 2. Optional local file: read ONLY in explicit dev mode |
| never read in production; no dotfiles baked in images |
+--------------------------------------------------------------+
v overridden by
+--------------------------------------------------------------+
| 3. Env vars + mounted secret files (highest precedence) |
| controlled by the deployment platform |
+--------------------------------------------------------------+
v merged result
+--------------------------------------------------------------+
| 4. Validate merged result: types, ranges, required keys, |
| cross-field rules (TLS on => cert path required) |
+--------------------------------------------------------------+
| ok | fail
v v
+-------------------------+ +-------------------------+
| 5. Frozen settings | | Process exits before |
| object (read-only) | | taking any traffic |
+-------------------------+ +-------------------------+
v injected via constructors / container
+-------------------------+
| Modules receive it; |
| then app binds port |
+-------------------------+
Startup builds one settings object by layering code defaults, a dev-only local file, and environment variables plus mounted secrets in a fixed precedence order, then validates the result and either injects a frozen object before binding the port or exits before taking traffic.
Implementing a typed settings object with fail-fast validation
Model the configuration as one root object, Settings, whose fields are per-module sections: database, cache, email, payments. Each module owns its section's class and receives only that section, so the payments code cannot read the cache's settings. Map variables by prefix, so PAYMENTS_WEBHOOK_SECRET becomes payments.webhook_secret.
Parse every value explicitly. The classic bug is if os.getenv("DEBUG"):. The string "false" is non-empty, so it is truthy, and debug mode turns on in production.
| Type | Accept | Reject or normalize |
|---|---|---|
| Boolean | true/false, 1/0 |
Treat empty string as unset. Reject yes, on and typos. |
| Duration | Value with a unit, e.g. 30s, 5m |
Reject a bare 30, because the unit is ambiguous. |
| List | Comma-separated values, trimmed | Drop empty items from trailing commas. |
| URL | Scheme and host present | Reject a missing scheme or a scheme outside the allowed set. |
| Number | Integer within declared bounds | Reject 10.5 for an integer field and negative pool sizes. |
Do not stop at the first error. Collect every failure and print one line for each, with the variable name, the expected type or format, and the redacted value it received. Then exit non-zero before the app binds a port. One deploy then shows every mistake, instead of one mistake per restart.
Wrap secrets in a Secret type whose repr, toString and error output all show ***. Unwrapping it should require an explicit call. Load each secret from either NAME or NAME_FILE, where NAME_FILE points to a mounted file. Treat it as an error if both are set.
Make security-relevant fields required, with no defaults: signing keys, webhook secrets and TLS verification flags. A default such as verify_tls=true looks safe until someone sets it to false for local development and it ships to production. Requiring the field forces every environment to state the value explicitly.
import os, re, sys
from dataclasses import dataclass
class Secret:
def __init__(self, value): self._value = value
def __repr__(self): return "Secret('***')" # str() and f-strings fall back to this
def reveal(self): return self._value # unwrapping is always explicit
def parse_bool(raw):
if raw in ("true", "1"): return True
if raw in ("false", "0"): return False
raise ValueError("expected true|false|1|0") # never bool(raw): "false" is truthy
def parse_seconds(raw):
m = re.fullmatch(r"(\d+)(s|m|h)", raw) # a bare "30" is rejected as ambiguous
if not m: raise ValueError("expected duration with unit, e.g. 30s or 5m")
return int(m[1]) * {"s": 1, "m": 60, "h": 3600}[m[2]]
def load(name, parse, errors, secret=False, default=None):
raw, path = os.environ.get(name, ""), os.environ.get(name + "_FILE", "")
if raw and path:
errors.append(f"{name}: set {name} or {name}_FILE, not both"); return None
if path: # e.g. a secret file mounted by the orchestrator
with open(path) as f: raw = f.read().strip()
if raw == "": # empty string is treated as unset
if default is None: errors.append(f"{name}: required, not set")
return default
try:
value = parse(raw)
return Secret(value) if secret else value
except ValueError as e: # collect the error, never echo a secret's raw value
errors.append(f"{name}: {e}; got {'***' if secret else repr(raw)}")
@dataclass(frozen=True)
class PaymentsSettings:
webhook_secret: Secret # required: no default
verify_tls: bool # required: security-relevant, so no default
timeout_s: int
errors = []
payments = PaymentsSettings(
webhook_secret=load("PAYMENTS_WEBHOOK_SECRET", str, errors, secret=True),
verify_tls=load("PAYMENTS_VERIFY_TLS", parse_bool, errors),
timeout_s=load("PAYMENTS_TIMEOUT", parse_seconds, errors, default=10))
if errors: # report every problem at once, then exit before binding a port
print("\n".join(errors), file=sys.stderr); sys.exit(1)
A standard-library Python sketch of the payments section: it parses booleans and durations explicitly, loads a secret from NAME or NAME_FILE, wraps it in a redacting Secret type, requires the security-relevant fields, and collects every error before exiting non-zero.
Testing, CI checks and generated documentation
Because the settings object is a plain typed value, tests should construct it directly rather than mutating the process environment. A test for the billing module builds only the BillingSettings slice it needs, passing explicit values for the fields under test. This keeps tests independent of each other. Setting os.environ in one test and forgetting to restore it is a common source of order-dependent failures. Validation rules get their own tests: feed the loader a deliberately broken input, such as a negative pool size or a missing required URL, and assert that it fails with a message naming the field.
Validating every environment in CI
Store each environment's non-secret configuration in the repository, for example as staging.env and production.env. Add a CI job that runs the same loader the application uses against each file. Secrets can be replaced with typed placeholders, because the job checks structure and constraints, not credentials. A typo in a variable name or an invalid enum value then fails the pull request instead of a deploy.
Generating docs from the schema
The schema already holds names, types, defaults and descriptions, so generate the reference table and an .env.example from it. Have a CI check compare the generated output with the committed files and fail on any difference. The documentation then cannot drift from the code.
This saves time in two places. Misconfigurations surface in review, when they are cheap to fix. New engineers get a complete, accurate list of every setting on their first day, so they do not have to search the code for getenv calls.
# test_billing_settings.py: settings are built directly; os.environ is never touched.
import pytest
from pydantic import BaseModel, Field, HttpUrl, ValidationError
# Normally imported from the billing module; inlined here so the example is self-contained.
class BillingSettings(BaseModel):
gateway_url: HttpUrl = Field(description="Base URL of the payment gateway")
db_pool_size: int = Field(default=5, gt=0, description="Max DB connections")
retry_limit: int = Field(default=3, ge=0, description="Gateway retries per charge")
VALID_URL = ""
def test_retries_can_be_disabled():
# Pass only the fields under test; the other fields keep their schema defaults.
settings = BillingSettings(gateway_url=VALID_URL, retry_limit=0)
assert settings.retry_limit == 0
assert settings.db_pool_size == 5
def error_fields(exc: ValidationError) -> set:
# Collect the field names the validator complained about.
return {err["loc"][0] for err in exc.errors()}
def test_negative_pool_size_is_rejected():
with pytest.raises(ValidationError) as exc_info:
BillingSettings(gateway_url=VALID_URL, db_pool_size=-1)
assert error_fields(exc_info.value) == {"db_pool_size"}
def test_missing_gateway_url_is_rejected():
# model_validate takes a plain dict, the same shape a loader produces from an env file.
with pytest.raises(ValidationError) as exc_info:
BillingSettings.model_validate({"db_pool_size": 10})
assert error_fields(exc_info.value) == {"gateway_url"}
These pytest tests build only the BillingSettings slice they need, with no changes to os.environ, and check that invalid input fails with an error naming the bad field (Pydantic v2).
Migrating from scattered getenv calls
Start with an inventory, because the schema is only as complete as your knowledge of what the code already reads. A recursive search finds the call sites: grep -rnE 'os\.getenv|os\.environ' --include='*.py' . for Python, or grep -rn 'process\.env' src/ for Node. To get a deduplicated list of variable names, extract them and count occurrences: grep -rhoE "getenv\(['\"][A-Z0-9_]+" . | sort | uniq -c. Variables that are read in many places with different defaults are your highest-risk items. Write down each default you find. When two modules disagree, record which one production currently uses, because that behavior is what you must preserve.
Next, add the schema without removing anything. Load it at startup in a non-blocking mode that logs validation failures instead of exiting. Run it this way for at least one full deploy cycle in every environment. This shows you which environments are missing variables or carrying malformed values before fail-fast can turn those problems into outages.
Then migrate one module per change. Swap its direct reads for the settings object, and keep each default identical to the old one. Small diffs are easy to review and easy to revert. Once the logs show no validation warnings, switch the loader to fail-fast.
Finally, lock in the result. Add a lint rule or CI step that runs the same grep and fails if any match appears outside the config loader's file. Without that check, new direct reads will creep back in within a few sprints.
#!/usr/bin/env bash
# CI guard: fail the build if any Python file other than the config loader
# reads the environment directly. Run from the repository root.
set -euo pipefail
# The only file allowed to touch os.environ / os.getenv.
ALLOWED_FILE="./app/config/settings.py"
# Same pattern used for the initial inventory, so the check matches
# exactly what the migration removed.
PATTERN='os\.getenv|os\.environ'
# grep exits 1 when nothing matches, which is the success case here.
# The '|| true' stops pipefail from treating that as an error.
# awk drops lines whose file path (field 1) is the allowed loader.
violations=$(
{ grep -rnE "$PATTERN" --include='*.py' \
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=node_modules . \
|| true; } \
| awk -F: -v allowed="$ALLOWED_FILE" '$1 != allowed'
)
if [ -n "$violations" ]; then
echo "Direct environment access found outside $ALLOWED_FILE:" >&2
echo "$violations" >&2
echo "Add the variable to the settings schema and read it from the settings object instead." >&2
exit 1
fi
echo "OK: environment is only read in $ALLOWED_FILE"
A CI shell check that reuses the inventory grep pattern and fails the build when os.getenv or os.environ appears in any Python file other than the config loader.
Trade-offs and when to avoid this pattern
Fail-fast startup changes when you find out about mistakes. A typo in an optional setting that the old code quietly ignored now stops every replica from booting. That only helps if readiness checks keep bad instances out of rotation and rollback is quick enough to restore the previous release before capacity runs out. Roll out validation in warn-only mode first so you can see what would have failed.
The central schema becomes a merge-conflict hotspot, because every feature touches the same file. Splitting it into per-module sections composed at load time keeps the ownership clear without losing the single entry point.
Load-once, immutable config is the wrong home for values that operators change at runtime, such as feature flags, rate limits and kill switches. Keep those in a separate dynamic store with its own reload semantics. Flat environment variables also handle nested or list-valued data awkwardly. Delimiter conventions and JSON-in-a-variable both work, but both push parsing errors into strings nobody reads closely.
Secrets in env vars can leak:
- Child processes inherit the environment.
- Process inspection tools may show it.
- Crash reporters and debug endpoints often dump it wholesale.
Unsafe defaults are just as risky. A default debug mode or localhost database URL will silently apply in production unless the schema refuses defaults for production-critical fields.
Validation libraries add a dependency and tempt teams into modeling every conceivable constraint. Requiring every variable also slows local setup, so ship a development profile with safe placeholders.
For small scripts, one-off migrations or short-lived tools, a few explicit reads with a clear error message are cheaper and good enough.
Alternatives and impact on cost and engineering time
| Approach | Validation | Change at runtime | Secrets safety | Ops complexity | Cost profile |
|---|---|---|---|---|---|
| Ad-hoc env reads | None until a code path runs | No (restart) | Easy to log by accident | Low | Cheap upfront, rising ongoing cost |
| Typed load-once settings | At startup, all keys | No (restart) | Can mark and redact fields | Low | Moderate upfront, low ongoing cost |
| Mounted structured files | Only if parsed into a schema | Only with reload logic | Depends on file permissions | Medium | Moderate on both |
| Dynamic config / flag system | Per flag, usually loose | Yes | Not designed for secrets | High (another service) | High on both |
| Runtime secret-store lookups | Per call | Yes | Strong (rotation, audit) | Medium to high | Moderate, plus latency and outage risk |
These approaches cover different needs and usually work together. Typed settings hold values that should stay fixed for the life of a process, such as ports, pool sizes and service URLs. A flag system handles values you want to change without a deploy, such as rollouts and kill switches. A secret store supplies credentials that rotate. A common split is to fetch secrets once at boot into redacted fields of the typed object, and keep flags in a separate client so rapidly changing values never pass through startup validation.
Illustrative estimate (assumed numbers, not a benchmark)
Hours saved per year = (incidents × hours per incident × fraction caught at startup) + (new hires × onboarding hours saved)
- Assumed inputs: 6 config incidents per year, 3 hours each, half caught by startup validation, 8 new hires, 3 hours of onboarding saved per hire.
- Incidents: 6 × 3 × 0.5 = 9 hours.
- Onboarding: 8 × 3 = 24 hours.
- Total: 9 + 24 = 33 hours per year.
Under these assumptions, an implementation cost of about 40 hours pays for itself in a little over a year. Replace the inputs with your own figures to see where you land.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith configuration management, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.