Software Design Reference Core Designer · Protecting the Domain Core

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.

Reading ≈ 9 minPractice ≈ 25 min

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:

  1. 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")
    ...
  1. 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.

  1. 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 principle behind the principle

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.

† The companion rule: never convert errors to 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.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
except Exception: return NoneThe caller cannot tell “absent” from “broken”.Raise; let the caller name the case it handles.
if not user: return in a handlerA violated precondition treated as an ordinary path.Refuse at the door, with the reason attached.
a KeyError surfacing three layers upThe value entered the system unvalidated.Validate at the input boundary, where the cause is.
logger.error(...) then continueLoud in the log, silent in the returned result.Record the failure in the result you return.
What people get wrong after reading this
"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.
"assert is 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 explicit raise with a typed error. Keep assert for "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

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.

Teach it back
  1. In two sentences, explain to a junior why "fail fast" and "be resilient" are instructions to different layers rather than contradictory advice.
  2. 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.
  3. 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

Bring a swallowed-error site to your agent-teacher and decide the correct boundary together.