Validation at the Boundary
The bytes that arrive on the socket are not a request. They are a claim, made by a client the service does not control, that a request is inside them. The boundary is where that claim is parsed into typed values or rejected, exactly once, and everything inside the boundary then deals in a SeatLabel and a HoldRequest, never in strings that might be anything. Stagedoor's first version checked the seat label in four handlers with four slightly different regular expressions and in a fifth handler not at all, and the fifth is the one that wrote 14c into a hold that no scanner could ever match.
The rule has a name, "parse, don't validate," and the difference between the two words carries the topic. A function that validates looks at the data and says yes or no, and the caller is left holding the same untyped data it had before. A function that parses hands back a value that cannot be wrong, and the caller holds a type that is the proof. Every later chapter assumes the second: Chapter 4's domain layer takes typed values, Chapter 5 authenticates a parsed token, Chapter 6 binds a SeatLabel to a query parameter and never re-checks it.
Parse, Don't Validate
validate(body) returns true, and the handler still holds a dictionary. Two lines later it reads body["seat_label"], which is a string, and passes it to a function that has to decide whether to trust it. parse(body) returns a HoldRequest or raises, and the handler holds a value whose seat_label is a SeatLabel that already matched one to three digits followed by one capital letter. No later function checks again, because the type is the record that the check happened. The difference shows up in the code that comes after: with validation, the same regular expression appears in every function that touches the label, because none of them can tell whether the caller ran it; with parsing, it appears once, in the constructor of the type, and every function that takes a SeatLabel is written as if the label is correct, because it is.
The Request Model as the Contract
One request model per endpoint, with types, constraints and defaults, is both the parser and the documentation. FastAPI builds the OpenAPI document of Chapter 10 from the model, so the shape a client reads in the docs is the shape the boundary enforces, by construction rather than by discipline. The demonstrated model is HoldRequest: an event id, a seat label, and nothing else.
SeatLabel = Annotated[str, StringConstraints(pattern=r"^[0-9]{1,3}[A-Z]$")] EventId = Annotated[int, Field(gt=0)] class HoldRequest(BaseModel): model_config = ConfigDict(extra="forbid") # seat_lable is a 422, not a silent no-op event_id: EventId seat_label: SeatLabel async def create_hold(req: HoldRequest, user: CurrentUser) -> HoldResponse: # req.seat_label is a str that matched the pattern; nothing below re-checks it hold = await holds.create(user.id, req.event_id, req.seat_label) return HoldResponse.from_hold(hold)
The model says three things and each one is enforced before the handler runs. The seat label must be one to three digits and a capital letter, so 14c and a label with a trailing space are rejected at the edge. The event id must be a positive integer, so "abc" and 0 never reach a query. And unknown fields are forbidden, which is the line most models leave out: a client that sends seat_lable with a typo would otherwise be told 201 Created for a hold with no seat, silently, and would find out at the door. With extra="forbid" the typo is a 422 naming the unexpected field, and the client's developer fixes it in the first minute of integration instead of the last hour of the on-sale.
What Belongs at the Edge and What Does Not
Shape, type, range, format and required-ness are the edge's. Is it JSON, is event_id an integer, is it positive, does the label match the pattern, is the field present at all. Every one of those can be answered from the bytes alone, in microseconds, before any identity is known and before any connection is taken from the pool. "Is this seat available" and "does this user own this order" are different in kind: they need the database, they depend on who is asking, and their answer can change between the check and the write. They belong in the domain layer of Chapter 4, and they return different codes. A malformed label is a 422 for the client to fix. A seat that is held is a 409 for the client to route around, and Chapter 2 drew that line for exactly this reason.
The request model that queries the database is the tempting mistake. A validator on seat_label that checks availability runs before authentication, because parsing happens first, so an anonymous client can probe which seats are free at zero cost. It holds a pooled connection during parsing, which is the wrong place to hold one under 3,000 requests a second. And it answers a state question with a shape code, a 422 for "seat taken," so the client shows a form error for a fact about the world. Keep the edge to what the bytes can answer.
400 vs 422 and the Error Body
Unparseable bytes are a 400: the body is not JSON, the content type is wrong, the encoding is broken. Parseable bytes that fail the model are a 422: the JSON is fine and the request is not. FastAPI answers a body that is not JSON with a 422 out of the box, so Stagedoor registers one handler that maps the decode failure to 400, because the split is the part a client acts on. The distinction lets a client tell "my serializer is broken" from "my user typed something wrong," and it matters because the two are fixed by different people. The 422 carries a Problem Details body in the shape of Topic 15, with one entry per failing field: the field's path, the constraint it broke, and the value's type if that is the problem. All of the failures at once, not the first one found, so a form with three bad fields round-trips once, and the client highlights three fields instead of showing "invalid request" and guessing.
Trusting the Inside
Once parsed, a value is passed by type through every layer, and no layer checks it again. A SeatLabel is never re-matched against the pattern in the domain layer. A Money is never re-rounded in the storage layer. The boundary is the one place where the outside vocabulary, strings and numbers that might be anything, is translated into the inside vocabulary, types that carry their own proof. A second check deeper in is not defensive programming; it is a sign that the boundary leaked, that somewhere a raw string got in without passing through the parser, and the fix is to find the leak, not to add the check.
The discipline has a cost that is worth naming. Every type is a small class, and a codebase that takes it seriously has forty of them where a loose one has none. The forty are the reason the domain layer can be read without wondering what is in each variable, and the reason the seat label was checked in one place in the redesign instead of four and a half.
The Other Boundaries
HTTP is the obvious edge and not the only one. Payrail's webhook body arrives as bytes from a network Stagedoor does not own, and Chapter 10 parses it with a model and a signature check before anything reads a field. The job payload from the Redis stream was written by the service's own api process, which is why the first worker trusted it as if it were a local function call, and why one job with a malformed order id crashed every worker that claimed it in turn, which is Chapter 8's poison message. The environment at startup is bytes from the shell, and Chapter 4 parses it into a typed config object before the first request. Every place bytes enter the process is a boundary with its own parser, and the one that gets skipped is the one that hurts, because it is the one nobody thought was outside.
Validation rejects what does not fit. A seat label that is not one to three digits and a capital letter is a 422, and the value that is stored is the value the client meant.
Sanitization modifies the input to make it fit: trimming, HTML-escaping, stripping tags. At the edge it is a trap, because the escaped string is now wrong for every consumer except the one it was escaped for. An event title HTML-escaped on input renders correctly on the web page and shows the escaped ampersand, entity and all, in the PDF ticket, the email subject and the scanner's screen.
The rule is to parse strictly at the edge, store the true value, and encode for each output at that output: HTML-escape in the template, bind as a parameter in the SQL, JSON-encode in the response. Each output knows its own escaping; the edge does not.
- Validating in the handler with
ifstatements — the same checks are copied into three handlers, drift into three slightly different regular expressions, and the fourth handler forgets one and writes a lowercase label into a hold. - Accepting unknown fields — a client sends
seat_lableand receives a 201 for a hold with no seat, silently, and the typo is discovered at the door instead of in the first minute of integration. - Domain checks at the edge — a request model that queries seat availability runs before authentication, holds a pooled connection during parsing, and returns a 422 for a fact about the world that should have been a 409.
- Sanitizing instead of parsing — the event title stored with its ampersand already HTML-escaped on input, and every non-HTML output shows the entity.
- Skipping the parser on the "internal" boundaries — the job payload from the stream is trusted as if the process wrote it, and a poison message crashes every worker that claims it, one after another, until the queue is empty of workers.
- Write one strict request model per endpoint with unknown fields forbidden, and let the OpenAPI document be generated from it so the docs cannot drift from the parser.
- Keep shape, type, range and format at the edge with a 422, and domain rules in the domain with a 409, and never let a request model touch the database.
- Pass parsed types inward and delete any check that re-validates a value that already carries its type; the check marks a leak, so find the leak instead.
- Parse every inbound boundary, HTTP, webhook, stream and environment, with the same model discipline, because the one that is skipped is the one that hurts.
- Store the true value and encode at each output, HTML in the template, parameters in the SQL, JSON in the response, and never escape on input.
Knowledge Check
A validate() function returns true for a request body and the handler proceeds. What does "parse, don't validate" say is still wrong with the code after that line?
- The check ran twice, once in the function and once in the handler, doubling the parsing cost
- The handler cannot return a 422, because a boolean result carries no list of failing fields
- The handler still holds untyped data, so every later function must decide whether to trust it
- The function accepted unknown fields, because a boolean validator cannot reject extra keys
Which check belongs in the domain layer rather than in the HoldRequest model, and why?
- Whether the seat label matches the pattern, because regular expressions are slow under 3,000 requests a second
- Whether the event id is positive, because the model cannot compare integers before authentication
- Whether the request carries unknown fields, because the model sees only the fields it declares
- Whether the seat is available, because the answer needs the database and can change before the write
A client sends seat_lable instead of seat_label to POST /holds. What happens with a strict model, and what would have happened without one?
- A 422 naming the unexpected field; without strictness the typo is ignored and nothing says so
- A 400 because the body is malformed; without strictness the server would guess the intended field
- A 422 that suggests the correct field name; without strictness the model would apply the correction itself
- A 409 because the seat cannot be found; without strictness the hold would be created on a random seat
An event title is HTML-escaped when it arrives and stored escaped. Where does the damage show?
- On the web page, where the title now renders with the wrong characters
- In the PDF, the email and the scanner, which show the entity instead of the character
- In the database, which rejects the escaped string as an invalid text value
- At the request model, which fails the pattern check because of the added characters
You got correct