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 companion rule: never convert errors toNone 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. |
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.
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.
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.
- 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.