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

inputownerselected direction
HTTP request or responseroute or shared contractvalidate at the transport boundary.
provider, webhook, or file payloadadaptervalidate provider vocabulary before it reaches domain code.
environment configurationconfiguration boundaryfail startup before partial runtime behavior.
persisted record read outside a trusted migrationpersistence boundaryvalidate one auditable interpretation.
locally produced typed valueexisting ownerdo not duplicate validation without a new trust boundary.

good and bad

bad

// route handler trusts the request body
const 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 boundary
async 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, 3
const 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 ONCE
const 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 contract
return createWorkflowRun(payload);
// Zod schema at the boundary
const CreateWorkflowRunInputSchema = z.object({
sourceIds: z.array(z.string().min(1)), // fails fast if string, not array
title: 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.

search pages

go to any page