A Generic Repository Pattern for NestJS with Mongoose for MongoDB
In modern web development, managing data effectively is crucial for building scalable applications. The code snippet we are exploring represents a Generic Repository class that provides a flexible and reusable interface for interacting with MongoDB using Mongoose in a NestJS environment. This article will break down the code, explain its components, and provide use cases for various methods, especially aimed at newcomers to the field.
Full Code Example
Here is the complete code for the GenericRepository
class:
import { ConflictException, Logger, NotFoundException } from "@nestjs/common";
import { ObjectId } from "mongodb";
import {
Document,
FilterQuery,
FlattenMaps,
Model,
QueryOptions,
SaveOptions,
UpdateQuery,
UpdateWithAggregationPipeline,
} from "mongoose";
export class GenericRepository<T extends Document> {
private readonly internalLogger: Logger;
private readonly internalModel: Model<T>;
constructor(model: Model<T>, logger?: Logger) {
this.internalModel = model;
this.internalLogger = logger || new Logger(this.constructor.name);
}
async create(doc: Partial<T>, saveOptions: SaveOptions = {}): Promise<T> {
try {
const createdEntity = new this.internalModel(doc);
const savedResult = await createdEntity.save(saveOptions);
return savedResult;
} catch (error) {
if (error?.name === "MongoServerError" && error?.code === 11000) {
this.internalLogger.error("Duplicate key error while creating:", error);
throw new ConflictException("Document already exists with provided inputs");
}
throw error;
}
}
async getAll(
filter: FilterQuery<T> = {},
options: QueryOptions = {},
): Promise<FlattenMaps<T>[]> {
try {
if (!options.sort) {
options.sort = { createdAt: -1 };
}
const result = await this.internalModel
.find(filter, null, options)
.lean()
.exec();
return result;
} catch (error) {
this.internalLogger.error("Error finding entities:", error);
return [];
}
}
async getOneWhere(
filter: FilterQuery<T>,
options: QueryOptions = {},
): Promise<T | null> {
try {
const result = await this.internalModel
.findOne(filter, null, options)
.exec();
return result;
} catch (error) {
this.internalLogger.error("Error finding entity by ID:", error);
return null;
}
}
async getOneById(id: string, options: QueryOptions = {}): Promise<T | null> {
try {
const result = await this.internalModel
.findOne({ _id: id }, null, options)
.exec();
return result;
} catch (error) {
this.internalLogger.error("Error finding entity by ID:", error);
return null;
}
}
async updateOneById(
documentId: string,
updated: UpdateWithAggregationPipeline | UpdateQuery<T>,
options: QueryOptions = {},
): Promise<T> {
try {
const result = await this.internalModel
.findOneAndUpdate(
{ _id: documentId },
{ ...updated, updatedAt: new Date() },
{ ...options, new: true },
)
.exec();
if (!result) {
throw new NotFoundException("Document not found with provided ID");
}
return result;
} catch (error) {
if (error?.name === "MongoServerError" && error?.code === 11000) {
this.internalLogger.error("Duplicate key error while updating:", error);
throw new ConflictException("Document already exists with provided inputs");
}
this.internalLogger.error("Error updating one entity:", error);
throw error;
}
}
async removeOneById(id: string): Promise<boolean> {
try {
const { acknowledged } = await this.internalModel
.deleteOne({ _id: id })
.exec();
return acknowledged;
} catch (error) {
this.internalLogger.error("Error removing entities:", error);
throw error;
}
}
async count(filter: FilterQuery<T> = {}): Promise<number> {
try {
const count = await this.internalModel.countDocuments(filter).exec();
return count;
} catch (error) {
this.internalLogger.error("Error counting documents:", error);
throw error;
}
}
async validateObjectIds(listOfIds: string[] = []): Promise<boolean> {
try {
if (!Array.isArray(listOfIds) || !listOfIds?.length) {
return false;
}
const objectIdStrings = listOfIds.map(String);
const objectIds = objectIdStrings.map((id) => new ObjectId(id));
const result = await this.internalModel
.find({ _id: { $in: objectIds } })
.select("_id")
.lean()
.exec();
return listOfIds.length === result?.length;
} catch (error) {
this.internalLogger.error("Error during validation:", error);
return false;
}
}
}
Code Breakdown
Imports
The code begins with several imports:
import { ConflictException, Logger, NotFoundException } from "@nestjs/common";
import { ObjectId } from "mongodb";
import {
Document,
FilterQuery,
FlattenMaps,
Model,
QueryOptions,
SaveOptions,
UpdateQuery,
UpdateWithAggregationPipeline,
} from "mongoose";
- NestJS Exceptions:
ConflictException
andNotFoundException
are exceptions that can be thrown to handle errors gracefully in a NestJS application. - ObjectId: This is a MongoDB data type used to represent the unique identifier for documents.
- Mongoose Types: Various types from Mongoose are imported to help define the structure of the repository.
Class Definition
The GenericRepository
class is defined with a generic type parameter <T extends Document>
, meaning it can work with any Mongoose document.
export class GenericRepository<T extends Document> {
private readonly internalLogger: Logger;
private readonly internalModel: Model<T>;
- internalLogger: An instance of
Logger
used for logging errors and information. - internalModel: A Mongoose model representing the collection the repository will interact with.
Constructor
The constructor initializes the model and logger:
constructor(model: Model<T>, logger?: Logger) {
this.internalModel = model;
this.internalLogger = logger || new Logger(this.constructor.name);
}
- The model is passed in as an argument, allowing the repository to interact with a specific MongoDB collection.
- If a logger is not provided, a default logger using the class name is created.
Methods
Now let’s go through the methods defined in the GenericRepository class, explaining each and providing use cases.
1. Create:
async create(doc: Partial<T>, saveOptions: SaveOptions = {}): Promise<T> {
try {
const createdEntity = new this.internalModel(doc);
const savedResult = await createdEntity.save(saveOptions);
return savedResult;
} catch (error) {
if (error?.name === "MongoServerError" && error?.code === 11000) {
this.internalLogger.error("Duplicate key error while creating:", error);
throw new ConflictException("Document already exists with provided inputs");
}
throw error;
}
}
- Purpose: Creates a new document in the database.
- Parameters: doc: The data for the new document. saveOptions: Options for saving (optional).
- Use Case: This method can be used when adding a new user to a user collection. If a user with the same email exists, it throws a ConflictException.
2. GetAll
async getAll(filter: FilterQuery<T> = {}, options: QueryOptions = {}): Promise<FlattenMaps<T>[]> {
try {
if (!options.sort) {
options.sort = { createdAt: -1 };
}
const result = await this.internalModel.find(filter, null, options).lean().exec();
return result;
} catch (error) {
this.internalLogger.error("Error finding entities:", error);
return [];
}
}
- Purpose: Retrieves all documents matching the filter.
- Parameters: filter: Criteria to filter documents (optional). options: Query options like sorting (optional).
- Use Case: Use this method to fetch all products in an e-commerce application, sorted by the most recently added.
3. GetOneWhere
async getOneWhere(filter: FilterQuery<T>, options: QueryOptions = {}): Promise<T | null> {
try {
const result = await this.internalModel.findOne(filter, null, options).exec();
return result;
} catch (error) {
this.internalLogger.error("Error finding entity by ID:", error);
return null;
}
}
- Purpose: Retrieves a single document based on the provided filter.
- Use Case: Fetch a specific user by their username to display their profile.
4. GetOneById
async getOneById(id: string, options: QueryOptions = {}): Promise<T | null> {
try {
const result = await this.internalModel.findOne({ _id: id }, null, options).exec();
return result;
} catch (error) {
this.internalLogger.error("Error finding entity by ID:", error);
return null;
}
}
- Purpose: Fetches a document by its unique identifier.
- Use Case: Retrieve a specific order from an orders collection using its ID.
5. UpdateOneById
async updateOneById(documentId: string, updated: UpdateWithAggregationPipeline | UpdateQuery<T>, options: QueryOptions = {}): Promise<T> {
try {
const result = await this.internalModel.findOneAndUpdate(
{ _id: documentId },
{ ...updated, updatedAt: new Date() },
{ ...options, new: true },
).exec();
if (!result) {
throw new NotFoundException("Document not found with provided ID");
}
return result;
} catch (error) {
if (error?.name === "MongoServerError" && error?.code === 11000) {
this.internalLogger.error("Duplicate key error while updating:", error);
throw new ConflictException("Document already exists with provided inputs");
}
this.internalLogger.error("Error updating one entity:", error);
throw error;
}
}
- Purpose: Updates a document by its ID.
- Parameters: documentId: The ID of the document to update. updated: The new data to update.
- Use Case: Modify user information, like updating an email address or password.
6. RemoveOneById
async removeOneById(id: string): Promise<boolean> {
try {
const { acknowledged } = await this.internalModel.deleteOne({ _id: id }).exec();
return acknowledged;
} catch (error) {
this.internalLogger.error("Error removing entities:", error);
throw error;
}
}
- Purpose: Deletes a document from the database by ID.
- Use Case: Remove a user from the database when they request account deletion.
7. Count
async count(filter: FilterQuery<T> = {}): Promise<number> {
try {
const count = await this.internalModel.countDocuments(filter).exec();
return count;
} catch (error) {
this.internalLogger.error("Error counting documents:", error);
throw error;
}
}
- Purpose: Counts documents matching the filter criteria.
- Use Case: Determine the number of active users in an application.
8. ValidateObjectIds
async validateObjectIds(listOfIds: string[] = []): Promise<boolean> {
try {
if (!Array.isArray(listOfIds) || !listOfIds?.length) {
return false;
}
const objectIdStrings = listOfIds.map(String);
const objectIds = objectIdStrings.map((id) => new ObjectId(id));
const result = await this.internalModel
.find({ _id: { $in: objectIds } })
.select("_id")
.lean()
.exec();
return listOfIds.length === result?.length;
} catch (error) {
this.internalLogger.error("Error during validation:", error);
return false;
}
}
- Purpose: Validates a list of Object IDs to ensure they exist in the database.
- Use Case: Before performing bulk operations, ensure all provided IDs are valid.
The GenericRepository
class is a powerful pattern for managing data access in a NestJS application using Mongoose. It encapsulates common database operations and promotes code reusability, making it easier for developers to interact with MongoDB collections. By utilizing this pattern, you can enhance the maintainability and scalability of your applications, allowing you to focus more on business logic rather than repetitive database code.