← back to the desk

Request validation on the backend

4 September 2026 · 5 min read · #backend #validation #api

written from this video


Here is the failure I keep seeing. A client posts {"name": 0} to an endpoint that creates a book. Nothing checks the payload, so the controller calls the service, the service calls the repository, and the repository runs:

INSERT INTO books (name) VALUES (0);

The column is name text not null. Postgres rejects the insert, the driver throws, the error handler catches something it does not recognize, and the client gets a 500 Internal Server Error. The client did something wrong and the server took the blame for it.

The right answer is a 400, returned before any of that ran. The difference between those two responses is entirely a question of where you check the data.

The check belongs at the entry point

In the layered structure — repository at the bottom, service above it, controller on top — validation sits above all three. After route matching, before the controller does anything meaningful, and definitely before the first service call.

request → route match → validate + transform → controller → service → repository

Everything from the client goes through that gate: the JSON body, the query string, path parameters, headers. All of it is attacker-controlled input until something says otherwise.

The reason to do it here and not deeper is that anything past the gate is business logic, and business logic written against unvalidated input has to defend itself at every line. Once the gate has run, the service layer gets to assume its arguments are the shape it was written for. That assumption is the whole point.

The layer boundary exists so the controller can own HTTP concerns — status codes, payload shape, validation — and the service can own behaviour. Validation living in the service is a sign the boundary has already leaked.

Three kinds of validation

Type validation is the floor. Is age a number, is tags an array, is active a boolean? Most schema libraries give you this for free, and it also nests: an array of strings should reject [1, 2] with an error pointing at element 0, not a generic "invalid array".

Syntactic validation checks structure inside a type. A string that is a valid email has a local part, an @, and a domain with a real TLD. A phone number is a country code followed by however many digits that country uses. A date is YYYY-MM-DD or it is not a date. The value is the right type and still wrong.

Semantic validation checks whether the value makes sense. 1995-06-12 and 2026-06-12 are both syntactically perfect dates. One of them is not a date of birth. Same with age: 43 is fine, 430 is not, and no type or format check catches that. This is where you encode the things a human would notice immediately:

dateOfBirth: date, must be in the past
age:         number, 1..120

The bounds are arbitrary and that is fine. You are not modelling reality, you are drawing a line past which the input is more likely a bug or an attack than a real user.

Then there are cross-field rules, which do not fit the three buckets but show up constantly. passwordConfirmation must equal password. partner is optional when married is false and required when it is true. Any schema library worth using lets you express both.

Transformation is the other half of the pipeline

Say you validate pagination:

page:  number, > 0, < 500
limit: number, > 0, < 10000

And the client calls GET /bookmarks?page=2&limit=20. This fails. Not because the client did anything wrong, but because query parameters are strings. Always. page arrives as "2", your first rule says number, and you reject a perfectly valid request.

The fix is not to loosen the schema to accept strings. It is to cast "2" to 2 before the rules run. That cast is a transformation, and it is the server's job, not the client's.

Transformations also run after validation, normalizing input into whatever the service layer wants:

in:  { "email": "Mudit@Example.COM", "phone": "919876543210" }
out: { "email": "mudit@example.com", "phone": "+919876543210" }

Lowercasing emails, prefixing phone numbers with +, reformatting dates. Each of these is a small decision that has to be made somewhere. Making it in the pipeline means it is made once, in a place you can read, instead of scattered across four service methods that each remembered a different subset.

That is why validation and transformation belong in one pipeline rather than two. Everything that happens to input before business logic sees it lives in a single file next to the route, and the answer to "what does this endpoint actually accept" is one place to look.

The errors are documentation

Post an empty body to a validated endpoint and you get something like:

[
  { "path": "email", "message": "Required" },
  { "path": "phone", "message": "Expected string, received number" },
  { "path": "dateOfBirth", "message": "Date of birth cannot be in the future" }
]

That is a usable description of the contract, produced by the same code that enforces it, and it cannot drift from the implementation the way a README does. It is not a substitute for real docs, but when the docs are stale — and they are — this is what people fall back on.

Frontend validation is not validation

The mistake worth naming explicitly: a form that checks the email field before enabling submit does not mean the server can skip the check.

Client-side validation exists for user experience. It gives immediate feedback so nobody types a whole form, submits, waits, and gets a rejection they could have seen a second earlier. That is all it does.

Server-side validation exists for security and data integrity. And it has to, because the browser is one client out of many. I can open Insomnia and hit the same endpoint directly, where there is no form and no field-level check between me and your handler. Every native app, every script, every integration, every curl command is in the same position.

So write the server-side rules as if no client validates anything, because sooner or later one of them will not.