I'll Be Backend
Principles · Java

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 Java 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 TicketShop {
    int seatsLeft = 100;
    List<String> receipts = new ArrayList<>();

    String buy(String quantityText, String email) {
        int quantity = reserve(quantityText);
        return issueReceipt(quantity, email);
    }

    int reserve(String quantityText) {
        int quantity = Integer.parseInt(quantityText);
        if (quantity < 1) throw new IllegalArgumentException("quantity");
        if (quantity > seatsLeft)
            throw new IllegalStateException("sold out");
        seatsLeft -= quantity;
        return quantity;
    }

    String issueReceipt(int quantity, String email) {
        receipts.add(email + ": " + quantity + " tickets");
        return "200 ok";
    }
}

class BuyHandler {
    TicketShop shop = new TicketShop();

    String handle(Map<String, String> form) {
        try {
            return shop.buy(form.get("quantity"), form.get("email"));
        } catch (IllegalArgumentException e) {
            return "400 bad request";
        } catch (IllegalStateException e) {
            return "409 sold out";
        }
    }
}

After

record TicketOrder(int quantity, String email) {
    TicketOrder {
        if (quantity < 1) throw new IllegalArgumentException("quantity");
        if (!email.contains("@")) throw new IllegalArgumentException("email");
    }
}

class TicketShop {
    int seatsLeft = 100;
    List<String> receipts = new ArrayList<>();

    String buy(TicketOrder order) {
        reserve(order.quantity());
        return issueReceipt(order.quantity(), order.email());
    }

    void reserve(int quantity) {
        if (quantity > seatsLeft)
            throw new IllegalStateException("sold out");
        seatsLeft -= quantity;
    }

    String issueReceipt(int quantity, String email) {
        receipts.add(email + ": " + quantity + " tickets");
        return "200 ok";
    }
}

class BuyHandler {
    TicketShop shop = new TicketShop();

    String handle(Map<String, String> form) {
        try {
            int quantity = Integer.parseInt(form.get("quantity"));
            TicketOrder order = new TicketOrder(quantity, form.get("email"));
            return shop.buy(order);
        } catch (IllegalArgumentException e) {
            return "400 bad request";
        } catch (IllegalStateException e) {
            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 map 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. A Number Format Exception is a kind of Illegal Argument Exception, so a quantity like abc 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, 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 Ticket Order record. 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. A compact constructor like this runs before the record exists, so an order for zero tickets can never be built. 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 Illegal 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. Java's own Objects dot require non null works this way, because it throws the moment a null comes in, not later where the value is first used.