This article will show how we can utilize the powerful class-transformer library to implement clean domain-driven code in a NestJS application. By the end, you will have a pattern to implement NestJS domain-driven design.

Class Transformer

Its ES6 and Typescript era. Nowadays you are working with classes and constructor objects more than ever. Class-transformer allows you to transform plain object to some instance of class and versa

Class-transformer lets us turn plain-old Javascript objects with no methods into actual classes. This is key to allowing us to write more domain-driven code.

Domain Driven Design

When we say domain-driven code we’re primarily referring to: data & functions/methods that act on that data should reside in the same place. This makes our code less complex by centralizing all of the logic related to data in one place and offers us the ability to hide our data. These benefits reduce the cause of many application bugs & make our code more readable.

Class-transformer can transform regular objects returned from the database or a network call, and turn them into their respective classes with the same data but now with the methods we define to act on that data.

Let’s take a look at implementing this in NestJS.

Example

Setup

The complete code for this example can be found here.

We’re starting with my NestJS starter template which provides us with a MongoDB persistence layer using Mongoose ORM. Go ahead and clone the existing project here and install the necessary dependencies in a terminal with

pnpm install

We have a docker-compose.yml with a mongo service defined for the database. In a separate terminal window start the service with

docker-compose up
PORT=3000
MONGODB_URI=mongodb://localhost:27017/nestjs-starter
JWT_EXPIRATION=1000000
JWT_SECRET=FOOBAR

Now before we can start our NestJS app we need to create a .env file at our root and provide a few environment variables

These are all values that the starter app needs as environment variables for configuration. Now we can start the NestJS app with

pnpm start:dev

At this point, our application is running and we are ready to refactor it to utilize class-transformer. Since NestJS already utilizes this library under the hood, we should have it installed.

AbstractRepository

In our project, we utilize a repository architecture to interact with the Mongoose ORM. Each Mongo collection has a respective schema & repository. There is a single AbstractRepository that each repository extends and contains all of our data access code.

We’re going to refactor this AbstractRepository findOne method to use class-transformer and transform the document from the DB in to a class that has methods.

Here is what the AbstractRepository looks like right now:

export abstract class AbstractRepository<TDocument extends AbstractDocument> {
  constructor(protected readonly model: Model<TDocument>) {}

  async findOne(filterQuery: FilterQuery<TDocument>): Promise<TDocument> {
    const document = await this.model
      .findOne(filterQuery, {})
      .lean<TDocument>();

    if (!document) {
      throw new NotFoundException('Document not found.');
    }

    return document;
  }

Let’s go ahead and refactor this to support domain-driven design with class-transformer:

export abstract class AbstractRepository<
  Model extends AbstractModel<Interface>,
  Interface,
> {
  constructor(
    protected readonly model: MongooseModel<Model>,
    protected readonly cls: ClassConstructor<Model>,
  ) {}

  async findOne(filterQuery: FilterQuery<Model>): Promise<Model> {
    const document = await this.model.findOne(filterQuery, {}).lean<Model>();

    if (!document) {
      throw new NotFoundException('Model not found.');
    }

    return plainToInstance(this.cls, document);
  }

The first step is to change our generics to accept our Model & the respective Interface type. This Model type is what will map to the Mongo document so it contains all of our data, and this will also be the type that contains all of our methods that act on that data.

The Interface type is a definition of all of the properties that the Model will receive in its constructor – this will be more clear once we implement it next.

Let’s take a look at the AbstractModel

@ObjectType({ isAbstract: true })
@Schema()
export class AbstractModel<T> {
  @Field(() => ID)
  @Prop({ type: SchemaTypes.ObjectId })
  _id: Types.ObjectId;

  constructor(args: T) {
    Object.assign(this, args);
    if (!this._id) {
      this._id = new Types.ObjectId();
    }
  }
}

This is the class that all of our other Models will extend and we can provide it with common data & functionality. We decorate it with the GraphQL @ObjectType & Mongoose @Schema decorators which register it as a type in our GraphQL/Mongoose modules.

The common MongoDB _id ObjectId is applied to all documents in MongoDB. We can mark it both as a GraphQL field with @Field and Mongoose schema type with @Prop decorators.

Lastly, we implement a constructor that receives args of type T – which will be our Interface type. These properties are then assigned to the class with

Object.assign(this, args);

We can also implement custom functionality like assigning a default _id if none is supplied.

Back to the AbstractRepository we have a new constructor parameter

protected readonly cls: ClassConstructor

This is going to be the type of the Model that we pass into class-transformer so it knows what turn the plain object into. We do this in the new findOne method by calling

plainToInstance(this.cls, document);

In this case, we turn the MongoDB document plain object into the new Model passed into the constructor. Now the repository is returning the Model which contains both the data & methods that can act on that data.

User

Now it’s time to utilise our new AbstractRepository by turning our User schema into a Model. Right now, our User schema & Model are two separate files.

import { Field, ObjectType } from '@nestjs/graphql';
import { AbstractModel } from '../../common/abstract.model';

@ObjectType()
export class User extends AbstractModel {
  @Field()
  readonly email: string;
}

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { AbstractDocument } from '../../database/abstract.schema';

@Schema()
export class UserDocument extends AbstractDocument {
  @Prop({ unique: true })
  email: string;

  @Prop()
  password: string;
}

export const UserSchema = SchemaFactory.createForClass(UserDocument);

We’ll refactor this into a single User Model that contains both our data & methods.

@Schema()
@ObjectType()
export class User extends AbstractModel<IUser> {
  @Prop({ unique: true })
  @Field()
  private email: string;

  @Prop()
  private password: string;

  getEmail() {
    return this.email;
  }

  getPassword() {
    return this.password;
  }

  async sendEmail(message: string, emailService: IEmailService) {
    if (this.email.split('@')[1].includes('QA')) {
      await emailService.send(this.email, `QA MESSAGE: ${message}`);
      return;
    }
    await emailService.send(this.email, message);
  }
}

In this new Model we extend the AbstractModel and provide a new IUser which contains the properties the User model accepts in its constructor. Notice, we’ve now hidden our internal Model data by turning the fields into private members so that they can not be modified outside of our class – a huge benefit.

We’ve also implemented a new method called sendEmail that implements some logic to send an email using a provided EmailService. There is some business logic to alter the message depending on what the email domain is – however, this is now encapsulated inside of the same class that contains the data.

The UserInterface is a simple definition of the constructor arguments

export interface IUser {
  email: string;
  password: string;
}

And here is the IEmailService interface

export interface IEmailService {
  send: (email: string, message: string) => Promise<void>;
}

Finally, we update our UsersRepository to extend the AbstractRepository correctly

@Injectable()
export class UsersRepository extends AbstractRepository<User, IUser> {
  protected readonly logger = new Logger(UsersRepository.name);

  constructor(@InjectModel(User.name) userModel: Model<User>) {
    super(userModel, User);
  }
}

Notice that here we are providing the User model both as a value (constructor call) and a type (generic).

We can now add a new method to our UsersService that will retrieve a User model from the repository and call the sendEmail method on that user.

@Injectable()
export class UsersService {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly emailService: EmailService,
  ) {}

  async emailUser(getUserArgs: GetUserArgs, message: string) {
    (await this.getUser(getUserArgs)).sendEmail(message, this.emailService);
  }

  async getUser(getUserArgs: GetUserArgs) {
    return this.usersRepository.findOne(getUserArgs);
  }

So instead of keeping the business logic for a user inside of a UsersService, we can keep it alongside the same object that contains the data and get all of the benefits of data hiding & encapsulation.

Thanks to class-transformer we are able to easily serialize our plain objects that come back from the database into useful classes that contain methods. This allows us to write code that is more domain driven which requires us to keep our data and methods together.

This same pattern can be utilized on the UI as well to serialize objects received over the wire into classes with methods

fetch('users.json').then((users: Object[]) => {
  const realUsers = plainToInstance(User, users);
  // now each user in realUsers is an instance of User class
});

Sign up to receive updates on new content & exclusive offers

We don’t spam! Cancel anytime.