DEV Community

Kim 金可明
Kim 金可明

Posted on

NestJS: Using a generic service to provide base CRUD functionality

In my company we were migrating our previous NodeJS project to a new NestJS project and we had to migrate 30+ services. This was a good time to do some refactoring while migrating everything.

More than half of the services we had to migrate contained CRUD functionality and I thought to myself: "Wouldn't it be possible to just have one generic service and let other services extend it to immediately provide CRUD functionality for that service without having to write anything but the class skeleton?"

So eventually, this is what I have right now which works amazingly (The example below is using Mongoose, but this can obviously work with any database you want of course):

// src/service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { Document, FilterQuery, Model } from 'mongoose';
import { LoggerService } from 'src/modules/logging/logger.service';

/**
 * Abstract base service that other services can extend to provide base CRUD
 * functionality such as to create, find, update and delete data.
 */
@Injectable()
export abstract class Service<T extends Document> {
  private readonly modelName: string;
  private readonly serviceLogger: LoggerService;

  /**
   * The constructor must receive the injected model from the child service in
   * order to provide all the proper base functionality.
   *
   * @param {Logger} logger - The injected logger.
   * @param {Model} model - The injected model.
   */
  constructor(
    logger: LoggerService,
    private readonly model: Model<T>,
  ) {
    // Services who extend this service already contain a property called
    // 'logger' so we will assign it to a different name.
    this.serviceLogger = logger;

    for (const modelName of Object.keys(model.collection.conn.models)) {
      if (model.collection.conn.models[modelName] === this.model) {
        this.modelName = modelName;
        break;
      }
    }
  }

  /**
   * Find one entry and return the result.
   *
   * @throws InternalServerErrorException
   */
  async findOne(
    conditions: Partial<Record<keyof T, unknown>>,
    projection: string | Record<string, unknown> = {},
    options: Record<string, unknown> = {},
  ): Promise<T> {
    try {
      return await this.model.findOne(
        conditions as FilterQuery<T>,
        projection,
        options,
      );
    } catch (err) {
      this.serviceLogger.error(`Could not find ${this.modelName} entry:`);
      this.serviceLogger.error(err);
      throw new InternalServerErrorException();
    }
  }

  // More methods here such as: create, update and delete.
}

Then we can use it like so:

// src/modules/users/users.service.ts
import { User } from './schemas/users.schema.ts';
import { Model } from 'mongoose';
import { LoggerService } from 'src/modules/logger/logger.service';
import { Service } from 'src/service';

class UsersService extends Service<User> {
  constructor(
    readonly logger: LoggerService,
    @InjectModel(User.name) readonly usersModel: Model<User>
  ) {
    super(logger, usersModel);
  }
} 

Please note that I only show 1 method to show the idea. The original service has all the methods in order to create, find, update and delete documents in MongoDB.

The nice thing about the conditions: Partial<Record<keyof T, unknown>> parameter inside the methods (which we do use in all our methods) is that it makes sure that the key you pass is must be a valid key of T, in this case: User. If you made a typo, TypeScript will immedately throw an error. The con of this approach is that it isn't possible to do this for the more advanced Mongoose query syntax such as:

this.usersService.findOne({
  'nestedObject.nestedKey': true,
})

I did made an issue on Reddit about this, see here. If you know how this can be possible fixed, feel free to post a comment on Reddit or here below.

Let's continue.

Using the users.service.ts in this way, we can still add additional functionality if needed using the usersModel and the only thing we need to do is pass it to the parent constructor.

For most of our services, we only had to extend this base service and we could immediately use all the functionalities in our controller.

For example:

// src/modules/users/users.controller.ts
@Controller()
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  async get(@Param('id') id: string): Promise<User> {
    return this.usersService.findOne({ _id: id });
  }
}

Would love to know if others also had a interesting approach to use generics to solve similiar problems. If you do, feel free to comment.

Thanks.

Top comments (3)

Collapse
 
alessandro profile image
rhombergalessandro

Hello, can you show me your model ? I have the mongoose Schema and one class model for validation. (class-validator). But they dont extends form thype Document. Can i exend from the Document class on the validation model without any problems ?

best regards, alessandro

Collapse
 
paularah profile image
Paul Arah • Edited

you can extend the Document type by creating an intersection type like this export type UserDocument = User & Document;and then pass the UserDocument type when you inject in your service. This is assuming you're using the InjectModel decorator that from the nestjs/mongose module. An example of what you would pass into your user service constructor would look like this @InjectModel(User.name) private readonly userModel: Model<UserDocument>

Collapse
 
johncmunson profile image
John Munson

Here's an alternative approach from one of the NestJS maintainers.
discord.com/channels/5206228127428...

With your example, which uses an abstract base class, are you sure you should be slapping @Injectable onto the abstract class? Pretty sure you shouldn't be doing this, and instead you should be adding @Injectable to UsersService.