postgresql · typescript · rest-api · nestjs · class-validator · typescript-tutorial · drizzleorm · class-transfomer

Paso a paso: implementando decorators personalizados en NestJS usando Class-Validator y Drizzle

9 min de lectura

Al construir APIs hay que validar los inputs y los outputs. Si la API acepta cualquier dato, el fallo es solo cuestión de tiempo. En lugar de sumar más ifs, conviene usar validación declarativa: NestJS + class-validator + class-transformer, una constraint asíncrona que consulta la base de datos, y un decorator personalizado integrado con el container. Resultado: errores detenidos antes de la capa de servicio y reglas reutilizables en todos los DTOs, con menos lógica duplicada.

En este tutorial vamos a ver cómo construir un decorator personalizado propio para evitar estos escenarios.

Configurando el proyecto

Empecemos con un proyecto base. Hay un repositorio template con una API RESTful que usa NestJS como framework, PostgreSQL como base de datos y Drizzle como ORM—pero se puede crear uno propio.

Este es el repo que se está usando: https://github.com/RubenOAlvarado/nestjs-drizzle-template También incluye integración con Swagger y un archivo de Docker Compose para uso propio.

Con el repositorio configurado y la API corriendo, el siguiente paso natural es modelar los datos.

UserSchema

Empecemos con el modelo más simple de la aplicación. Para el user, tenemos estas propiedades: id, name y email. Los timestamps se pueden omitir si se quiere—conviene incluirlos como buena práctica. Y como se está trabajando con Postgres, hay que importar desde el paquete pg-core de 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;

Como se puede ver, el ID se define como identity. También se crean types a partir del schema—esto es un extra que no hace falta para este tutorial, pero es un tip útil para proyectos futuros. Este schema define el patrón que se va a seguir en toda la aplicación.

TaskSchema

Ahora modelemos los tasks, que están relacionados con los users mediante una foreign key para habilitar queries compuestas.

Un task tiene estas propiedades: id, title y description. El campo user hace referencia al schema de users usando el campo ID con 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;

Con ambos schemas definidos, ya se pueden declarar las relations para habilitar queries legibles con Drizzle.

¿SQL-like o no SQL-like?

Hay un debate constante sobre si la sintaxis SQL-like o la sintaxis estilo ORM es mejor. Drizzle ofrece ambas. La funcionalidad SQL-like es la preferida, pero para este tutorial se usa la Queries API para mantener las cosas simples. Para que esto funcione, hay que definir relations: un task tiene un user, y un user tiene muchos 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' }),
}));

Con los schemas y las relations definidas, ya se puede trabajar en los endpoints. Pero antes, hay que generar y correr la migration—esto conecta la base de datos con la aplicación. Si se está usando el template, ya está todo listo. Si no, hay que leer la documentación de Drizzle para terminar de configurar la conexión a la base de datos y las migrations.

Trabajando con los Controllers

Como los tasks pertenecen a los users, el diseño de la API va a seguir esta estructura. Esto crea una jerarquía semántica que muestra que los tasks pertenecen al recurso users. Para profundizar en el diseño de APIs RESTful, hay un excelente artículo de Microsoft: https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design. Y por favor, nunca devolver un status code 200 con un array vacío o un mensaje de error.

Si ya se viene trabajando con NestJS, seguramente ya se sabe cómo generar resources con la CLI. Generemos resources para los dos módulos: users y tasks.

nest g res users
nest g res tasks

Para la transport layer, hay que seleccionar REST API e ingresar y cuando se pregunte si generar los endpoints CRUD.

Con ambos módulos creados, abrir el archivo tasks.controller.ts y actualizar la definición del controller de tasks a users/:userId/tasks.

@Controller('users/:userId/tasks')
export class TasksController {}

A continuación, definir un método GET para traer todos los tasks que pertenecen a un user específico.

@Get()
findAll(@Param() { userId }: UserIdParamDto) {
	return this.tasksService.findAllByUserId(userId);
}

Como la ruta depende de userId, hay que validar este path parameter para evitar errores antes de que lleguen a la capa de servicio.

Creando el Param DTO

Vamos a definir un param DTO que transforma y valida el userId que viene del path. Este DTO tiene estos elementos clave: el decorator ApiProperty le indica a Swagger cómo presentar este atributo y su metadata. El decorator IsNotEmpty lo marca como requerido. El decorator Type de class-transformer convierte el ID a number—todos los URL params y query fields llegan como strings.

Una vez transformado, se usan juntos los decorators IsInt y Min para asegurar que el ID sea un integer mayor o igual a 1. Como no debería existir un ID 0 (o al menos no debería), esta validación mantiene los datos limpios. La definición del DTO queda así:

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;
}

Estas validaciones cubren type y range, pero no verifican la existencia en la base de datos. Sumemos una constraint asíncrona.

Creando el método findOne

Abrir el archivo UsersService y dejar la clase vacía:

@Injectable()
export class UsersService {}

Si se está usando el template, ya se tiene un DrizzleClient injectable; si no, se puede resolver más adelante. En el constructor, inyectar el DrizzleClient.

constructor(@Inject(DRIZZLE) private readonly drizzle: DrizzleClient) {}

Ahora, definir el método findOne. Este va a usar la Queries API de Drizzle, devolviendo la primera ocurrencia donde el ID obtenido sea igual al ID del user. Para verificar esta igualdad, importar la utilidad eq del paquete de Drizzle. El método queda así:

findOne(id: number) {
	return this.drizzle.query.users.findFirst({ where: eq(users.id, id) });
}

Con el TasksService, seguir el mismo enfoque—pero esta vez, devolver todos los tasks encontrados e incluir el 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 },
    });
  }
}

Hay que asegurarse de exportar ambos services en sus respectivas module classes. Con la capa de servicio definida, ya se puede crear la constraint que la inyecta.

Creando la Constraint

Vamos a crear una constraint asíncrona que usa el UsersService para verificar si el user existe.

Crear una clase llamada UserExistConstraint que implemente la ValidatorConstraintInterface de class-validator. Usar el decorator ValidatorConstraint para marcarla como un validator asíncrono—se está conectando a la base de datos. Opcionalmente, también se puede dar un name a la constraint. Marcarla como Injectable para que Nest la reconozca como un provider. Así se ve el código hasta ahora:

@ValidatorConstraint({ async: true, name: 'UserExist' })
@Injectable()
export class UserExistConstraint implements ValidatorConstraintInterface {}

Implementar el método validate de la interface. Este método recibe un userId number como parámetro. Llamar al método findOne de UsersService para obtener el user con ese ID. Devolver !!user: true si existe y false en caso contrario.

Finalmente, definir un defaultMessage por si no se especifica uno al usar el decorator. Eso es todo—la constraint ya está lista.

@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.';
  }
}

Para evitar esparcir Validate por todos lados, vamos a envolver la constraint en un decorator reutilizable.

Convirtiéndola en un Decorator

Envolvamos la constraint en un decorator reutilizable para poder aplicarla a cualquier DTO usando el decorator pattern. Registrar el decorator como UserExist con la utilidad registerDecorator. En la propiedad validator, usar la constraint recién definida: UserExistConstraint.

export function UserExist(validationOptions?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      async: true,
      propertyName,
      options: validationOptions,
      validator: UserExistConstraint,
    });
  };
}

Y listo—el userId se va a validar cada vez que se llame al endpoint de tasks. ¿O no? Todavía no. Falta integrarlo en el lifecycle de Nest.

El toque final de la receta

Hay que decirle a Nest que esta constraint es un provider, y decirle a class-validator que use el container de Nest para resolver dependencies.

Primero, agregar UserExistConstraint como provider en el TasksModule. De esta forma, Nest va a permitir que TasksModule inyecte esta constraint. No hay que olvidarse de importar el UsersModule para poder inyectar el UsersService.

@Module({
  imports: [UsersModule],
  controllers: [TasksController],
  providers: [TasksService, UserExistConstraint],
})
export class TasksModule {}

Finalmente, en el archivo main.ts, importar la función useContainer de class-validator y configurarla para usar la app de Nest (el container de Nest) para la dependency injection. ¡Y listo! El validator personalizado ya debería funcionar.

useContainer(app.select(AppModule), { fallbackOnErrors: true });

Con la DI en su lugar, ya se puede verificar el comportamiento en runtime con un request rápido.

Integrations Tests… Más o Menos

Agregar algo de dummy data a la base de datos usando Drizzle Studio, Drizzle Seed, o directamente en la base de datos; para este ejemplo se usó Drizzle Seed. Ahora, probar el endpoint de tasks. Si el ID existe, el resultado debería ser similar a este:

[
  {
    "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"
    }
  }
]

Si no, debería recibirse un error 400 con el mensaje User with the given ID does not exist. También se puede especificar un mensaje personalizado al usar el decorator.

{"message":["User with the given ID does not exist."],"error":"Bad Request","statusCode":400}

Este flujo confirma que la validación ocurre antes de ejecutar la business logic, reduciendo errores y coupling.

Reflexiones Finales

Este patrón es fácil de extender y personalizar a la hora de construir decorators personalizados y sacarle el máximo provecho a Drizzle ORM y al framework de NestJS. Como tarea, se puede intentar validar la existencia de un task creando un endpoint find-one, o validar la unicidad del email—el límite lo pone la imaginación o los requerimientos del stakeholder.

Los comentarios son bienvenidos para contar qué tal este enfoque, o si se quiere profundizar en algún tema cubierto en este tutorial. El código completo está disponible en este repositorio: https://github.com/RubenOAlvarado/custom-validations-example

Espero que esto ayude a construir mejores APIs RESTful con NestJS. ¡Nos vemos en la próxima!