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.

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 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.

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

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