
Where should I validate? Everywhere, and for different reasons
Validation is one of those topics that feels boring until something goes wrong in production. Here is how I think about validation across the stack, with real examples from the apps I run.
Peter Oliha
There are two things every software engineer building products needs to take seriously. Yes there are others, but I want these two at the top of the list.
Validation and error handling.
This article is about the first one. I will write a follow up on error handling soon, because the two play well together and a lot of the patterns pair up nicely.
A quick note on the code. I spend most of my time in the NestJS ecosystem, so most of the samples below use class-validator decorators and a few custom constraints I have built up over the years. The patterns translate to whatever you are using (Zod, Yup, Vuelidate, vee-validate) and the point is not the syntax, it is the thinking.
Validation is boring until it isn't
Every "weird bug in prod" story I have ever heard has a missing validator somewhere in the middle of it. A form that accepts an empty string where it should not. A scraper that silently ingests HTML when it expected JSON. A trial that should have ended last Tuesday.
When you build products, they are rarely used the way you imagined. Validation is how you keep the gap between what you designed for and what actually shows up from getting out of hand.
I think of validation as falling into four flavours. They look similar on the surface but serve different purposes, and mixing them up is where I see people get stuck.
The four flavours
1. User input validation
This is what most people think of when they hear validation. Forms. API endpoints. Request bodies. You want the data your users send in to match the shape your app expects.
Here is a small piece of the CreateParentDto from KidzLog. It is the DTO that runs when a new parent is added to a childcare centre.
export class CreateParentDto {
@IsOptional()
@ValidateIf(o => o.email !== undefined && o.email !== null && o.email !== '')
@IsEmail()
email?: string
@IsNotEmpty()
@IsUUID('4', { each: true, message: 'Invalid child id' })
children: string[]
}
Notice the @ValidateIf on email. Email is optional, but if someone provides one it has to be a real email. Without that guard, class-validator runs @IsEmail against empty strings and rejects perfectly valid requests where the user simply left the field blank. Small detail, but those are the details that make the difference between forms that feel helpful and forms that feel adversarial.
The each: true on the children array is another small thing worth flagging. It validates every UUID in the array, not just the first one. If someone sends in five children and the third one is malformed, you want to know about that one specifically.
2. System to system validation
This is validation for data coming from things that are not your users. Webhooks. Scraper output. Third-party APIs. Batch jobs.
Here is a fun one from the Jobven scraper. Each company in the system has a URL normalisation config that decides which query parameters to keep when deduplicating job URLs. The config accepts either the wildcard "*" (keep everything) or an explicit array of parameter names.
@ValidatorConstraint({ name: 'isArrayOrWildcard', async: false })
export class IsArrayOrWildcardConstraint
implements ValidatorConstraintInterface
{
validate(value: any, _args: ValidationArguments) {
if (value === '*') {
return true;
}
if (Array.isArray(value)) {
return value.every((item) => typeof item === 'string');
}
return false;
}
defaultMessage(_args: ValidationArguments) {
return 'allowedQueryParams must be either an array of strings or the wildcard "*"';
}
}
The reason this is interesting is that the usual validator decorators do not compose well to express "array of strings OR the literal string *". Writing a tiny custom constraint gets you exactly the shape you want, with a message that tells future-you (or a teammate) what the rule actually is.
3. Business logic validation
This is the one that catches people. It is not "is the input well formed" but "is this action allowed right now, given the state of the system".
Think permissions. Trial expiration. Quota enforcement. Can this user delete this record? Has this subscription lapsed? Is this employee still active?
A small but good example from FindChildcare.ca is a custom decorator called @IsBiggerThan. Listings have an age range: minimum age and maximum age the centre accepts. The rule is that maxAge has to be greater than or equal to minAge. That is a business rule, not a formatting rule, and it cannot be expressed in the type system.
export function IsBiggerThan(
property: string,
validationOptions?: IsBiggerThanOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isBiggerThan',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName] || 0;
const bothNumbers =
typeof value === 'number' && typeof relatedValue === 'number';
if (validationOptions?.allowEqual) {
return bothNumbers && value >= relatedValue;
}
return bothNumbers && value > relatedValue;
},
},
});
};
}
Now anywhere I need the constraint, it reads naturally:
@IsBiggerThan('minAge', { allowEqual: true })
maxAge: number
Business rules as reusable decorators is one of those patterns I keep reaching for. It pushes the rule into a named thing you can apply everywhere the rule applies, and you stop writing one-off custom validators in the service layer that drift out of sync across endpoints.
Another flavour of business logic validation is deduplication before insert. KidzLog has a parent service that checks whether a parent with the same email already exists in the same centre before creating a new record. If a match is found, it merges the incoming children into the existing parent instead of creating a duplicate.
This is not validation in the class-validator sense. No decorator will do this for you. But it is validation in the deeper sense: you are confirming that the action is allowed given the current state of the system, and you are picking a sensible path when it is not.
4. Ingestion drift
This one deserves its own flavour because it is easy to miss, and scrapers live in the middle of it.
When you ingest data from external sources, the data that validates cleanly today may not validate cleanly in six months. Sites rework their markup. APIs deprecate fields. Vendors silently change response shapes, or start returning null where they used to return strings. The data you stored three months ago may no longer match the data you are ingesting today.
Scrapers are the clearest example because the HTML you parse is not a contract. It is a page rendered for a human, and the owner of that page owes you nothing. They can change class names, reorder fields, move the job description to a different component, or A/B test two layouts and return a different one on every request. Your selectors silently start matching the wrong thing, and your pipeline happily writes "Apply now" into your description column for a week before anyone notices.
The only defence is to treat ingestion boundaries as validation boundaries, even for systems you built. A few things I do in the Jobven scraper:
- Validate the shape, not just the presence. A successful fetch is not a successful scrape. After parsing, check that the fields you expect are there and look roughly right (job title under N characters, description over N characters, URL matches a domain pattern). Anything that does not pass goes to a review queue, not to the database.
- Count attempts, flag the persistent failures. Each job has a
scrapeAttemptCountand amaxScrapeAttempts. If the same job fails to validate more times than allowed, it gets amarkManualReviewflag and stops retrying. You want the system to stop repeating its mistake, and you want a human to see the pile. - Alert on the rate of drift, not individual failures. A single failed scrape is noise. A spike in failures for a specific company is usually a signal that their site changed. I publish failure events to a consumer that aggregates by company and pings Discord when the rate jumps.
- Log the raw shape. Before parsing, log what came in. Not the full content necessarily, but enough to reconstruct what the page looked like when the scrape broke. Drift debugging without the original payload is archaeology.
The same thinking applies to any ingestion boundary. Third-party webhooks, partner APIs, CSV uploads from a client who "cleaned up" their spreadsheet before sending it to you. If you did not author the producer, assume the producer will change. Validate on the way in.
Where should I validate?
This is the question that comes up most, and I expect it to come up more now that people are prompting their way to full apps with LLMs. I keep getting asked "should I validate on the frontend or the backend".
The answer is yes. Both. Also the database. They solve different problems.
Frontend validation is for UX
The frontend validates so your users know immediately when something is wrong. A red "password must be 8 characters" before they hit submit is worth more than a backend 400 response three seconds later. Frontend validation is about keeping the experience tight.
It is also a layer you cannot trust. The browser is a hostile environment. Anyone can disable your JavaScript, modify the request, or hit the API directly. Frontend validation is for the user, not for your data integrity.
Backend validation is for integrity
The backend validates so your data stays coherent. The backend does not trust the frontend. It does not trust other services. It definitely does not trust request bodies that showed up at 3am from an IP in a country you have never shipped to.
Backend validation is where business rules get enforced. The frontend can pretend a trial ended, but the backend is the only place that actually makes that true.
The database is the last line of defence
And then the database is where you put the constraints you would lose sleep over if they were violated. NOT NULL. Foreign keys. Unique indexes. Check constraints. Enums.
Everything above the database is code, and code has bugs. The database is the one layer that catches everything, even the bugs in your validation. If a column should never be null, tell the database. If a value must come from a specific set, tell the database. Belt and braces.
Custom validators as named rules
If you take one thing from all of this, let it be this: treat custom validators as a first-class way to express business rules.
Most teams default to putting rules in services or controllers. Then the same rule shows up in three places, drifts in two of them, and the bug you ship is the case where the drift matters. A named decorator like @IsBiggerThan or @IsUniqueEmail turns the rule into a single source of truth you can apply anywhere it belongs.
This is not a NestJS thing or a class-validator thing. The same pattern works in Zod with .refine(), in Yup with custom tests, in Vuelidate with custom validators. The point is that the rule has a name, lives in one place, and shows up in your code where the rule applies.
Closing
Validation is not about rejecting data. It is about knowing what shape your data is in at every boundary in your system.
Every boundary is different. User input needs one kind of vigilance. System to system traffic needs another. Business rules need a third. And ingestion drift needs the kind of vigilance that does not get written until something has already broken, so it is worth writing early.
The frameworks and libraries do not matter much. What matters is that you treat validation as a real concern, give it names, put it where it belongs, and resist the temptation to let one layer pick up the slack for another. They all have jobs to do.
Next up, I will write about error handling, which is the partner to validation. Validation tells you what should not happen. Error handling tells you what to do when it happens anyway.
Have any questions, want to share your thoughts or just say Hi? I'm always excited to connect! Follow me on Bluesky, LinkedIn or Twitter for more insights and discussions. If you've found this valuable, please consider sharing it on your social media. Your support through shares and follows means a lot to me!
Building a Claude Code Plugin with Hooks
A practical guide to Claude Code's plugin system - hooks, skills, and commands. I built Claude Overflow to learn how it all fits together.
3 Ways to Run NestJS Cron Jobs When Running Multiple Instances
Learn three effective methods to handle cron jobs in a multi-instance NestJS environment. From named instances to database locking strategies.
