Skip to content

← The Edge of the System step 5 of 12

Medium End-to-End

The configuration boundary in practice: file, environment, argv, one Config

Every previous item in this track handled one layer. Production has four, and the bugs live in the seams between them: a value set in the file, overridden in the environment by a leftover variable from a debugging session six weeks ago, and nobody can tell you why the service is listening on port 9100.

So the deliverable is not just a Config — it is a Config plus provenance.

def resolve_config(file_table: Mapping[str, object],
                   env: Mapping[str, str],
                   argv: Sequence[str]) -> Resolved: ...

Resolved carries the frozen Config and a sources mapping saying which layer won each field: "default", "file", "env" or "cli". That mapping is the difference between a five-minute diagnosis and an afternoon. It costs you one dictionary.

The precedence order, and why it is that order

defaults  <  file  <  environment  <  command line

Each layer is more specific to this run than the one before it. Defaults ship with the code. The file ships with the deployment. The environment is the container. The command line is this invocation. Reverse any pair and you have built something that cannot be overridden in an incident.

The resolution is per field, not per layer. A file that sets host does not reset port to its default. Implement it as an overwrite in order — the last writer wins, and you record who it was.

The three layers do not have the same shape

This is the part that catches people, and it is why one generic coerce() is the wrong design:

layer what you receive what you do
file (TOML via tomllib) already-typed int, bool, str check the type — a "9000" in the file is a bug in the file
environment always str convert, and reject what does not convert
command line always str convert, same rules

tomllib.load returns dict[str, Any], which passes --strict while being completely unchecked — the exact trap from the tomllib item. Typing the parameter Mapping[str, object] instead of Mapping[str, Any] is what forces you to narrow every value before it reaches the dataclass.

(Two tomllib details worth carrying: it requires binary mode, because TOML is UTF-8 and the parser owns the decoding; and it has no writing support — reach for tomli-w or tomlkit.)

Unknown keys: the rule flips between layers

An unknown key in your config file is a typo — reject it. An unknown variable in the environment is PATH, and rejecting it is absurd.

The reconciliation is a prefix. Every variable you own starts with APP_, so APP_TIEMOUT is unambiguously a typo in your namespace and PATH is unambiguously not yours. Namespacing is what buys back the “unknown key is an error” property in a shared namespace. Do the same on the command line: an unrecognised --flag is an error, never a silent no-op.

Command-line grammar

Keep it small and total:

  • --key=value for every field
  • --verbose / --no-verbose for boolean fields only
  • anything else — a bare --port, a positional argument, an unknown name — raises

Underscores in field names are written as dashes on the command line (--workers, and --some-field for some_field).

Errors

raise ConfigError(layer, key, kind)
# layer in {"file", "env", "cli"}
# kind  in {"unknown", "type", "malformed"}

key is the key as that layer spells ittiemout for the file, APP_TIEMOUT for the environment, --tiemout=30 for the command line. An operator should be able to paste it straight back into the thing they need to fix.

Loading visualization…