Skip to content
Skip to main content
Novel Systems home
Decision log
D-004August 3, 2026Accepted

One validation path, and schemas live at module scope

Affects: backend/src/controllers/quotes.controller.ts, backend/src/controllers/work-orders.controller.ts, backend/src/routes/quotes.routes.ts, backend/src/routes/work-orders.routes.ts

What was wrong

There were two ways to validate a request, and the codebase used both.

Bodies went through validateBody. Path parameters and query strings were parsed inline with .parse() inside the handlers — four places, two of them declaring the schema inside the handler body. validateParams and validateQuery existed in middlewares/validate.ts and were imported by nothing.

This was found by coverage: validate.ts sat at 70.59% and the uncovered lines were two entire exported functions. The obvious reading is that the middleware was dead code and should be deleted. The correct reading is the opposite — the dead code was the right design and the live code was the defect. .parse() throws a ZodError, and a ZodError escaping a handler was a 500 until D-003. Deleting the unused middleware would have removed the fix and kept the bug.

The decision

Every input to these routers is validated in the route table, by middleware, and every schema is declared at module scope and exported.

Module scope is not stylistic. A schema declared inside a handler body is reachable by nothing except that handler — not the route table, not a test, and not a documentation generator. These schemas are the published contract for these routes, and generating OpenAPI from the Zod definitions rather than maintaining a hand-written file alongside them requires that the definitions be reachable. Four new exports came out of this: approveQuoteParamsSchema, listQuotesQuerySchema, workOrderIdSchema, listWorkOrdersQuerySchema.

Params are validated before body. Both are 422s, but validating the id first means the response names the route problem rather than a field problem the caller would fix without noticing the URL was wrong.

validate.ts went from 70.59% to 100% — not because tests were added to it, but because the code that should always have been calling it started calling it.

The caveat a future upgrade will hit

validateQuery writes its parsed result back with Object.assign(req.query, result.data), which requires req.query to be writable. It is in Express 4 (^4.21.2, pinned). In Express 5 it is a getter, and this line silently stops applying coercions and defaults — limit arrives as the string "25" and the default never lands. An Express 5 upgrade must change this to a separate req.validatedQuery property. Recorded here because the failure mode is quiet: nothing throws, the values are simply wrong.