DEV Community

Rodion
Rodion

Posted on • Edited on

MongoDB support for NestJS Boilerplate with hexagonal architecture

We created NestJS boilerplate in August 2020 and since then we have worked on its optimisation and improvements. NestJS boilerplate is a project that contains all necessary libraries and solutions like auth, mailing, etc. for fast-starting your project using a classic REST API approach. Right now this boilerplate has 1.8K stars on Github and got recognition and support from the developers community. Recently we also published our new frontend boilerplate on React that is excellently compatible with the backend implementation, so for now, we have a whole bc boilerplate ecosystem.

NestJS Boilerplate with MongoDB support

Motivation to include Mongo support

PostgreSQL support was originally included in the boilerplate because of its reliability, data integrity and an active community. But for projects that require high speed of working with large data sets and high scalability, MongoDB is usually a better choice. So, we wanted to integrate MongoDB support into our project. Also we’ve got a number of requests to include NoSQL DB support from the community members and coworkers that use this boilerplate.

Community request to support MongoDB

So, now it's done and developers can choose between document-oriented database MongoDB and relational database PostgreSQL.

Now let’s figure out what would be better to use when setting up a new project. Of course, the question is not which database is better, because both databases are excellent, it all depends on the scope and goals of the application. Let’s dive into details.

  • If you need a relational database that uses complex SQL requests and works with most apps that support relational table structure, it’s better to choose PostgreSQL.
  • For a scenario where a high level of security and high ACID compliance is required, then PostgreSQL is the best solution.
  • If you need a reliable tool to handle complex transactions and analytics in applications that work with multi-structured, fast-changing data, then MongoDB is a good choice for your project.
  • If you're running an application that you'll need to scale and need to be distributed across regions for data locality or data sovereignty, MongoDB's scale-out architecture will automatically meet those needs.

If you need to know more about MongoDB vs PostgreSQL comparison, I recommend reviewing this article.

In order to provide a good level of abstraction and to simplify work with MongoDB we use Mongoose - an object data modeling (ODM) library. It allows developers to define their data models using a schema-based approach and provides a rich set of features that simplify the process of working with MongoDB. In addition to supporting basic CRUD operations and query functions out of the box, Mongoose provides a richer set of features for working with MongoDB, such as middleware functions, virtual properties, query builders, and schema validation. It allows developers to define the structure of their data, including the types of each field, and specify validation rules to ensure data consistency and integrity.

Object Mapping between Node and MongoDB managed via Mongoose
Object Mapping between Node and MongoDB managed via Mongoose

Implementation with hexagonal architecture

To allow an application to uniformly manage batch execution scenarios separately from its end devices and databases, the hexagonal software architecture (aka ports and adapters architecture) introduced by Alistair Cockburn was used. In his article, he emphasizes that there is not much difference between how a user interface and a database interact with an application, since they are both external connections that are interchangeable with similar components and interact with the application in equivalent ways. Therefore, we used this architectural approach in the project, and it allowed us to encapsulate the implementation details of the data source, thus implementing the support of 2 types of databases in the boilerplate.
Let's take a closer look at the implementation. First of all, we create the User entity in the users/domain directory.

export class User {
  id: number | string;
  email: string | null;
  password?: string;
  firstName: string | null;
  lastName: string | null;

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Then we create a port called UserRepository.

export abstract class UserRepository {
  abstract create(
    data: Omit<User, 'id'>,
  ): Promise<User>;
  abstract findOne(fields: EntityCondition<User>): Promise<NullableType<User>>;
}
Enter fullscreen mode Exit fullscreen mode

In users/infrastructure/persistence/relational/repositories we implement UserRepository for working with TypeORM.

@Injectable()
export class UsersRelationalRepository implements UserRepository {
  constructor(
    @InjectRepository(UserEntity)
    private readonly usersRepository: Repository<UserEntity>,
  ) {}

  async create(data: User): Promise<User> {
    const persistenceModel = UserMapper.toPersistence(data);
    const newEntity = await this.usersRepository.save(
      this.usersRepository.create(persistenceModel),
    );
    return UserMapper.toDomain(newEntity);
  }

  async findOne(fields: EntityCondition<User>): Promise<NullableType<User>> {
    const entity = await this.usersRepository.findOne({
      where: fields as FindOptionsWhere<UserEntity>,
    });

    return entity ? UserMapper.toDomain(entity) : null;
  }
}
Enter fullscreen mode Exit fullscreen mode

And we create a module for working with TypeORM in users/infrastructure/persistence/relational.

@Module({
  imports: [TypeOrmModule.forFeature([UserEntity])],
  providers: [
    {
      provide: UserRepository,
      useClass: UsersRelationalRepository,
    },
  ],
  exports: [UserRepository],
})
export class RelationalUserPersistenceModule {}
Enter fullscreen mode Exit fullscreen mode

Now we do the same for Mongoose.

@Injectable()
export class UsersDocumentRepository implements UserRepository {
  constructor(
    @InjectModel(UserSchemaClass.name)
    private readonly usersModel: Model<UserSchemaClass>,
  ) {}

  async create(data: User): Promise<User> {
    const persistenceModel = UserMapper.toPersistence(data);
    const createdUser = new this.usersModel(persistenceModel);
    const userObject = await createdUser.save();
    return UserMapper.toDomain(userObject);
  }

  async findOne(fields: EntityCondition<User>): Promise<NullableType<User>> {
    if (fields.id) {
      const userObject = await this.usersModel.findById(fields.id);
      return userObject ? UserMapper.toDomain(userObject) : null;
    }

    const userObject = await this.usersModel.findOne(fields);
    return userObject ? UserMapper.toDomain(userObject) : null;
  }
}
Enter fullscreen mode Exit fullscreen mode

And module for working with MongoDB.

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: UserSchemaClass.name, schema: UserSchema },
    ]),
  ],
  providers: [
    {
      provide: UserRepository,
      useClass: UsersDocumentRepository,
    },
  ],
  exports: [UserRepository],
})
export class DocumentUserPersistenceModule {}
Enter fullscreen mode Exit fullscreen mode

After that, we connect either the module for working with Mongoose (DocumentUserPersistenceModule) or TypeORM (RelationalUserPersistenceModule) in users/users.module.ts based on the ENV configuration.

const infrastructurePersistenceModule = (databaseConfig() as DatabaseConfig)
  .isDocumentDatabase
  ? DocumentUserPersistenceModule
  : RelationalUserPersistenceModule;

@Module({
  imports: [infrastructurePersistenceModule, FilesModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService, infrastructurePersistenceModule],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

And then in the UserService we can access the UserRepository, and nestjs will understand which database to use based on the ENV configuration settings.

@Injectable()
export class UsersService {
  constructor(
    private readonly usersRepository: UserRepository,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

The full implementation can be found here.

MongoDB schema

Schema was built with best practices for MongoDB for best performance and scalability. Schema design for NoSQL databases is not the same as for relational databases. One of the differences is that in relational we have the option to reduce your schema to normal forms to avoid duplicates, etc. While for NoSQL, we can duplicate data to avoid "joins", due to which the best performance indicator will be achieved during data sampling. Let's take a look at the boilerplate as an example to see what the difference is. The database schema for PostgreSQL looks something like this:

DB Schema for PostgreSQL

And an example of data table:

Example of data table for PostgreSQL

Talking about MongoDB, then we can NOT transfer the design experience from PostgreSQL here, that is, create 4 collections users, files (for photos), roles, statuses and store in the user collection links to other collections and during data sampling using aggregation ($lookup) append additional data, as this will affect performance ​(read more about joins comparison in this article, though 2020 year sounds old, but it is still actual). What should the scheme look like? Everything is very simple: all data must be stored in one collection:

MongoDB Schema and example of datasets

And now when we will fetch users, we will not need to make additional requests for obtaining data about the user's photo, role and status, because all the data is already stored in the user's collection, in fact, due to which productivity will increase.

To learn more about designing MongoDB schemas I recommend reviewing this article.

How to use NestJS Boilerplate with Mongoose

For comfortable development (MongoDB + Mongoose) you have to clone the repository, go to folder my-app/ and copy env-example-document as .env.

cd my-app/
cp env-example-document .env

Change DATABASE_URL=mongodb://mongo:27017 to DATABASE_URL=mongodb://localhost:27017

Run additional container:
docker compose -f docker-compose.document.yaml up -d mongo mongo-express maildev

Install dependency
npm install

Run migrations
npm run migration:run

Run seeds
npm run seed:run:document

Run app in dev mode
npm run start:dev

That's it.

If we talk about the impact of the selected database on the frontend application, in particular the Extensive React boilerplate, which we also maintain up-to-date and it plays well with the currently discussed NestJS boilerplate, so it won't have affect on their interaction.

Frontend React Boilerplate

Whatever database you choose to use - PostgreSQL or MongoDB (they are both great), the choice should depend on the whole project, and in our boilerplate you have that choice) So, welcome to try it if you find it useful, check out our bc boilerplate ecosystem, and don't forget to click the star at the library ⭐.

Full credits for this article to Vlad Shchepotin and Elena Vlasenko 🇺🇦

Top comments (0)