What is per-module configuration in a modular monolith, and why does a flat global config break module boundaries?

In a modular monolith, per-module configuration means each module owns a typed settings object bound from its own namespaced section of the configuration, such as billing: or notifications:. The module declares the shape it needs: field names, types, defaults, and constraints. The host application binds each section at startup and validates all of them before serving traffic. Environments differ only in values, never in structure. Development, staging, and production supply the same keys with different contents.

Why a flat global config erodes boundaries

A single flat config, a dictionary of keys any code can read, works until the codebase has a few modules and a few owners. Then three problems appear:

  • Hidden coupling. When the orders module reads SMTP_HOST because it happened to be there, it now depends on notification internals. Nothing in the module's public contract shows that dependency, so a boundary review won't catch it.
  • Unclear ownership. No one can say who decides what TIMEOUT_MS means or which module breaks if it changes. Ownership falls back to whoever last edited the file.
  • Keys that can't be removed. Any module might read any key, so deleting one is a guess. Teams stop guessing, and dead keys pile up across every environment.

Namespacing plus types makes each dependency explicit and each removal checkable by the compiler or a startup validator.

The pattern pays off most for teams running several modules across multiple environments with more than one owning team. In that setup, configuration drift and accidental cross-module reads cost real debugging time.

How does typed, isolated module configuration work?

At startup the host builds one configuration tree from several sources. Each later source overrides the one before it. This order is documented and fixed, so anyone can answer "where did this value come from?" by checking the layers from the top down. The order is: code defaults, base file, per-environment file, environment variables, then the secret store.

Layer (lowest to highest precedence) What belongs there Who edits it
Code defaults Safe values that work in local development, such as timeouts and page sizes Module owners, through code review
Base file Settings shared by every environment, such as feature defaults and retry policy Module owners
Per-environment file Real differences between environments, such as hostnames and pool sizes Module owners and the platform team
Environment variables Values injected per deploy, such as region and replica-specific overrides Platform team and the deploy pipeline
Secret store Credentials, API keys and signing keys Security and platform teams only

The host then splits the merged tree by top-level namespace, for example billing, notifications and platform. It binds each section to that module's settings type and validates the result. A module that fails validation stops startup. The error names the namespace and the key that failed.

Each module receives only its own settings object. It has no handle to the full tree, so the billing module cannot read a notifications key, even by accident. If billing needs to know whether email delivery is enabled, it asks the notifications module through that module's public API. The owning module keeps control of how its settings are interpreted. It can rename or restructure its keys without breaking other modules, because no other module depends on their shape.

 Sources, lowest -> highest precedence (each overrides the one before)
 +----------+  +---------+  +----------+  +----------+  +----------+
 |  Code    |->|  Base   |->| Per-env  |->| Env vars |->|  Secret  |
 | defaults |  |  file   |  |   file   |  |          |  |  store   |
 +----------+  +---------+  +----------+  +----------+  +----------+
                                  |
                                  v
            +-------------------------------------------+
            | Host: merge layers into one config tree   |
            | then split by top-level namespace         |
            +-------------------------------------------+
                |                 |                 |
             billing        notifications       platform
                v                 v                 v
        +---------------+ +---------------+ +---------------+
        | bind type +   | | bind type +   | | bind type +   |
        | validate      | | validate      | | validate      |
        +---------------+ +---------------+ +---------------+
                |                 |                 |  invalid: stop startup
                v                 v                 v  (error names ns, key)
        +---------------+ +---------------+ +---------------+
        | Billing       | | Notifications | | Platform      |
        | module        | | module        | | module        |
        | own settings  | | own settings  | | own settings  |
        +-------+-------+ +-------+-------+ +---------------+
                |                 ^
                +-- public API -->+
          billing asks: is email delivery enabled?

The host merges five configuration layers in a fixed order, splits the result by namespace, then binds and validates each section so every module receives only its own typed settings; billing reaches notifications only through that module's public API.

How do you implement typed settings, binding, and fail-fast validation per module?

Take a billing module as the example. It owns one settings type, BillingSettings, with fields such as gateway_url, request_timeout_ms (default 5000), max_retries (default 3), retry_backoff_ms (default 200), and api_key. Choose defaults that are safe when nobody overrides them: short timeouts, few retries, features off. An omission then degrades behavior conservatively and never makes it more aggressive.

Binding from layered sources

The composition root reads the billing section of the base file, applies the per-environment file (for example production) on top, and binds the merged result to BillingSettings. The binder should record where each value came from, so it knows that request_timeout_ms was set in config/production and max_retries came from the base file.

Validating before anything starts

Validate every module's settings during startup, before you open listeners or start workers. Check that required fields are present, numbers fall within ranges, and URLs parse. Then check cross-field rules: max_retries × retry_backoff_ms must stay below request_timeout_ms, or retries can never complete. Collect all failures and report them together. Each message should name the module, the key, the offending value, and its source, for example: billing.request_timeout_ms = -1 (from config/production): must be between 100 and 60000. With that information the operator can fix the file without reading code.

Injection and secrets

Pass BillingSettings into the billing service's constructor. Never pass the raw configuration tree. The constructor signature then documents exactly what the module depends on, and tests can build the object directly without loading any files.

Declare api_key as a secret reference, such as a vault path or environment variable name, and resolve it at startup. The settings type should redact it in toString and in log output. Committed environment files contain only the reference, never the secret value.

from dataclasses import dataclass
from urllib.parse import urlparse

@dataclass(frozen=True)
class BillingSettings:
    gateway_url: str                 # required: no safe default exists
    api_key_ref: str                 # secret *reference* (e.g. vault path), never the key
    request_timeout_ms: int = 5000   # safe defaults: short timeout, few retries
    max_retries: int = 3
    retry_backoff_ms: int = 200

def bind(layers):
    # Later layers override earlier ones; remember which file set each key.
    merged, sources = {}, {}
    for source, tree in layers:
        for key, value in tree.get("billing", {}).items():
            merged[key], sources[key] = value, source
    return BillingSettings(**merged), sources

def validate(s, sources):
    errors = []  # collect everything, report once
    def fail(key, msg):
        errors.append(f"billing.{key} = {getattr(s, key)!r} "
                      f"(from {sources.get(key, 'default')}): {msg}")
    url = urlparse(s.gateway_url)
    if url.scheme != "https" or not url.netloc: fail("gateway_url", "must be an https URL")
    if not s.api_key_ref.startswith("vault:"): fail("api_key_ref", "must be a vault: reference")
    if not 100 <= s.request_timeout_ms <= 60000: fail("request_timeout_ms", "must be between 100 and 60000")
    if not 0 <= s.max_retries <= 10: fail("max_retries", "must be between 0 and 10")
    if s.max_retries * s.retry_backoff_ms >= s.request_timeout_ms:  # cross-field rule
        fail("retry_backoff_ms", "max_retries * retry_backoff_ms must be < request_timeout_ms")
    return errors

# Composition root: bind, validate, then inject; nothing starts on failure.
base = {"billing": {"gateway_url": "", "api_key_ref": "vault:billing/api_key"}}
prod = {"billing": {"request_timeout_ms": -1}}
settings, sources = bind([("config/base", base), ("config/production", prod)])
if errors := validate(settings, sources):
    raise SystemExit("\n".join(errors))
# billing = BillingService(settings)  # constructor gets typed settings, not the raw tree

A typed BillingSettings bound from base and production layers with per-key source tracking, validated at startup with module/key/source error messages before the settings object is injected.

How do you keep environments shape-identical and expose the effective config to operators?

Every environment should bind against the same settings types. Dev, staging, and production differ only in values, never in which keys exist. If the billing module declares a required gateway.timeout, a production deploy that omits it fails validation at startup instead of on the first payment at 3 a.m. This guarantee only holds if code never branches on the environment name. A check like "if production, read this extra key" creates a key that dev and staging never exercise. Express the difference as a value instead, such as a feature flag or a URL, so all three environments share one shape.

Nested keys in environment variables

Environment variables are flat, so nested keys need a separator. Some libraries use a double underscore (BILLING__GATEWAY__TIMEOUT). Others accept a single underscore with relaxed, case-insensitive matching. Check how your binder maps separators before standardizing on a convention. A variable that doesn't map to any key is silently ignored. Turn on unknown-key detection for each module prefix, so a typo like BILLING__GATEWAY__TIMOUT fails the boot instead of leaving the default in place.

Showing the effective config

Operators need to see what the process actually resolved. At startup, log each module's settings with secrets redacted, along with the source that supplied each value: default, file, environment variable, or secret store. A diagnostic endpoint can expose the same view on demand.

This output carries leak risk. If one secret field isn't marked for redaction, it ends up in log aggregation. If the endpoint is reachable without authentication, it hands out a map of your infrastructure. Redact by default, use an allowlist for the fields you display, and restrict the endpoint to internal networks or authenticated operators.

import json, logging, os
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class Gateway(BaseModel):
    url: str
    timeout: float          # no default: a missing value fails at boot in every env
    api_key: SecretStr      # SecretStr renders as '**********' when dumped

class BillingSettings(BaseSettings):
    # BILLING__GATEWAY__TIMEOUT -> gateway.timeout (same shape in dev/staging/prod)
    model_config = SettingsConfigDict(env_prefix="BILLING__", env_nested_delimiter="__")
    gateway: Gateway
    retry_limit: int = 3

def known_env_names(prefix, model):
    names = set()
    for field, info in model.model_fields.items():
        t = info.annotation
        if isinstance(t, type) and issubclass(t, BaseModel):
            names |= known_env_names(f"{prefix}{field.upper()}__", t)
        else:
            names.add(f"{prefix}{field.upper()}")
    return names

# Fail the boot on typos like BILLING__GATEWAY__TIMOUT instead of ignoring them.
known = known_env_names("BILLING__", BillingSettings)
unknown = sorted(k for k in os.environ if k.upper().startswith("BILLING__") and k.upper() not in known)
if unknown:
    raise SystemExit(f"Unknown billing settings: {unknown}")

settings = BillingSettings()  # raises ValidationError if a required key is missing

# Effective-config log: redacted values plus which source won for each key.
upper_env = {k.upper() for k in os.environ}
sources = {k: ("env" if k in upper_env else "default") for k in sorted(known)}
logging.basicConfig(level=logging.INFO)
logging.info("billing config %s", json.dumps({
    "values": settings.model_dump(mode="json"),
    "sources": sources,
}))

A billing module's settings validated with pydantic-settings using a double-underscore nested delimiter, rejecting unknown BILLING__ environment variables at boot, and logging the resolved config with secrets redacted and the winning source per key.

How should you test and evolve module configuration without breaking deploys?

Configuration needs three layers of tests, because each catches a different class of failure.

Unit tests construct settings objects directly. A module's logic should accept its typed settings as a plain value, so a test can build one with exactly the values it needs. No files or environment variables are involved. This keeps tests fast and makes each test's assumptions visible.

One test loads every environment's config through the real binder and validator. Loop over each environment's files, bind them the same way production startup does, and assert that validation passes. A typo in the staging overrides then fails in CI instead of during a deploy.

A key check flags unused or unknown keys. Binders usually ignore keys that match no property. A misspelled key silently falls back to a default, and a key left over from removed code lingers forever. Compare the keys in the config against the keys the schemas declare, and fail the build on any difference.

Isolation needs its own enforcement. The type system won't stop a developer from injecting the whole config root and reading another module's section. Add an architecture test that forbids modules from depending on the root configuration type, or a review rule with the same effect.

Treat a module's schema as part of its public contract. To rename a key, run a deprecation window. During that window the binder accepts both names, prefers the new one, and logs a warning when the old one appears. Remove the old name only after every environment has migrated, and use the key check to prove the migration is complete.

# test_config.py: one schema per module; every environment file is checked in CI
import json, warnings
from pathlib import Path
import pytest
from pydantic import BaseModel, ConfigDict, model_validator

class BillingSettings(BaseModel):
    # extra="forbid" makes misspelled or leftover keys fail instead of silently defaulting
    model_config = ConfigDict(extra="forbid")
    ledger_url: str
    invoice_retry_limit: int = 3

    @model_validator(mode="before")
    @classmethod
    def accept_deprecated_name(cls, data):
        # Deprecation window: accept old "retry_count", prefer the new name, warn on use
        if isinstance(data, dict) and "retry_count" in data:
            warnings.warn("billing.retry_count is deprecated; use invoice_retry_limit", DeprecationWarning)
            data = dict(data)
            old = data.pop("retry_count")
            data.setdefault("invoice_retry_limit", old)
        return data

SCHEMAS = {"billing": BillingSettings}  # in a real app, reuse the startup binder's registry
ENV_FILES = sorted(Path("config").glob("*.json"))  # dev.json, staging.json, prod.json

def test_unit_settings_built_directly():
    # Unit tests build the typed value; no files or env vars involved
    s = BillingSettings(ledger_url="", invoice_retry_limit=1)
    assert s.invoice_retry_limit == 1

@pytest.mark.parametrize("path", ENV_FILES, ids=lambda p: p.stem)
def test_every_environment_binds_and_validates(path):
    raw = json.loads(path.read_text())
    unknown = set(raw) - set(SCHEMAS)  # whole sections no module declares
    assert not unknown, f"{path.name}: unknown sections {unknown}"
    for section, schema in SCHEMAS.items():
        with warnings.catch_warnings():
            warnings.simplefilter("error")  # after migration, any deprecated key fails the build
            schema.model_validate(raw.get(section, {}))

A pytest suite using Pydantic v2 that builds settings directly in unit tests, validates every environment file against each module's schema, rejects unknown keys and sections, and accepts a renamed key temporarily with a deprecation warning that CI turns into a failure once migration is done.

When should you avoid typed per-module settings, and what are the trade-offs?

When the pattern is overkill

If the whole application reads a dozen settings, such as a database URL, a log level and a couple of API keys, per-module types, binders and validators mostly add files. One settings object validated at startup gives you the same fail-fast guarantee. Module isolation pays off when modules have separate owners and enough keys that collisions and accidental coupling are real risks. Before that point, it is ceremony.

Trade-offs to accept deliberately

Strict validation can block unrelated deploys. If a rarely used export feature needs an SFTP credential and one environment lacks it, fail-fast refuses to start the entire process. That process also serves checkout. You can validate only when the feature is enabled, but then you have reintroduced the risk you set out to remove: a misconfiguration can sit unnoticed until someone flips the flag. If you take this route, keep the gated set small, and make the startup check report gated settings that are disabled but incomplete, so operators can see what would break before they enable the feature.

The shared platform section drifts. "Platform" config is the easiest place to put anything that doesn't obviously belong to one module. Without review, it turns back into the global grab-bag. Hold it to genuinely cross-cutting concerns, such as telemetry, process limits and shared infrastructure endpoints, and move a key out as soon as only one module reads it.

Bound settings are static. Values bound at startup don't change until restart. Feature flags, rate limits or kill switches that must change at runtime need a separate path. That path has its own source, its own refresh semantics, and defined behavior when a refresh fails. Don't retrofit hot reload onto startup-bound objects that code assumes are immutable.

What are the alternatives, and how does this approach affect development cost and engineering time?

The alternatives differ mainly in where a misconfiguration surfaces and who can tell which module owns a key.

Approach Isolation Safety Upfront cost Debugging effort
Flat global config None. Any module reads any key Typos and missing keys appear at first use Lowest High. Ownership has to be inferred by grepping
Raw env vars read ad hoc None. Reads are scattered through the code Parsing errors show up inside requests Very low High. There is no inventory of what is read
Single typed global object Weak. Typed, but every module sees every field Fails at startup Low to moderate Moderate. Values are visible but have no owner
Central dynamic config / flag service Depends on naming discipline Changes at runtime with no deploy gate High (service, client, failure modes) Moderate. State lives outside the deploy
Typed per-module settings Strong. Each module binds only its own section Fails at startup, before traffic Moderate. One class and validator per module Low. Effective config is shown per module

The per-module approach costs more code up front. You write a settings type, binding, and validation rules for every module. Each later change gets cheaper in return. A bad value stops the deploy instead of failing in production on a code path that runs once a week. Every key has exactly one owner. Removing a key becomes a compile error or a startup failure, not a silent no-op. During an incident, operators read the effective config for the suspect module and skip tracing environment lookups through the codebase.

A dynamic config service does not replace this pattern. It handles the runtime-mutable values described above, and both can coexist.

When to adopt it

  • Adopt when more than one team works in the codebase, when modules have distinct owners, or when you have had incidents caused by a missing or misread setting.
  • Defer for a small codebase with one team and a handful of keys. A single typed object is enough there. Split it by module once the sections start to tangle.

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 modular monolith configuration, 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 .env by 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.