Lesson 0029 · Master Designer · Module 1
Configuration vs Convention
Every config knob is a promise that the system varies in that way. Convention says "we already decided" — and removes a class of decisions. Choose by measuring real variation.
Mission tie-in: the third Complexity Trade-Off — deciding where the system stays open and where it decides for you.
Knowledge: knobs are promises
Configuration — the system exposes a setting, an env var, a parameter. The cost: every knob must be documented, validated, tested, and understood; every combination is a potential state the code must survive.
Convention — the system decides: "tables named by the model", "endpoints in api/", "config from config.py". The cost: the decision is invisible; newcomers must learn the convention; and when the convention doesn't fit, you fight it.
The two forces: explicit flexibility (configuration) against predictable defaults (convention). The unit of decision: the setting — one knob at a time, decided on its own evidence. "We are configuration-driven" is not a decision, it is forty decisions nobody made.
The mechanism is combinatorial. Ten boolean knobs describe 1,024 reachable systems; you test three of them. A configuration option is therefore not a feature — it is a promise to support a family of systems, and you only ever collect on that promise during an incident, when the running configuration turns out to be one of the 1,021 nobody exercised. Convention removes a decision from the world, and that, not the typing saved, is its value: one less thing that can differ between two environments at three in the morning.
Where it flips. Convention wins when you control the whole stack and the population of users is your own team. It stops applying when your users are outside your building — in a library or a platform, your convention is somebody else's constraint, and the seam you refuse to expose becomes the fork they maintain. It also stops applying when an operator must change behaviour without a deploy: a log level, a kill switch, a rate limit. There the knob is bought with minutes of recovery time, which is the strongest case any knob can make for itself.
The deciding question is measured variation:
# convention: one decision, made once, everywhere
class OrderService:
TIMEOUT_SECONDS = 30 # the team decided; no knob to mis-set
# configuration: real, measured variation — per deployment, per tenant
class OrderService:
def __init__(self, timeout: timedelta, retries: int) -> None:
self._timeout = timeout # differs between prod and a slow partner
The Twelve-Factor rule gives the boundary: settings that vary between deploys (credentials, hosts, feature flags) are configuration. Settings that don't vary are convention — hardcode the decision, don't expose a knob. And the framework lesson (Rails' "convention over configuration"): when you control the whole stack, conventions buy productivity for the 80% case; configuration exists only where the 20% genuinely diverges.
Worked example: forty-one settings and one that mattered
A payments service carries 41 settings. Nobody proposes deleting any, because each one might be needed.
A senior designer does not open the settings file. They dump the effective configuration from production, from staging, and from each tenant, and diff them — because the file is an inventory of possibilities and the diff is evidence about reality. The result: 34 of the 41 are identical everywhere. Of the seven that differ, three differ by accident, left over from hotfixes nobody reverted.
The question they ask next is answered from the config repository's history, not from opinion: "which of these has a human ever deliberately changed, and what happened when they did?" The git log answers in five minutes what a meeting would not settle in an hour.
The tempting response is to document all 41 and add validation. It is rejected because it addresses the wrong risk. Documentation makes a knob explicable and validation makes a bad value fail loudly, but neither shrinks the state space, and the incidents come from combinations of individually valid values that nobody ran together.
Here is what one of those combinations cost. CONNECTION_POOL_SIZE was 5 in one region and 50 everywhere else — drift from a two-year-old hotfix, never intended as a variation. A traffic spike queued checkout requests behind the small pool; p99 latency went from 240 ms to about 9 s for 38 minutes. At roughly 1,900 checkouts an hour and an observed 12% rise in abandonment, that is about 145 lost checkouts at a €62 average basket: near €9,000 of revenue, plus the incident review, plus the two days spent proving it was not the database. The knob had never been deliberately varied. It was a convention wearing a knob's clothes, and the disguise is what made the diagnosis slow.
What ships: delete the 34, keep the 7, give the survivors a typed settings object that fails at startup rather than at first use, and log the effective non-default settings on boot so the next incident responder can see the actual configuration in one line instead of inferring it.
# config.py — the knobs that survived, and the defaults that are conventions
@dataclass(frozen=True)
class Settings:
database_url: str # varies per deploy: genuine configuration
log_level: str = "INFO" # varies during incidents: earns its knob
pool_size: int = 20 # a default that IS the team's decision
@classmethod
def from_env(cls, env: dict[str, str]) -> "Settings":
try:
url = env["DATABASE_URL"]
except KeyError:
raise ConfigError("DATABASE_URL is required") from None
return cls(
database_url=url,
log_level=env.get("LOG_LEVEL", "INFO"),
pool_size=int(env.get("POOL_SIZE", 20)),
)
def log_effective(settings: Settings) -> None:
defaults = Settings(database_url=settings.database_url)
changed = {f.name: getattr(settings, f.name) for f in fields(Settings)
if getattr(settings, f.name) != getattr(defaults, f.name)}
logger.info("effective non-default settings: %s", changed or "none")
One more knob deserves naming: the feature flag. A flag is configuration with a scheduled death — it exists to carry a transition and it should be removed when the transition ends. Without a removal date it becomes a permanent branch in the code and a permanent row in the test matrix, and the branch that was never meant to survive is the one running in production two years later.
† The anti-pattern is the config graveyard: knobs nobody varies, kept "just in case" — every one is a YAGNI (lesson 0002) wearing env-var clothes. Grep for unused settings; delete them.| The setting | Verdict | Because |
|---|---|---|
DATABASE_URL | configuration | It differs in every environment — measured variation. |
MAX_PAGE_SIZE = 100 | convention | One number the team chose; nobody has ever varied it. |
LOG_LEVEL | configuration | Operators change it during an incident, without a deploy. |
TEMPLATE_DIR = "templates/" | convention | A knob that has never been turned in the project’s life. |
ENABLE_NEW_CHECKOUT | configuration, with an expiry date | A flag is a transition (lesson 0035), not a permanent setting. |
- "Configuration is free — it's just a variable."
- Each option multiplies the reachable configuration space and adds a line to every incident's "what is different here?" investigation. The cost is not the variable; it is the combinations nobody tested and the diagnosis time they buy.
- "Making it configurable means we can decide later."
- It means you decided never to decide, and delegated the decision to whoever edits the YAML at two in the morning. A knob without a documented owner and a tested range is an unowned decision.
- "Convention just means hardcoding, which is bad practice."
- A named constant in one place with the reason beside it is a decision anyone can find and change. An environment variable read in nine places is the same decision, diffused, with no single place to read it.
- "Feature flags are configuration."
- They are temporary configuration with an owner and a removal date. A flag older than the feature it guarded is a permanent, rarely exercised branch — and the untested path in a two-year-old flag is where the interesting outages live.
When this is the wrong move
Expose the knob anyway when your users are not your team: a library or platform that hardcodes your convention forces its users to fork it, and the seam you saved costs them a maintenance burden. Expose it for genuinely per-tenant behaviour. And expose it for incident controls — kill switches, log levels, rate limits, circuit-breaker thresholds — where the knob's value is measured in recovery time rather than in variation. A knob that has never been turned but can be turned in thirty seconds during an outage is earning its keep in a currency the variation test does not measure.
Skill: knob or decision?
A value that never varies across deployments should be:
The strongest argument for keeping a knob you almost never turn is:
A config graveyard forms when:
Practice on your own code
List the config settings in your system. For each, answer: does it vary per deployment or per tenant? If it doesn't, convert it to a convention — hardcode the decision and delete the knob. Grep for settings read nowhere and delete those too.
In the lab. The phase-3 checkpoint has you diff the effective configuration of the returns-resolution service across its environments, delete every setting that has never varied, and give the survivors a typed settings object that fails at startup.
Reveal: a graveyard audit
An app exposed MAX_PAGE_SIZE (never varied), RETRY_DELAY (varied between prod and staging), and FEATURE_X_ENABLED (dead). Audit: MAX_PAGE_SIZE became a constant; RETRY_DELAY stayed config, now validated at startup; the dead flag was deleted. Settings count: 14 → 5, and the survivors all varied.
- Explain to a junior, in two sentences, why a knob is a promise rather than a convenience.
- Find one setting in your own system this week that has never been varied. Name its cost: what would it add to the next incident's "what is different here?" list?
- Predict what your settings file looks like in a year at the current rate of additions — and which of today's flags will still be there.
Your win
You can audit any setting with the measured-variation test, convert non-varying knobs into conventions, and clear the graveyard.
Read and watch deeper
- The Twelve-Factor App — Config: the canonical statement of what belongs in configuration.
- "Convention over Configuration", The Rails Doctrine — the strongest statement of the trade, from the framework that made it a movement.
- "Feature Toggles", Pete Hodgson on martinfowler.com — the taxonomy of flags by lifespan, and why a toggle without a removal plan becomes permanent configuration.
- Software Architecture in Practice, Bass et al. — configurability as a quality attribute with a price.
- Watch: ArjanCodes YouTube — search "configuration" or "12 factor".
- Next: lesson 0030 — Generality vs Specificity.
- Reference: Trade-offs — configuration vs convention; glossary — config graveyard.
Bring your settings inventory to your agent-teacher and run the variation test on every knob.