Search code examples
mongodbmongoosenestjsmongoose-schemadto

How do you manage interfaces/models/validation classes in Nest JS?


I just started using NestJs for a new project I started, and I'm feeling quite frustrated with all of the boilerplate code at the moment, particularly when it comes to DTO classes, Mongoose schemas and interfaces. The way I have been doing it so far is I have an interface that describes the fields I need in my collection documents, then I have DTO classes for validation, which implement those interfaces, and then there are also Schemas for Mongoose which have to simply mirror the interface (as they can't directly implement them afaik (edit: this is somewhat wrong; I explain in my edit)). This means that if I change one thing in the structure of my documents, I have to update it in THREE different places. And then there is also doubled-up validation: on the schema as well as on the DTO classes.

Can I not just completely do away with the DTO classes and just use interfaces and schemas, and simply do validation when inserting documents into the database? If that is not a good idea, is there a different alternative that doesn't require me to change code in three different places? What about using the DTO create class instead of the interface?

EDIT: I have finally found the best solution for me, so here it is for those who stumble upon this question:

I use an interface that describes the model, I now use Schema classes (using the @Schema decorator from @nestjs/mongoose, as suggested by Yahya Eddhissa), and I always have them implement the interface. And I do still use DTOs, which do validation and also implement the same interface. If there are fields that need not be passed in on creation, but do need to be stored in the DB, I just make them optional on the interface and leave them out when implementing the interface in the DTO. If necessary, I include some of those fields in the update DTO, so that they can still be edited after creation.

With this system, if I do change something in the interface, both the DTO and the schema will show errors, reminding me to implement those changes, which is fine, because it's not often that you need to make DB migrations in most projects. And the interface can obviously also be used in other places in my code. So I do still need to update all three files, but, since they serve different functions, I believe this is the most elegant solution. Here is a simple example from my project, starting with the interface:

import { EventFormat } from '../enums';

interface IEvent {
  eventId: string;
  name: string;
  rank: number;
  format: EventFormat;
}

export default IEvent;

Then the schema class:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
import IEvent from '~/shared_helpers/interfaces/Event';
import { EventFormat } from '~/shared_helpers/enums';

@Schema({ timestamps: true })
export class Event implements IEvent {
  @Prop({ required: true, immutable: true, unique: true })
  eventId: string;

  @Prop({ required: true })
  name: string;

  @Prop({ required: true })
  rank: number;

  @Prop({ enum: EventFormat, required: true })
  format: EventFormat;
}

// Used as the return type for DB queries
export type EventDocument = HydratedDocument<Event>;
// Used for dependency injection
export const EventSchema = SchemaFactory.createForClass(Event);

And lastly the DTOs:

import { IsEnum, IsNumber, IsString, Min, MinLength } from 'class-validator';
import IEvent from '~/shared_helpers/interfaces/Event';
import { EventFormat } from '~/shared_helpers/enums';

export class CreateEventDto implements IEvent {
  @IsString()
  @MinLength(3)
  eventId: string;

  @IsString()
  @MinLength(3)
  name: string;

  @IsNumber()
  @Min(0)
  rank: number;

  @IsEnum(EventFormat)
  format: EventFormat;
}
import { PartialType } from '@nestjs/mapped-types';
import { CreateEventDto } from './create-event.dto';

export class UpdateEventDto extends PartialType(CreateEventDto) {
  // If there were optional fields in the interface that were left
  // out from the create DTO, because they are not needed on
  // creation, I would put them here, if they need to be editable.
}

Solution

  • In NestJS, it's possible to use one DTO class for an entity to implement both Mongoose schema definition and validation. NestJS offers a package called: @nestjs/mongoose (if you're not using it already) that allows you to define Mongoose schema using Typescript classes and decorators. The same DTO class used to define the Mongoose schema can also be used to define validation rules using @nestjs/class-validator and pipes, by decorating class properties with validation decorators from class-validator as well. The following class serves as a Mongoose schema and handles validation at the same time:

    import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
    import { IsNotEmpty, IsString, IsEmail } from 'class-validator';
    import { HydratedDocument } from 'mongoose';
    
    export type UserDocument = HydratedDocument<User>;
    
    @Schema()
    export class User {
      @IsNotEmpty()
      @IsString()
      @Prop({ required: true })
      name: string;
    
      @IsNotEmpty()
      @IsEmail()
      @Prop({ required: true, unique: true })
      email: string;
    
      @IsNotEmpty()
      @IsString()
      @Prop({ required: true })
      password: string;
    }
    
    export const UserSchema = SchemaFactory.createForClass(User);
    

    To learn more about that consider reading the following pages on the official NestJS documentation:

    I hope you found this answer helpful.

    Edit: In order to use only one class for both mongoose schema definition and request body validation, but have additional DTOs for specific CRUD operations without having to redefine any properties, you first create the Event class the same way you did by defining mongoose schema decorators, but add class-validator decorators as well on properties you want to validate, and define additional DTOs like CreateEventDto and UpdateEventDto using the Omit operator to exclude any properties that are exclusive to the schema in the create dto, and the Partial operator to make properties optional in the update dto:

    type CreateEventDTO = Omit<Event, "property1" | "property2">
    type UpdateEventDTO = Partial<CreateEventDTO>
    

    The Omit operator will exclude properties you don't want to have in the creation DTO for example timestamps which are created automatically by mongodb at the database level. And the Partial operator will create a new DTO with all properties of the create DTO but all properties will be optional since you wouldn't need to update all properties sometimes.

    I hope this was clear.