boundary validation
why
external data is wrong by default; its wrongness is cheapest to prove exactly where it enters. one boundary validation protects every downstream consumer from re-checking input, and turns malformed requests into one typed failure at the front door instead of mysterious behavior deep in services. re-validating internally produced typed values is the complementary risk: duplicated rules that drift apart.
rule
accept untrusted input as unknown at the transport, provider, file,
configuration, or persistence-read boundary. validate it once against the named
consumer contract, then map it into a domain fact only when the two vocabularies
have different stable meanings.
the validation library is replaceable. the trust boundary, one acceptance rule, typed failure, and explicit mapping are not.
decision boundary
| input | owner | selected direction |
|---|---|---|
| HTTP request or response | route or shared contract | validate at the transport boundary. |
| provider, webhook, or file payload | adapter | validate provider vocabulary before it reaches domain code. |
| environment configuration | configuration boundary | fail startup before partial runtime behavior. |
| persisted record read outside a trusted migration | persistence boundary | validate one auditable interpretation. |
| locally produced typed value | existing owner | do not duplicate validation without a new trust boundary. |
good and bad
bad
// route handler trusts the request bodyconst payload = requestBody as CreateWorkflowRunInput;// no validation. if caller sends { sourceIds: "invalid" } instead of an array:// - TypeScript can't catch it (the assertion lies)// - the service tries to iterate on a string// - a runtime error occurs deep in the service, not at the boundaryasync function createWorkflowRun(input: CreateWorkflowRunInput) {// Caller sent sourceIds: "abc123" (string) instead of ["abc123"] (array)for (const sourceId of input.sourceIds) {// iterates chars: a, b, c, 1, 2, 3const source = await repo.findSource(sourceId); // each char fails the query}// Multiple failed queries are logged; eventually the request times out// The HTTP client retries; the whole workflow runs again// No one knows what the real error was because it's buried in service logs}
the assertion skips the acceptance rule, so malformed external data silently
corrupts business behavior: a caller sends { sourceIds: "abc123" } (a typo —
should be an array), the service iterates characters, fetches fail, the request
times out, the client retries on a clean slate and fails the same way. logs show
failed fetches but no boundary error; the audit log sees the workflow created
four times from one request.
good
// route handler validates at the boundary ONCEconst parseResult = CreateWorkflowRunInputSchema.safeParse(requestBody);if (!parseResult.success) {return c.json({error: "invalid_input",details: parseResult.error.format(),},400,);}const payload = parseResult.data; // guaranteed to match the contractreturn createWorkflowRun(payload);// Zod schema at the boundaryconst CreateWorkflowRunInputSchema = z.object({sourceIds: z.array(z.string().min(1)), // fails fast if string, not arraytitle: z.string().min(1),// ...});
the parser accepts unknown, produces one trusted contract, and reports a
typed failure at the boundary that introduced the risk. { sourceIds: "abc123" }
fails immediately with "sourceIds must be an array", the client sees a 400 and
stops retrying, the service never runs, and logs are queryable by error code.
one bad request produces one boundary error, not four duplicate workflows.
verification
exercise representative valid and malformed values at the actual boundary. confirm one acceptance owner, no parallel handwritten transport type, and an explicit mapper wherever external and domain vocabulary differ.