loading...

NestJS: Using a generic service to provide base CRUD functionality

kkoomen profile image Kim 金可明 ・3 min read

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.

Posted on by:

Discussion

pic
Editor guide