Lesson 0014 · Core Designer · Module 3
Fail-Fast at the Right Boundaries
The question is never "should we fail?" — it's where. Fail early, at the boundary, loudly, and the error arrives with its address attached.
Mission tie-in: fail-fast completes the Core Designer phase — it's how the domain core protects itself from the messy world around it.
Knowledge: fail where the cause lives
A bad value that travels is a bomb with a delayed fuse: it's written here, read there, and explodes in a third place, hours later, in unrelated code. Fail-fast means the bomb detonates at the moment and place of the cause, where the fix is obvious.
Three boundaries deserve loud failures:
- The input boundary — data enters the system. Validate and refuse before work starts:
def schedule_meeting(request: MeetingRequest) -> Meeting:
if request.end <= request.start:
raise InvalidMeetingRequest("end must be after start")
...
- The invariant boundary — domain rules (lesson 0011) refuse invalid transitions, whoever is asking:
class Meeting:
def cancel(self, reason: str) -> None:
if self._status is Status.CANCELLED:
raise AlreadyCancelled(self.meeting_id) # not a silent no-op
self._status = Status.CANCELLED
The tempting alternative — returning quietly when the meeting is already cancelled — hides a real bug: something in the system believes it is cancelling a live meeting. Silence turns that into a mystery three days later; the exception turns it into a stack trace now.
- The integration boundary — the outside world fails; errors surface as errors, not as
None:
def process_batch(orders: list[Order], gateway: PaymentGateway) -> BatchResult:
results = []
for order in orders:
try:
charge_id = gateway.charge(order.total, order.token)
except PaymentError as e:
results.append(FailedOrder(order.order_id, str(e))) # loud, per item
continue
results.append(PaidOrder(order.order_id, charge_id))
return BatchResult(results)
Notice the shape: each failure is caught at the boundary where it can be handled (per-order), recorded loudly, and reported — versus lesson 0005's AI version that swallowed everything and returned early. The difference between "fail-fast" and "fragile": the first fails loudly at the right boundary; the second fails silently at the wrong one.
The cost of a defect is set by the distance between cause and symptom — measured in call frames, in state transitions, and in wall-clock time. Each transition between the moment a value goes wrong and the moment anyone notices multiplies the space you have to search, because every one of them is a candidate explanation. Once the bad value is persisted, the distance stops being bounded at all: the cause is in a log that has since rotated, in a request nobody can replay. Failing at the cause collapses that search to zero — the stack trace is the diagnosis. Fail-fast is an economic argument about debugging cost, not a moral position about strictness.
Which is exactly why it is not universal. Failing fast trades availability for correctness, and that trade is only good when a wrong answer costs more than no answer. Money, safety, permissions, anything that gets written down: refuse. A recommendations panel, an enrichment lookup, a nice-to-have field on a dashboard: a broken pixel is cheaper than a broken page. And there is one place where naive fail-fast actively inverts: when the failing input will be handed back to you immediately. A poison message on a retrying queue, or a config error at process start under an orchestrator, turns "fail loudly" into a crash loop that takes out the healthy traffic behind it. There the discipline is not to swallow the error but to fail the smallest unit and make the failure countable: dead-letter the message, degrade the feature, emit the metric — so that silence is never mistaken for success.
How a senior designer works the case
A payments API, forty pods, a rolling deploy at 02:00. Startup validates the whole config file strictly and refuses anything it does not recognise — a rule the team adopted after a missing signing key was silently defaulted to empty. Tonight someone ships a typo in an optional feature flag. Every replacement pod fails to boot, the rollout drains the healthy ones, and a service that could have answered every request is down for nineteen minutes.
What they look at first. Not the typo. The question of what the failing check was protecting, and what it took down. Strictness here was not wrong in kind; it was applied at one granularity to two kinds of configuration with opposite risk profiles.
The question they ask. "Does failing here prevent harm, or cause it?" A missing signing key means every subsequent request is processed unverified — failing prevents harm, and must happen before the pod takes traffic. An unparseable flag for a UI experiment means one feature stays off — failing causes harm, and nothing else.
The alternative they reject. The reflex fix, wrapping the config load in try/except and defaulting everything, restores availability by deleting the distinction. It also silently reinstates the original incident: the missing signing key becomes "signature verification disabled", discovered by an auditor rather than by a crash. Trading a nineteen-minute outage for an unbounded security failure is not a trade a senior takes.
REQUIRED = ("database_url", "signing_key", "gateway_url")
def load_config(raw: dict) -> Config:
missing = [key for key in REQUIRED if not raw.get(key)]
if missing: # cannot serve correctly: refuse before traffic
raise ConfigError("cannot start without: " + ", ".join(missing))
flags, rejected = parse_flags(raw.get("feature_flags", {}))
for key, problem in rejected: # optional: degrade, but countably
log.error("ignoring feature flag %s: %s", key, problem)
metrics.config_degraded.labels(key=key).inc()
return Config(raw, flags=flags, degraded=tuple(key for key, _ in rejected))
The consequence over time. The line between "cannot serve" and "nice to have" is now a list in the source, so adding a required key is a visible diff someone argues about, rather than an accident of which parser ran first. Tonight's typo becomes one ERROR line and a tick on a gauge. The subtler win is that the degraded state is observable: a pod running without an optional feature is now distinguishable from a pod running correctly, which is the property blanket try/except destroys and the reason it keeps producing incidents that take days to attribute.
None or empty results at the boundary where the error still means something. Only convert where a default genuinely makes sense — and name that decision.
| You see this | What it costs | The move |
|---|---|---|
except Exception: return None | The caller cannot tell “absent” from “broken”. | Raise; let the caller name the case it handles. |
| if not user: return in a handler | A violated precondition treated as an ordinary path. | Refuse at the door, with the reason attached. |
| a KeyError surfacing three layers up | The value entered the system unvalidated. | Validate at the input boundary, where the cause is. |
| logger.error(...) then continue | Loud in the log, silent in the returned result. | Record the failure in the result you return. |
- "Fail fast means never use a default."
- A default is fine when it is a decision — written down, and visible at runtime. The defect is the implicit default that hides an absence, so that "configured to 30 seconds" and "nobody configured this" produce identical behaviour and identical logs.
- "
assertis how you fail fast." - Assertions vanish under
python -O, and they express programmer beliefs, not runtime contracts. Anything a user, a queue or another service can trigger must be an explicitraisewith a typed error. Keepassertfor "this cannot happen" — and expect it to be absent in production. - "Fail-fast and resilience are opposites."
- They work at different levels. Resilience is built out of components that fail predictably: a unit that fails fast can be retried, isolated or shed by the layer above it, while a unit that limps on corrupted state cannot be contained by anything. Fail fast inside; contain at the boundary.
- "Logging the error counts as failing loudly."
- Only if someone counts the log. If the function still returns a plausible value, the caller proceeds and the log becomes archaeology, read after the customer complains. Loud means the result carries the failure — an exception, or a result type with the failure inside it.
When this is the wrong move
- Best-effort features. Recommendations, enrichment, avatars, telemetry. A failure here should shrink the response, not replace it. Return the page without the panel and count the omission.
- Streaming ingest. One malformed record must not stop the stream. Dead-letter it with enough context to replay, and alert on the dead-letter rate rather than on the record.
- Startup dependencies that are merely late. Crashing because the database is not up yet is reasonable under an orchestrator that restarts you; crashing because it was slow for four seconds at 03:00 is a self-inflicted outage. Bounded retry with a deadline, then fail.
- Anywhere the failure costs more than the wrong answer. Name that judgment explicitly, in the code, next to the fallback — the next reader needs to know it was chosen rather than forgotten.
Skill: which boundary fails how?
A payment fails for one order in a batch of fifty. The batch should:
Returning None from get_user() when the user is missing is:
The input check end <= start belongs at the boundary because:
Practice on your own code
Find a silent failure in your code: a swallowed exception, a return None hiding an error, an early return abandoning work. Decide the correct boundary, make the failure loud, and (if useful) add the per-item handling pattern.
In the lab: the phase-1 checkpoint contains a returns pipeline whose gateway call is wrapped in a bare except Exception that returns an empty result, so a failed refund is indistinguishable from a return with nothing to refund. You decide which boundary owns the failure and make the difference visible in the value that comes back.
Reveal: a worked fix
# before: silent
def load_config(path):
try:
return yaml.safe_load(open(path))
except Exception:
return {}
# after: loud at the input boundary
def load_config(path: Path) -> dict:
return yaml.safe_load(path.read_text()) # errors propagate with their cause
An empty config silently disables features; a raised error tells the operator the config is broken, at startup, where it can be fixed.
- In two sentences, explain to a junior why "fail fast" and "be resilient" are instructions to different layers rather than contradictory advice.
- Find one swallowed exception in code you touched this week. Say what a caller currently cannot distinguish because of it, and who eventually pays for that confusion.
- Assume that swallow stays for a year while the call sites triple. Describe the incident it produces, and why the log will not explain it.
Your win
You can now place failures deliberately: refuse at input, guard invariants, catch-and-report at integration boundaries — and you've completed the Core Designer phase. Take the Phase II checkpoint.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": fail-fast's philosophical home.
- Clean Code, Martin — ch. 7 "Error Handling": why swallowed errors are structural defects.
- Release It!, Nygard — the stability patterns: where loud, early failure protects the whole system.
- Shore, "Fail Fast" (IEEE Software, 2004) — the short primary source that named the practice, including its warning that fail-fast is about making bugs visible, not about crashing more.
- Watch: ArjanCodes YouTube — search "error handling" or "exceptions".
- Next phase: lesson 0015, System Designer.
- Reference: Design Principles — fail fast and its counterpart, define errors out of existence.
Bring a swallowed-error site to your agent-teacher and decide the correct boundary together.