Every value that arrives from outside your process is a claim, not a fact. A browser may have a nice form, but a caller can send any bytes they want. Validation decides whether a request is acceptable; transformation converts a useful but messy representation into the form the application expects.
Keeping those ideas separate makes code easier to reason about. “page was sent as the string " 02 "” is a transformation concern. “A user may not create more than five active projects” is a business rule. Both matter, but they belong at different points in the journey.
What belongs near the HTTP boundary
A controller or request-schema layer should handle facts that can be checked from the request itself:
- required fields are present;
- a value has the expected primitive type;
- a string is within a sensible length and matches a format;
- a query parameter can be converted to a positive integer;
- unknown fields are rejected or ignored by an intentional policy.
For a task creation endpoint, that might look like:
{
"title": " Send proposal ",
"dueDate": "2026-11-01",
"priority": "HIGH"
}The boundary layer can trim title, turn the date string into a date value, and verify that priority is one of the allowed values. It should not be the only place that enforces “this project is archived, so new tasks are forbidden.” That rule matters to HTTP requests, a nightly import, and a queue worker alike. Put it in the service or domain model where every entry point must obey it.
A predictable order helps
The order below avoids a surprisingly common bug: rejecting a value before converting it to the representation being checked.
- Parse the wire format, such as JSON.
- Transform safe mechanical values: trim, parse, normalise case, supply documented defaults.
- Validate the request shape.
- Authenticate and authorise the caller where required.
- Apply business rules with current data.
For example, a query string always starts as text. Validate after parsing it:
const page = Number.parseInt(req.query.page ?? "1", 10);
if (!Number.isInteger(page) || page < 1 || page > 10_000) {
throw badRequest("page must be an integer between 1 and 10000");
}Do not use Number(" ") and accidentally accept it as zero. Little details like this become production incidents when an API is public.
Transformation should preserve meaning
Useful transformations include:
- trimming whitespace around a display name;
- lower-casing an email address if that is your product’s identity policy;
- turning
"25"into the number25for a query filter; - applying a default page size;
- converting an ISO timestamp into an instant.
Be cautious with transformations that change what the user meant. Lower-casing a case-sensitive password is wrong. Silently changing an unknown enum to a default hides a client bug. If the server fixes or discards input, make that a deliberate product decision.
Validation is not a security border by itself
Good input validation reduces accidents and narrows attack surface, but it does not replace authorization, parameterized queries, output encoding, rate limiting, or a secure dependency policy.
Two examples make this concrete:
- A perfectly valid UUID does not prove its caller may view that project.
- A string that matches an email regex must still be passed as a parameter to SQL, never concatenated into a query.
// Good: the database receives SQL structure and a value separately.
await db.query("select * from users where email = $1", [email]);Frontend validation is a convenience; backend validation is the rule
Validate in the browser because immediate feedback is pleasant. Still validate on the server because the server owns the data.
Someone can call your endpoint with curl, replay an old request, modify a request in developer tools, or use a client built before a form rule changed. The backend is the last reliable gate before side effects happen.
Return errors callers can act on
Avoid a vague "Invalid input" for every failure. A stable, structured error lets a UI show the right message and lets support diagnose problems without reading logs.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Please correct the highlighted fields.",
"fields": [
{ "path": "title", "message": "Title must be between 1 and 120 characters." },
{ "path": "dueDate", "message": "Use an ISO-8601 date." }
]
}
}Keep the response useful, but do not leak internal implementation details. “Project 42 is archived” can be an appropriate domain message for an authorised member; a raw database exception is not.
A practical boundary contract
For each endpoint, write a small contract before the handler:
| Part | Example for POST /projects/:projectId/tasks |
|---|---|
| Path | projectId is a UUID |
| Body | title required; dueDate optional ISO date; priority one of three values |
| Transform | trim title, default priority to normal |
| Authentication | signed-in user required |
| Authorization | user must be a project member with edit permission |
| Business rule | project must be active; title must be unique within the project |
This is not bureaucracy. It is a compact map of what can go wrong and where each decision lives.
The outcome of validation should be more than “the request passed.” It should be a small, well-shaped command that the rest of the application can use without constantly wondering whether title is missing or page is actually the string "banana".
Cross-field checks and business rules
Not every rule looks at one field. A booking needs endAt to be after startAt; a password-confirmation form needs two values to match; a filter may allow either projectId or organisationId, but not both. These are still request-level validations because they can be checked from the submitted values together.
Business rules need the current world as well. “The due date must be after the project start date” requires loading the project. “No more than three active projects on this plan” requires current account data and careful handling when two requests arrive together. Put those checks in the service and reinforce critical invariants with database constraints or transactions.
It is normal for a request to pass schema validation and still be rejected by the use case. That is not a failure of validation; it means the API distinguished well-formed input from an allowed operation. A clear 422 or 409 response gives clients a chance to explain the difference to the user.