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 TicketShop
{
int seatsLeft = 100;
List<string> receipts = new();
public string Buy(string quantityText, string email)
{
int quantity = Reserve(quantityText);
return IssueReceipt(quantity, email);
}
int Reserve(string quantityText)
{
int quantity = int.Parse(quantityText);
if (quantity < 1) throw new ArgumentException("quantity");
if (quantity > seatsLeft)
throw new InvalidOperationException("sold out");
seatsLeft -= quantity;
return quantity;
}
string IssueReceipt(int quantity, string email)
{
receipts.Add($"{email}: {quantity} tickets");
return "200 ok";
}
}
class BuyHandler
{
readonly TicketShop shop = new();
public string Handle(Dictionary<string, string> form)
{
try
{
return shop.Buy(form["quantity"], form["email"]);
}
catch (Exception e) when (e is FormatException or ArgumentException)
{
return "400 bad request";
}
catch (InvalidOperationException)
{
return "409 sold out";
}
}
}After
sealed record TicketOrder
{
public int Quantity { get; }
public string Email { get; }
public TicketOrder(int quantity, string email)
{
if (quantity < 1) throw new ArgumentException("quantity");
if (!email.Contains('@')) throw new ArgumentException("email");
Quantity = quantity;
Email = email;
}
}
class TicketShop
{
int seatsLeft = 100;
List<string> receipts = new();
public string Buy(TicketOrder order)
{
Reserve(order.Quantity);
return IssueReceipt(order.Quantity, order.Email);
}
void Reserve(int quantity)
{
if (quantity > seatsLeft)
throw new InvalidOperationException("sold out");
seatsLeft -= quantity;
}
string IssueReceipt(int quantity, string email)
{
receipts.Add($"{email}: {quantity} tickets");
return "200 ok";
}
}
class BuyHandler
{
readonly TicketShop shop = new();
public string Handle(Dictionary<string, string> form)
{
try
{
int quantity = int.Parse(form["quantity"]);
var order = new TicketOrder(quantity, form["email"]);
return shop.Buy(order);
}
catch (Exception e) when (e is FormatException or ArgumentException)
{
return "400 bad request";
}
catch (InvalidOperationException)
{
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. Int dot Parse throws a Format Exception for a quantity like abc, and the exception filter sends that to 400 bad request too.
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, as its first line. It compiles, 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 sealed Ticket Order record. Its constructor is written out in full, because the checks we move in next need a body to run in. 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 constructor throws before any property is set, so nobody can ever 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 line in Ticket Order's constructor. Reserve and Issue Receipt didn't change at all. A bad email still gets 400 bad request, because the constructor throws an Argument Exception 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, so the inner code never checks input again, and the next input rule is one line 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. In dot NET, Argument Null Exception dot Throw If Null works this way, because it throws the moment a null comes in, not later where the value is first used.