We created the
Right now, this boilerplate has 1.8K stars on GitHub and got recognition and support from the developer community. Recently, we also published our new
PostgreSQL support was originally included in the boilerplate because of its reliability, data integrity, and 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 who use this boilerplate.
So, now it's done, and developers can choose between the document-oriented database MongoDB and the 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 the details.
If you need to know more about MongoDB vs PostgreSQL comparison, I recommend reviewing
In order to provide a good level of abstraction and to simplify work with MongoDB, we use
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.
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
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 theusers/domain
directory.
export class User {
id: number | string;
email: string | null;
password?: string;
firstName: string | null;
lastName: string | null;
// ...
}
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>>;
}
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;
}
}
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 {}
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;
}
}
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 {}
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 {}
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,
) {}
}
The full implementation can be found
Schema was built with best practices for MongoDB for best performance and scalability. The 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:
And an example of a data table:
Talking about MongoDB, 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
What should the scheme look like? Everything is very simple: all data must be stored in one collection:
And now, when we fetch users, we will not need to make additional requests to obtain 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 productivity increases.
To learn more about designing MongoDB schemas, I recommend reviewing
For comfortable development (MongoDB + Mongoose), you have to clone the repository, go to the 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 the additional container:docker compose -f docker-compose.document.yaml up -d mongo mongo-express maildev
Install dependencynpm install
Run migrationsnpm run migration:run
Run seedsnpm 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
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, you are welcome to try it if you find it useful, and don't forget to click the star at the library ⭐.
Full credits for this article to