When building APIs, you must validate your inputs and outputs. If your API accepts any data, failure is only a matter of time. Instead of adding more ifs, use declarative validation: NestJS + class-validator + class-transformer, an async constraint that queries your DB, and a custom decorator integrated with the container. Result: errors stopped before the service layer and reusable rules across all your DTOs, with less duplicated logic.
In this tutorial, I'll teach you how to build your own custom decorator to prevent these scenarios.
Setting Up the Project
Let's start with a base project. I have a template repository with a RESTful API using NestJS as the framework, PostgreSQL as the database, and Drizzle as the ORM—but you can create your own.
This is the repo I'm using: https://github.com/RubenOAlvarado/nestjs-drizzle-template This also includes Swagger integration and a Docker Compose file for your own use.
With the repository set up and the API running, the next natural step is to model our data.
UserSchema
Let's start with the simplest model in our application. For the user, we have these properties: id, name, and email. You can skip the timestamps if you want—I like to include them as good practice. And since we're working with Postgres, we need to import from the pg-core package from Drizzle.
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
As you can see, we're defining the ID as identity. We're also creating types from the schema—this is a bonus we don't need for this tutorial, but it's a helpful tip for your future projects. This schema defines the pattern we'll follow throughout this application.
TaskSchema
Now let's model our tasks, which are related to users through a foreign key to enable compound queries.
A task has these properties: id, title, and description. The user field references the users schema using the ID field with on delete cascade.
export const tasks = pgTable('tasks', {
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
title: text('title').notNull(),
description: text('description').notNull(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
With both schemas defined, we can now declare relations to unlock readable queries with Drizzle.
SQL-like or Not SQL-like?
There's ongoing debate about whether SQL-like syntax or ORM-style syntax is better. Drizzle offers both. I prefer the SQL-like functionality, but for this tutorial we're using the Queries API to keep things simple. To make this work, we need to define relations: a task has one user, and a user has many tasks.
export const tasksRelations = relations(tasks, ({ one }) => ({
user: one(users, {
fields: [tasks.userId],
references: [users.id],
relationName: 'user_tasks',
}),
}));
export const userRelations = relations(users, ({ many }) => ({
tasks: many(tasks, { relationName: 'user_tasks' }),
}));
With the schemas and relations defined, we can now work on our endpoints. But first, generate and run your migration—this will connect your database to your application. If you're using my template, you're all set. If not, read the Drizzle documentation to fully set up your database connection and migrations.
Working with Controllers
Since tasks belong to users, our API design will follow this structure. This creates a semantic hierarchy showing that tasks belong to the users resource. If you want to dive deeper into RESTful API design, read this excellent article from Microsoft: https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design. And for God's sake, never return a 200 status code with an empty array or an error message.
If you've been working with NestJS, you already know how to generate resources with the CLI. Let's generate resources for our two modules: users and tasks.
nest g res users
nest g res tasks
For the transport layer, select REST API and enter y when prompted to generate CRUD endpoints.
With both modules created, open the tasks.controller.ts file and update the controller definition from tasks to users/:userId/tasks.
@Controller('users/:userId/tasks')
export class TasksController {}
Next, define a GET method to fetch all tasks belonging to a specific user.
@Get()
findAll(@Param() { userId }: UserIdParamDto) {
return this.tasksService.findAllByUserId(userId);
}
Since our route depends on the userId, we need to validate this path parameter to prevent errors before they reach the service layer.
Creating the Param DTO
We'll define a param DTO that transforms and validates the userId from the path. This DTO has these key elements: the ApiProperty decorator tells Swagger how to present this attribute and its metadata. The IsNotEmpty decorator marks it as required. The Type decorator from class-transformer converts the ID to a number—all URL params and query fields come through as strings.
Once transformed, we use the IsInt and Min decorators together to ensure the ID is an integer greater than or equal to 1. Since we don't have an ID 0 (or at least we shouldn't), this validation keeps our data clean. Our DTO definition looks like this:
export class UserIdParamDto {
@ApiProperty({
description: 'Unique identifier of the user',
example: 1,
type: Number,
})
@IsNotEmpty({ message: 'User ID must be provided' })
@Type(() => Number)
@Transform(({ value }) => parseInt(String(value), 10))
@IsInt({ message: 'User ID must be a valid number' })
@Min(1, { message: 'User ID must be a positive number' })
userId: number;
}
These checks cover type and range, but they don’t verify existence in the database. Let’s add an async constraint.
Creating the findOne Method
Open your UsersService file and leave the class empty:
@Injectable()
export class UsersService {}
If you're using my template, you have an injectable DrizzleClient; if not, you'll figure it out later. In the constructor, inject the DrizzleClient.
constructor(@Inject(DRIZZLE) private readonly drizzle: DrizzleClient) {}
Now, define the findOne method. This will use the Queries API from Drizzle, returning the first occurrence where the retrieved ID equals the user ID. To verify this equality, import the eq utility from the Drizzle package. This leaves our method looking like this:
findOne(id: number) {
return this.drizzle.query.users.findFirst({ where: eq(users.id, id) });
}
With the TasksService, follow the same approach—but this time, return all tasks found and include the user.
@Injectable()
export class TasksService {
constructor(@Inject(DRIZZLE) private readonly drizzle: DrizzleClient) {}
findAllByUserId(userId: number) {
return this.drizzle.query.tasks.findMany({
where: eq(tasks.userId, userId),
with: { user: true },
});
}
}
Make sure to export both services in their module classes. With our service layer defined, we can create our constraint that injects it.
Creating our Constraint
We'll create an async constraint that uses the UsersService to verify whether the user exists.
Create a class called UserExistConstraint that implements the ValidatorConstraintInterface from class-validator. Use the ValidatorConstraint decorator to mark this as an async validator—we're connecting to the database. You can also optionally provide a name for the constraint. Make it Injectable so Nest recognizes it as a provider. Here's what our code looks like so far:
@ValidatorConstraint({ async: true, name: 'UserExist' })
@Injectable()
export class UserExistConstraint implements ValidatorConstraintInterface {}
Implement the validate method from the interface. This method receives a userId number as a parameter. Call the findOne method from UsersService to retrieve the user with that ID. Return !!user for true if exists and false otherwise.
Finally, define a defaultMessage in case you don't specify one when using the decorator. That's it—your constraint is ready.
@ValidatorConstraint({ async: true, name: 'UserExist' })
@Injectable()
export class UserExistConstraint implements ValidatorConstraintInterface {
constructor(private readonly usersService: UsersService) {}
async validate(userId: number) {
const user = await this.usersService.findOne(userId);
return !!user;
}
defaultMessage() {
return 'User with the given ID does not exist.';
}
}
To avoid sprinkling Validate everywhere, we’ll wrap the constraint in a reusable decorator.
Making it a Decorator
Let's wrap our constraint in a reusable decorator so we can apply it to any DTO using the decorator pattern. Register the decorator as UserExist with the registerDecorator utility. In the validator property, use the constraint we just defined: UserExistConstraint.
export function UserExist(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
async: true,
propertyName,
options: validationOptions,
validator: UserExistConstraint,
});
};
}
And there you go—your userId will be validated each time you call the tasks endpoint. Or will it? Not yet. We need to integrate it into the Nest lifecycle.
The final spice in our Recipe
We need to tell Nest that this constraint is a provider and tell class-validator to use the Nest container to resolve dependencies.
First, add UserExistConstraint as a provider in the TasksModule. This way, Nest will allow TasksModule to inject this constraint. Don't forget to import the UsersModule so you can inject the UsersService.
@Module({
imports: [UsersModule],
controllers: [TasksController],
providers: [TasksService, UserExistConstraint],
})
export class TasksModule {}
Finally, in the main.ts file, import the useContainer function from class-validator and configure it to use the Nest app (the Nest container) for dependency injection. And voilà! Your custom validator should now work.
useContainer(app.select(AppModule), { fallbackOnErrors: true });
With DI in place, we can verify runtime behavior with a quick request.
Integrations Tests… Kind of
Add some dummy data to your database using Drizzle Studio, Drizzle Seed, or directly in your database; for this example, I used Drizzle Seed. Now, test the tasks endpoint. If the ID exists, you should see a result similar to this:
[
{
"id": 3,
"title": "ulcXZ7wJVwvLI6whPeg",
"description": "cfMBXl9heU9w75Ijkpx",
"userId": 4,
"createdAt": "2025-07-23T03:18:12.712Z",
"updatedAt": "2025-03-27T16:56:35.363Z",
"user": {
"id": 4,
"name": "Shauna",
"email": "inflatable_autum@web.de",
"createdAt": "2023-10-24T10:58:31.664Z",
"updatedAt": "2024-02-18T23:40:17.943Z"
}
}
]
If not, you should receive a 400 error with the message User with the given ID does not exist. You can also specify a custom message when using the decorator.
{"message":["User with the given ID does not exist."],"error":"Bad Request","statusCode":400}
This flow confirms that validation happens before executing business logic, reducing errors and coupling.
Final Thoughts
This pattern is easy to extend and customize when building custom decorators and getting the most from Drizzle ORM and the NestJS framework. As homework, try validating task existence by creating a find-one endpoint, or validate email uniqueness—your imagination or stakeholder requirements are the limit.
Let me know in the comments how you feel about this approach or if you want to dive deeper into any topic covered in this tutorial. You can check the complete code in this repository: https://github.com/RubenOAlvarado/custom-validations-example
I hope this helped you create better RESTful APIs with NestJS. I'll see you in the next one!
Top comments (1)
Custom Decorator is one of the most amazing function what NestJS actually has! great post!