Skip to main content

Validation at the Boundary

advanced28 min readLesson 145 of 169

Parse, don't validate: turn untrusted input into typed values once, at the edge, so the core of your application only ever sees valid data.

Untrusted bytes arrive at your edge; your core logic deserves typed, verified values. The boundary's whole job is that translation โ€” and doing it once.

Parse, don't validate

Anti-pattern: every function re-checks if user is not None because nobody is sure where validation happened. The fix is a boundary function that returns either a clean, typed value or a complete set of problems:

def validate_user(payload: dict) -> tuple[dict, list[dict]]:
    clean, errors = {}, []
    # ... normalize + check every field ...
    return clean, errors   # errors == []  <=>  payload accepted

Inside the application, clean carries a guarantee: every field present, normalized (email lowercased and stripped), typed. No core function ever re-validates. The guarantee is the architecture.

Collect all the errors

A form that reports one error per submit is a form users submit ten times. Validate every field, gather everything, report once. The envelope from the previous lesson carries one entry per problem:

{"error": {"status": 422, "title": "validation_failed",
           "detail": "3 fields invalid",
           "fields": [{"field": "email", "detail": "..."}, ...]}}

Normalization is half of validation

" A@Example.COM " and "a@example.com" are the same address. Normalize before checking (strip, case-fold, coerce numeric strings) so the check sees canonical data โ€” and so equal inputs can't sneak past as unequal.

Fail closed, loudly

  • Unknown fields: reject (they're probably typos of real fields) โ€” or at minimum never trust them.
  • Exceptions at the boundary become 422/400 envelopes, never 500s. A 500 means your bug; a 422 means their input. Blurring that trains clients to ignore your errors.
  • Log validation failures at debug level without the full payload โ€” payloads contain passwords.

The DI seam

Handlers should receive their dependencies (repositories, mailers, clocks) as parameters, not import singletons. That single decision makes the handler unit-testable: the test passes a fake repository and no database exists anywhere. We'll exercise that seam in the checkpoint.

Now practice

Boundary DrillsParse, don't validate: a boundary that collects every problem, and a token bucket whose time is injected.2 challenges ยท ยท ~24 min