I'll Be Backend
Principles · Python

Fail fast: validate at the boundary

Reject bad input at the boundary, before any code changes state.

11 steps4 chapters~5 min at 1.25×#fail fast#validation#guard clauses#constructors
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–7

Payoff

Steps 8–9

Recap

Steps 10–11

The code: before and after

Before

class SoldOut(Exception):
    pass


class TicketShop:
    def __init__(self) -> None:
        self.seats_left = 100
        self.receipts: list[str] = []

    def buy(self, quantity_text: str, email: str) -> str:
        quantity = self.reserve(quantity_text)
        return self.issue_receipt(quantity, email)

    def reserve(self, quantity_text: str) -> int:
        quantity = int(quantity_text)
        if quantity < 1:
            raise ValueError("quantity")
        if quantity > self.seats_left:
            raise SoldOut()
        self.seats_left -= quantity
        return quantity

    def issue_receipt(self, quantity: int, email: str) -> str:
        self.receipts.append(f"{email}: {quantity} tickets")
        return "200 ok"


class BuyHandler:
    def __init__(self) -> None:
        self.shop = TicketShop()

    def handle(self, form: dict[str, str]) -> str:
        try:
            return self.shop.buy(form["quantity"], form["email"])
        except ValueError:
            return "400 bad request"
        except SoldOut:
            return "409 sold out"

After

class SoldOut(Exception):
    pass


@dataclass(frozen=True)
class TicketOrder:
    quantity: int
    email: str

    def __post_init__(self) -> None:
        if self.quantity < 1:
            raise ValueError("quantity")
        if "@" not in self.email:
            raise ValueError("email")


class TicketShop:
    def __init__(self) -> None:
        self.seats_left = 100
        self.receipts: list[str] = []

    def buy(self, order: TicketOrder) -> str:
        self.reserve(order.quantity)
        return self.issue_receipt(order.quantity, order.email)

    def reserve(self, quantity: int) -> None:
        if quantity > self.seats_left:
            raise SoldOut()
        self.seats_left -= quantity

    def issue_receipt(self, quantity: int, email: str) -> str:
        self.receipts.append(f"{email}: {quantity} tickets")
        return "200 ok"


class BuyHandler:
    def __init__(self) -> None:
        self.shop = TicketShop()

    def handle(self, form: dict[str, str]) -> str:
        try:
            quantity = int(form["quantity"])
            order = TicketOrder(quantity, form["email"])
            return self.shop.buy(order)
        except ValueError:
            return "400 bad request"
        except SoldOut:
            return "409 sold out"

Transcript, step by step

Problem

Step 1: A ticket shop for one concert Before

This is a ticket shop for one concert with a hundred seats, and Buy Handler is its only caller. The handler gets the web form as a dictionary of strings and hands the quantity and the email to buy, which reserves seats and then issues a receipt. Reserve parses the quantity, checks it, and takes the seats. Each exception becomes the HTTP-style status text the handler returns. Calling int on text like abc raises a Value Error too, so that quantity also gets 400 bad request.

Step 2: Reject bad emails Code smell

Support wants receipts sent only to real addresses, so an email without an at sign has to be rejected. The email is first used in issue receipt, so that's where the check goes, right at the top. Mypy is happy, and a bad email now gets 400 bad request.

Step 3: Checked halfway through Code smell

Each input is checked where it's first used, deep inside, so a bad request is found halfway through the work, after earlier lines have already changed state. The quantity is parsed here, checked here, the seats are taken here, and only then is the email checked, here. Send quantity four with the email ann dot example dot com, and you get 400 bad request, but seats left has already dropped by four. No receipt exists for that order, so four seats are gone that nobody bought. And every new rule means hunting for the method that first touches its field.

Refactor

Step 4: Back it out, refactor first Plan

Let's back the email check out. The boundary is where outside data enters the code, and here that's the handler. We'll refactor first, so every input check runs at the boundary before anything changes, and then add the email rule.

Step 5: Parse in the handler Move Statements to Callers

First, the parse moves out of reserve and up into the handler's try block. Buy and reserve now take an int quantity, and reserve no longer hands it back. The parse still runs inside the try, so abc still gets 400 bad request.

Step 6: Name the order Introduce Parameter Object

Next, the quantity and the email always travel together, so we give them one name, a frozen Ticket Order data class. The handler builds the order inside its try, and buy takes it as one parameter. Reserve still takes an int, so it didn't change.

Step 7: Check in the constructor Move Statements into Function

Now the quantity check moves out of reserve and into Ticket Order's constructor. The generated init calls post init before it returns, so the handler can never hold an order for zero tickets. The sold-out guard stays in reserve, because it depends on seats left, and only the shop knows that.

Payoff

Step 8: Add the email rule again Payoff

Now the email rule goes back in, as one check in Ticket Order's constructor. Reserve and issue receipt didn't change at all. A bad email still gets 400 bad request, because post init raises a Value Error inside the handler's try.

Step 9: Why it was easy Payoff

The parse and both input checks now sit at the boundary, in the handler and in the constructor. Seats are taken only after a whole valid order exists, and issue receipt can trust its email. So the form with ann dot example dot com gets 400 bad request, and seats left is still a hundred. Every Ticket Order is valid from the moment it exists, and it's frozen, so the inner code never checks input again, and the next input rule is one check in one place. This is fail fast at the boundary, and it fits here because the handler is the one place form data comes in. Since reserve changes seats left, a whole valid order has to exist before it runs, and then a bad form can never change state.

Recap

Step 10: Where we started, where we ended Before / after

On the left, raw strings went deep into the shop, and reserve parsed and checked them. The email check ran only after the seats were taken. On the right, Ticket Order holds both input checks, and buy only accepts an order that passed them. Reserve keeps only the sold-out guard, right before it changes seats left.

Step 11: When to reach for it Takeaways

Reach for it when data from outside, like a form, a file or an API call, flows into code that changes state. Skip it for a rule that depends on current state, like sold out, because only the shop can check that, so keep it as a guard clause right before the change. You'll spot it by raw strings passed deep into the code, input checks spread over inner methods, or a check that runs after a line changed state. Python's own int works this way, because it raises a Value Error the moment the text isn't a number, so a bad value never travels on.