What global config sprawl looks like and why it hurts
Global config sprawl in a monolith rarely starts as a decision. It builds up one convenient import at a time, until the settings layer is the most tightly coupled code in the system.
| Symptom | Why it hurts refactoring | Why it hurts testing |
|---|---|---|
| One settings module imported everywhere | Every module depends on it, so a rename changes files across the codebase | Tests patch a shared global and can leak state into each other |
| Env vars read deep in business logic | Inputs are hidden, so you can't see what a function needs from its signature | Tests have to set process-wide variables, which makes them order-dependent |
| Keys with no clear owner | Nobody knows who can safely change or remove a key | You can't tell which keys a test fixture needs, so fixtures copy all of them |
| Dead keys nobody deletes | Every key looks live, so engineers work around it instead of removing it | Fixtures keep stale values that hide real misconfiguration |
All four symptoms share a cause: configuration is organized by where values come from (the environment, a file) instead of who uses them. When a module extraction or ownership change happens, the config doesn't move with the code.
The fix is to make configuration follow module boundaries:
- Owned and namespaced: each module owns a settings object under its own namespace.
- Typed: the object has typed fields and a schema.
- Validated at startup: the process refuses to start if a value is missing or invalid.
- Injected explicitly: the object is passed to the code that needs it, so a module's dependencies on configuration are visible and easy to replace in tests.
How per-module settings work: namespaces, schemas, and the composition root
The mechanism has three parts: a naming convention that mirrors module boundaries, a schema per module, and a single place where everything is assembled.
Namespaces make ownership visible
Every key carries its module's name. Environment variables use a prefix (BILLING_RETRY_LIMIT, SEARCH_INDEX_URL), and config files use a matching section ([billing], [search]). When someone sees SEARCH_TIMEOUT_MS in a deployment manifest or an incident channel, they know which team owns it and where its schema lives. The prefix also limits the blast radius of a rename, because a key can only belong to one module.
Schemas define the contract
Each module declares the settings it accepts: name, type, default, and whether the value is required. The schema lives inside the module, next to the code that reads the values. This lets a module's owners change their settings without editing a shared file. It also lets the loader reject unknown keys under a module's prefix, which catches typos like BILLING_RETRY_LIMT that would otherwise fall back silently to a default.
The composition root assembles everything once
At startup, a single loader reads every source and merges them. For each module, it extracts that module's namespace, validates the values against the module's schema, and builds an immutable settings object. The composition root then passes each object into its module's factories. Modules never read the environment themselves, so a module cannot see another module's keys.
env vars config files secret store
\ | /
+-----------+------------+
|
loader (runs once)
/ \
BILLING_* + [billing] SEARCH_* + [search]
validate vs schema validate vs schema
| |
BillingSettings SearchSettings
| |
billing factories search factories
+-----------+ +--------------+ +--------------+
| env vars | | config files | | secret store |
+-----------+ +--------------+ +--------------+
| | |
+----------------+-----------------+
v
+----------------------------+
| loader (runs once): |
| read all sources, merge |
+----------------------------+
| |
BILLING_* + [billing] SEARCH_* + [search]
v v
+------------------+ +------------------+
| validate against | | validate against |
| billing schema | | search schema |
+------------------+ +------------------+
v v
+------------------+ +------------------+
| BillingSettings | | SearchSettings |
| (immutable) | | (immutable) |
+------------------+ +------------------+
composition root passes each object in
v v
+------------------+ +------------------+
| billing module | | search module |
+------------------+ +------------------+
The composition root runs a single loader that merges all configuration sources, splits the result by module namespace, validates each slice against that module's schema, and passes the resulting immutable settings object into the module's factories.
Implementing typed module settings with fail-fast validation
Start with one settings type per module, owned by that module's package. For billing, BillingSettings might declare currency (string, default "USD"), invoice_due_days (integer, default 30, must be between 1 and 90), tax_api_url (URL, required, no default), and retry_limit (integer, default 3, must be non-negative). Keep these as immutable values with no methods that read the environment. That keeps the type a description of what the module needs. Fetching those values is someone else's job.
Validation belongs to the loader, and it should collect errors rather than throw on the first one. The loader runs at boot, before any server socket opens. It asks each registered module for its schema, resolves BILLING_* and SEARCH_* against those schemas, and appends every failure to a single list: missing BILLING_TAX_API_URL, SEARCH_TIMEOUT_MS not an integer, and so on. If the list is non-empty, the process prints all entries and exits non-zero. One deploy attempt then surfaces every problem. Without this, a monthly invoicing job would be the first code to hit the missing URL, weeks after release.
The composition root passes each validated object into its module through a constructor or factory, such as BillingService(settings, tax_client). Billing never imports the loader and never reads a global.
That separation is what makes testing cheap. A unit test constructs BillingSettings directly with invoice_due_days = 14 and a fake URL, builds the service, and asserts on due dates. It needs no environment variables, no config files, and no cleanup between tests.
import sys
from dataclasses import dataclass
from datetime import date, timedelta
@dataclass(frozen=True) # immutable; never reads the environment itself
class BillingSettings:
tax_api_url: str # required, no default
currency: str = "USD"
invoice_due_days: int = 30 # must be 1..90
retry_limit: int = 3 # must be >= 0
def _int(env, key, default, ok, rule, errors):
raw = env.get(key)
if raw is None: return default
try: v = int(raw)
except ValueError: errors.append(f"{key}: not an integer: {raw!r}"); return default
if not ok(v): errors.append(f"{key}: {rule}, got {v}")
return v
def load_billing(env, errors): # appends failures instead of raising on the first
url = env.get("BILLING_TAX_API_URL", "")
if not url.startswith("https://"): errors.append("BILLING_TAX_API_URL: required https URL")
return BillingSettings(url, env.get("BILLING_CURRENCY", "USD"),
_int(env, "BILLING_INVOICE_DUE_DAYS", 30, lambda v: 1 <= v <= 90, "must be 1..90", errors),
_int(env, "BILLING_RETRY_LIMIT", 3, lambda v: v >= 0, "must be >= 0", errors))
class BillingService: # receives settings explicitly; no loader import, no globals
def __init__(self, settings, tax_client): self.s, self.tax = settings, tax_client
def due_date(self, issued): return issued + timedelta(days=self.s.invoice_due_days)
def boot(env, loaders): # composition root: runs before any socket opens
errors = []
settings = {name: load(env, errors) for name, load in loaders.items()}
if errors: # report every problem from every module in one deploy attempt
print("Invalid configuration:\n " + "\n ".join(errors), file=sys.stderr); sys.exit(1)
return settings # caller wires e.g. BillingService(settings["billing"], TaxClient(...))
def test_due_date_uses_configured_days(): # no env vars, files, or cleanup needed
s = BillingSettings(tax_api_url="", invoice_due_days=14)
assert BillingService(s, tax_client=object()).due_date(date(2026, 1, 1)) == date(2026, 1, 15)
A frozen BillingSettings type, a loader that collects every validation error across registered modules and exits once at boot, constructor injection into BillingService, and a unit test that builds the service from fake settings with no environment setup.
Layering environment config, shared platform settings, and secrets
That isolation holds only if production builds the same objects in a known order. Use one precedence chain, where each layer overrides the one before it:
| Layer | Typical contents | Who changes it |
|---|---|---|
| Code defaults | Safe, environment-neutral values | Module owners |
| Base file | Shared non-secret settings | Module owners |
| Per-environment file | Staging or production differences | Platform team |
| Environment variables | Per-deployment overrides | Deploy tooling |
| Secrets source | Credentials, keys | Secret store only |
Map nested keys to environment variables with one mechanical rule: uppercase the key and join each level with a double underscore. billing.invoice_due_days becomes BILLING__INVOICE_DUE_DAYS. A single underscore stays inside a key name, so the mapping works in both directions. The loader can therefore reject any variable with a known module prefix that matches no field, which catches typos.
The loader should record which layer supplied each value. A config dump command can then print every effective key next to its source, such as env:BILLING__INVOICE_DUE_DAYS or file:production.yaml. When someone asks why staging uses 30 days, this answers the question without anyone reading code.
Keep a small core namespace that holds only the database URL, service name, and log level. Modules receive these values explicitly. They do not reach into a global object to get them.
Secrets stay as typed fields on the same settings objects. They are marked as secret types and filled only from the secrets source. The type's string form returns ***, so logs, exceptions, and dumps redact them by default. Dumps still show the source of each secret, so you can tell where it came from without exposing the value.
import os
class Secret:
"""Typed secret field: str/repr/f-strings always show *** instead of the value."""
def __init__(self, value): self._value = value
def reveal(self): return self._value # explicit call site when the real value is needed
def __repr__(self): return "***"
__str__ = __repr__
PREFIXES = ("CORE", "BILLING") # module namespaces the loader owns
def load(defaults, base, env_file, secrets):
values, sources = {}, {}
# Each layer overrides the previous one, and we record which layer won.
for label, layer in [("default", defaults), ("file:base.yaml", base),
("file:production.yaml", env_file)]:
for path, v in layer.items():
values[path], sources[path] = v, label
# BILLING__INVOICE_DUE_DAYS -> ("billing", "invoice_due_days")
for name, raw in os.environ.items():
if name.split("__", 1)[0] not in PREFIXES:
continue
path = tuple(part.lower() for part in name.split("__"))
if path not in values or path in secrets:
raise KeyError(f"unknown or secret-only config variable: {name}") # catches typos
values[path], sources[path] = raw, f"env:{name}"
for path, v in secrets.items(): # secrets come only from the secret store
values[path], sources[path] = Secret(v), "secrets:store"
return values, sources
defaults = {("core", "service_name"): "billing-api", ("core", "log_level"): "INFO",
("core", "database_url"): None, ("billing", "invoice_due_days"): 14}
os.environ["BILLING__INVOICE_DUE_DAYS"] = "30" # simulates a per-deployment override
values, sources = load(defaults, {}, {("core", "log_level"): "WARNING"},
{("core", "database_url"): "postgresql://app:example-pw@db:5432/app"})
# Provenance-aware dump: effective value plus the layer it came from; secrets print as ***
for path in sorted(values):
print(f"{'.'.join(path)} = {values[path]} [{sources[path]}]")
*A minimal stdlib-only loader that applies defaults, base file, environment file, environment variables and secrets in order, maps double-underscore environment variable names to nested keys and rejects unknown ones, and prints a dump that shows each value's source while masking secrets as **.
Guardrails and an incremental migration from a global settings file
Per-module settings only stay per-module if something checks the boundaries on every build. Code review alone won't catch these violations, because each one looks harmless in a single diff.
Guardrails worth automating
- Ban direct environment reads outside the config layer. A lint rule or a test that scans source files for environment-access calls should fail the build when they appear anywhere except the loader. One stray read skips validation and defaults, and you lose the single place where you can see what the app consumes.
- Stop modules from importing each other's settings. An import-boundary check that allows
billingto import onlybilling.settingskeeps coupling visible. If two modules need the same value, it belongs in shared platform settings, and the check forces that decision. - Generate a key reference. Build a table from the schemas that lists each key, its owning module, type, default, and description. Because it comes from code, it can't drift out of date, and on-call engineers can see who owns a key.
- Flag unused keys. Compare deployed environment config against the schemas. Keys that no schema declares are dead weight or typos.
Migrating incrementally
Start by wrapping existing global keys behind per-module objects without renaming anything. The objects read the old names, so behavior doesn't change. Then move callers one module at a time, and turn on the import check for each module as it finishes. When you introduce new key names, keep the old ones as aliases that log a deprecation warning naming the replacement. Put a removal date on each alias, track it like any other deadline, and delete the aliases once the warnings stop appearing in logs.
# tests/test_config_boundaries.py
import ast
import pathlib
SRC_ROOT = pathlib.Path("src/app")
# Only the config loader may touch the process environment.
ALLOWED = {SRC_ROOT / "config" / "loader.py"}
ENV_NAMES = {"environ", "environb", "getenv"}
def _env_reads(tree):
for node in ast.walk(tree):
# Catches os.environ[...], os.environ.get(...), os.getenv(...)
if (
isinstance(node, ast.Attribute)
and node.attr in ENV_NAMES
and isinstance(node.value, ast.Name)
and node.value.id == "os"
):
yield node.lineno
# Catches `from os import environ` and `from os import getenv`
if isinstance(node, ast.ImportFrom) and node.module == "os":
if any(alias.name in ENV_NAMES for alias in node.names):
yield node.lineno
def test_no_env_reads_outside_config_layer():
violations = []
for path in SRC_ROOT.rglob("*.py"):
if path in ALLOWED:
continue
tree = ast.parse(path.read_text(), filename=str(path))
violations += [f"{path}:{line}" for line in _env_reads(tree)]
# Fail the build with file:line so the fix is obvious in CI output.
assert not violations, (
"Read settings through your module's settings object, not os.environ:\n"
+ "\n".join(violations)
)
A pytest guardrail that parses every source file and fails the build if it finds an environment read anywhere except the config loader.
Who should use it, when to avoid it, and the alternatives
Per-module settings pay off in modular monoliths where several teams share one deployable, or where the settings count has grown past what one person can hold in their head. In those codebases the main cost is unclear ownership: nobody knows who may change a value, or what breaks when they do. Namespaces and per-module schemas answer both questions.
Avoid the pattern in small apps with a handful of settings. If one flat file fits on a screen and a single team owns it, schemas per module add indirection and no safety.
The trade-offs are real:
- Duplication versus coupling. Two modules that both need a timeout can each declare their own copy, which may drift. Or they can share one, which couples their release decisions. Share a value only when the two must change together.
- Cosmetic namespacing. A prefix that any module can still read across gives you labels without boundaries. You need an enforcement mechanism, such as an import-boundary check or giving each module access only to its own section.
- Long prefixes. Keys like
BILLING_INVOICING_RETRY_MAX_ATTEMPTSclutter deployment manifests and invite typos. - Hot reload. Load-once immutable objects conflict with changing values at runtime. Keep the few reloadable values in a separate, explicitly mutable source.
| Approach | Ownership | Validation | Best fit |
|---|---|---|---|
| Single flat config | Implicit, shared | At load, if any | Small apps, one team |
| Global settings singleton | Everyone, everywhere | Central, often partial | Legacy code under migration |
| Ad hoc env reads | None | At first use, often in production | Scripts and prototypes |
| Per-module typed settings | Explicit per module | At startup, per schema | Multi-team modular monoliths |
Impact on development cost and engineering time
The upfront cost lands early. Every module needs a schema and a few lines of wiring in the composition root. Someone has to build and maintain the config explain tool. Every compatibility alias added during migration is debt until someone deletes it. Record a removal date next to each alias in the schema so that stale aliases show up in code review and are not forgotten.
The savings come later and keep recurring. Finding out which value a module is using, and which layer it came from, takes one command instead of grepping env files and deploy manifests. Tests construct settings objects directly, so they need no environment variables. A missing or malformed value fails the deploy instead of becoming an incident. Renaming a key is a validator-checked change, not a search-and-hope.
Illustrative estimate (assumed numbers, not a benchmark):
- Upfront hours = modules × hours per module + tool hours = 20 × 3 + 24 = 84
- Annual savings = (config incidents × hours each) + (debug sessions per year × hours saved each) = (6 × 5) + (48 × 1) = 78
With these assumptions, the work pays back in about a year. Plug in your own incident history.
Fail-fast has a cost too. One bad value in a minor module can block every deploy. A workable policy:
- Required modules always fail startup on invalid settings.
- Optional modules can be turned off with a feature flag. A disabled module's settings are skipped, and startup logs that the module is off.
- Never fall back silently to defaults.
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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.