Fail fast: validate at the boundary
Reject bad input at the boundary, before any code changes state.
Watch the narrated video
Every step
Problem
Steps 1–3Refactor
Steps 4–7Payoff
Steps 8–9Recap
Steps 10–11The 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.