Subscribe
Feb 12, 2024
9 min read
nest.jsmongoose

Crafting an Efficient Data Layer with NestJS and Mongoose

This guide offers a comprehensive walkthrough on creating a NestJS project from scratch, focusing on integrating DAOs to enhance data handling efficiency and maintainability.

Introduction

This guide will teach you how to set up a NestJS project from scratch and make the most of Data Access Objects (DAOs) for efficient data management.

NestJS is a modern and versatile Node.js framework with many features to help you build robust server-side applications. On the other hand, Mongoose is a popular tool for creating scalable and maintainable data models and interactions in a Node.js environment. By combining the two, you can build a robust foundation for your applications that can help you develop scalable and highly maintainable solutions with ease.

Understanding DAO

Data Access Objects (DAOs) is a design pattern that separates low-level data accessing functions from high-level business services. This pattern allows interaction with a database or another persistence mechanism without influencing the business logic layer. It is useful for managing the application's data by mapping application calls to the persistence layer.

This approach allows greater flexibility in replacing or modifying the underlying database operations without affecting the business logic layer. In short, DAOs provide an abstract interface to a database or persistence mechanism.

Below is a diagram that illustrates how a DAO fits into the main components of NestJS.

DAO Architectural Diagram illustrating a three-tiered application structure

In a nutshell, DAOs are like your app's database traffic controllers. They provide a set of precise instructions that dictate how your app interacts with the database, including tasks like retrieving data, modifying records, and deleting them. By providing a consistent and structured approach, DAOs offer several benefits:

  • Abstraction of Complexity: Developers can manage data operations without knowing the specifics of database languages.
  • Scalability: When your application grows, it can take time to keep track of all the database calls you're making. DAOs can help by centralizing these calls and making them easier to manage and scale.
  • Maintainability and Reusability: DAOs help to make database transitions and data interactions smoother, with minimal disruption to the core functions. You can reuse established DAOs for data models in your application to minimize redundancy and ensure consistent data operations.
  • Enhanced Testing: Using DAOs in unit tests makes testing your data access layer easier. This is because you can mock DAOs instead of relying on the actual database.

As we progress in our development process, we will incorporate DAOs (Data Access Objects) into our NestJS and Mongoose framework. This approach will help us improve the efficiency of our database interactions and enhance our application's overall structure and maintainability. With DAOs, we can create a more modular, manageable, and scalable application architecture, which will serve as a solid foundation for advanced application development.

Setting Up a NestJS Project

NestJS, with its out-of-the-box architecture and rich module ecosystem, provides a robust framework for server-side applications. Let's walk through the steps to set up a new NestJS project and prepare it to integrate our modules.

The initial step involves installing the NestJS CLI (Command Line Interface), an indispensable tool for maintaining your NestJS applications. Open your terminal and run:

npm install -g @nestjs/cli
npm install -g @nestjs/cli

Once you have installed the CLI, creating a new project is just a command away. First, navigate to the directory where you want to create the project. Then, execute the following command:

nest new project-name
nest new project-name

Replace project-name with the name you want to give to your project. The CLI will prompt you to choose a package manager - npm, pnpm, or yarn - which will be used to set up your project's architecture.

After installation, you'll have a new directory with your project's name. It will contain the initial structure provided by NestJS, including the following:

  • src/ - Your primary workspace for modules, controllers, and services.
  • test/ - Holds your end-to-end tests.
  • nest-cli.json - Configuration for the Nest CLI.
  • tsconfig.json - Sets up TypeScript compiler options.

Go to your project folder and launch the application to ensure everything is configured correctly.

cd project-name
npm run start:dev
cd project-name
npm run start:dev

By default, your application will be accessible at http://localhost:3000. Visiting this URL in your browser should display a welcoming "Hello World!" message, signaling that your NestJS project is successfully up and running.

A Modular Architecture

Modules are at the heart of NestJS's architecture, helping to keep your code organized and facilitating the modularization of your application. This approach not only makes your application easier to maintain but also enhances its scalability.

Each application feature can be enclosed in its module, creating a more organized and maintainable codebase. For instance, we'll create a features module that will be a container for all our API-exposed modules.

To clarify this concept, let's introduce two key components of our application: Book and Bookmark entities. Each of these entities will have a module that will demonstrate how we can systematically organize the features of our application.

Modular architecture - NestJS

Navigate to the 'src' folder and create a new folder called 'features'. Create a file named 'features.module.ts' inside this folder to declare the module. This module will act as a container for all the feature-specific modules, including ones for the Book and Bookmark entities.

src/features/features.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class FeaturesModule {}
src/features/features.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class FeaturesModule {}

To make the features module accessible throughout your application, you'll need to import it into the AppModule, which is the main module of your application. You can do this by editing the 'app.module.ts' file and adding it to the imports array.

src/app.module.ts
import { Module } from '@nestjs/common';
import { FeaturesModule } from './features/features.module';
 
@Module({
  imports: [FeaturesModule]
})
export class AppModule {}
src/app.module.ts
import { Module } from '@nestjs/common';
import { FeaturesModule } from './features/features.module';
 
@Module({
  imports: [FeaturesModule]
})
export class AppModule {}

To create modules for the Book and Bookmark entities, navigate to the 'features' folder and make two new folders, one for each entity. Create a module file inside each folder named 'book.module.ts' and 'bookmark.module.ts', respectively.

src/features/book/book.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class BookModule {}
src/features/book/book.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class BookModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class BookmarkModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class BookmarkModule {}

Once you have created both modules, link them back to the FeaturesModule. This will combine all of your application's features into a coherent and modular approach to development.

src/features/features.module.ts
import { Module } from '@nestjs/common';
import { BookModule } from './book/book.module';
import { BookmarkModule } from './bookmark/bookmark.module';
 
@Module({
  imports: [BookModule, BookmarkModule]
})
export class FeaturesModule {}
src/features/features.module.ts
import { Module } from '@nestjs/common';
import { BookModule } from './book/book.module';
import { BookmarkModule } from './bookmark/bookmark.module';
 
@Module({
  imports: [BookModule, BookmarkModule]
})
export class FeaturesModule {}

Mongoose Integration

Integrating Mongoose in NestJS streamlines database operations and strengthens modular design by connecting your app's logic with MongoDB's storage capabilities.

To start, you must add Mongoose to your project and install the NestJS wrapper for Mongoose. This wrapper will help you integrate the two more easily. To do this, run the following command in your project root directory:

npm install i @nestjs/mongoose
npm install i @nestjs/mongoose

Creating a separate module for database configuration is a good idea. This will help organize your code and make it more modular. By having a dedicated module to handle database connections and operations, you can easily reuse it in different parts of your application.

Modular architecture in nest.js

To start, go to the 'src' folder and create a new 'core' folder. This folder will help you organize core functionalities, such as database configurations. It will also make your application structure scalable. Once you've created the core folder, create a new file called 'core.module.ts'. This file will manage the core functionalities.

src/core/core.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class CoreModule {}
src/core/core.module.ts
import { Module } from '@nestjs/common';
 
@Module({})
export class CoreModule {}

Remember to import this module into the app module.

src/app.module.ts
import { Module } from '@nestjs/common';
import { FeaturesModule } from './features/features.module';
import { CoreModule } from './core/core.module';
 
@Module({
  imports: [FeaturesModule, CoreModule]
})
export class AppModule {}
src/app.module.ts
import { Module } from '@nestjs/common';
import { FeaturesModule } from './features/features.module';
import { CoreModule } from './core/core.module';
 
@Module({
  imports: [FeaturesModule, CoreModule]
})
export class AppModule {}

To set up the MongoDB connection, create a file called 'database.module.ts' in the 'database' folder. This file will centralize the connection setup.

src/core/database/database.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
 
@Module({
  imports: [
    MongooseModule.forRootAsync({
      useFactory: () => {
        const host = 'localhost';
        const port = 27017;
        const name = 'bookmarksy';
        return {
          uri: `mongodb://${host}:${port}/${name}`,
          connectionFactory: (connection) => {
            if (connection.readyState === 1) {
              console.log(`Connected to database ${name} at ${host}:${port}`);
            }
            return connection;
          }
        };
      }
    })
  ]
})
export class DatabaseModule {}
src/core/database/database.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
 
@Module({
  imports: [
    MongooseModule.forRootAsync({
      useFactory: () => {
        const host = 'localhost';
        const port = 27017;
        const name = 'bookmarksy';
        return {
          uri: `mongodb://${host}:${port}/${name}`,
          connectionFactory: (connection) => {
            if (connection.readyState === 1) {
              console.log(`Connected to database ${name} at ${host}:${port}`);
            }
            return connection;
          }
        };
      }
    })
  ]
})
export class DatabaseModule {}

The MongooseModule.forRootAsync method offers a dynamic and asynchronous way to configure MongoDB connections. It uses a useFactory function that enables the injection of other services or configurations. This provides the flexibility to adjust database connections according to runtime conditions or environmental configurations.

After preparing the database module, import it into the core module.

src/core/core.module.ts
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
 
@Module({
  imports: [DatabaseModule]
})
export class CoreModule {}
src/core/core.module.ts
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
 
@Module({
  imports: [DatabaseModule]
})
export class CoreModule {}

Upon launching the application, initialization logs will confirm the successful setup of modules, including DatabaseModule, FeaturesModule, and entity-specific modules.

NestJS logs for dependencies initialization

Schemas Definition

It's time to create schemas for our entities, define the structure of MongoDB collection documents, and prepare for database interactions.

Begin with the Book model by heading to the 'src/features/book' directory and creating a 'book.schema.ts' file. Use the @Schema() decorator to mark the class as a Mongoose schema and employ the SchemaFactory for generating a schema based on the Book class:

src/features/book/book.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
 
export type BookDocument = HydratedDocument<Book>;
 
@Schema()
export class Book {
  @Prop({ required: true, unique: true, maxlength: 100 })
  title: string;
  @Prop({ required: true, maxlength: 100 })
  author: string;
  @Prop({ enum: ['fiction', 'non-fiction', 'fantasy', 'sci-fi', 'horror'] })
  genre?: string;
  @Prop({
    validate: function (value) {
      return new Date(value) <= new Date();
    }
  })
  publicationYear: Date;
}
export const BookSchema = SchemaFactory.createForClass(Book);
src/features/book/book.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
 
export type BookDocument = HydratedDocument<Book>;
 
@Schema()
export class Book {
  @Prop({ required: true, unique: true, maxlength: 100 })
  title: string;
  @Prop({ required: true, maxlength: 100 })
  author: string;
  @Prop({ enum: ['fiction', 'non-fiction', 'fantasy', 'sci-fi', 'horror'] })
  genre?: string;
  @Prop({
    validate: function (value) {
      return new Date(value) <= new Date();
    }
  })
  publicationYear: Date;
}
export const BookSchema = SchemaFactory.createForClass(Book);

The @Prop() decorator enables you to define schema properties, specifying validation rules like required and unique.

Next, mirror this process for the Bookmark model:

src/features/bookmark/bookmark.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose';
import { Book } from '../book/book.schema';
 
export type BookmarkDocument = HydratedDocument<Bookmark>;
 
@Schema()
export class Bookmark {
  @Prop({ type: MongooseSchema.Types.ObjectId, ref: 'Book', required: true })
  bookId: Book | Types.ObjectId;
  @Prop({ required: true })
  pageNumber?: number;
  @Prop()
  notes?: string;
}
export const BookmarkSchema = SchemaFactory.createForClass(Bookmark);
src/features/bookmark/bookmark.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose';
import { Book } from '../book/book.schema';
 
export type BookmarkDocument = HydratedDocument<Bookmark>;
 
@Schema()
export class Bookmark {
  @Prop({ type: MongooseSchema.Types.ObjectId, ref: 'Book', required: true })
  bookId: Book | Types.ObjectId;
  @Prop({ required: true })
  pageNumber?: number;
  @Prop()
  notes?: string;
}
export const BookmarkSchema = SchemaFactory.createForClass(Bookmark);

It's essential to integrate these schemas into their respective modules. You'll need to update the 'book.module.ts' file to do this. Use the MongooseModule.forFeature() method to include the BookSchema and associate it with the Book model. This method allows you to configure the module and define registered models in the current scope.

src/features/book/book.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './book.schema';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])
  ]
})
export class BookModule {}
src/features/book/book.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './book.schema';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])
  ]
})
export class BookModule {}

Similarly, enrich the 'bookmark.module.ts' with the BookmarkSchema.

src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Bookmark, BookmarkSchema } from './bookmark.schema';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])
  ]
})
export class BookmarkModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Bookmark, BookmarkSchema } from './bookmark.schema';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])
  ]
})
export class BookmarkModule {}

Introducing DAOs

The DAO pattern is an architectural blueprint isolates the application or business layer from the persistence layer, utilizing an abstract API for database operations. This clear separation simplifies database management and ensures the service layer remains decoupled from the database access layer.

In the heart of your project's core functionality, the groundwork begins with creating an abstract DAO class within the 'src/core/database' directory. This class outlines generic methods for CRUD operations, laying a versatile foundation that specific model DAOs can extend.

src/core/database/database.dao.ts
import { Model, FilterQuery, UpdateQuery, QueryOptions, SaveOptions } from 'mongoose';
 
export abstract class DAO<T> {
  constructor(protected readonly model: Model<T>) {}
 
  async create(doc: Partial<T>, options?: SaveOptions) {
    const createdDoc = new this.model(doc);
    return createdDoc.save(options);
  }
 
  async find(filter: FilterQuery<T> = {}, options?: QueryOptions) {
    return this.model.find(filter, null, options).lean();
  }
 
  async findOne(filter: FilterQuery<T>, options?: QueryOptions) {
    return this.model.findOne(filter, null, options).lean();
  }
 
  async updateOne(filter: FilterQuery<T>, update: UpdateQuery<T>, options?: QueryOptions) {
    return this.model.findOneAndUpdate(filter, update, { new: true, ...options });
  }
 
  async deleteOne(filter: FilterQuery<T>, options?: QueryOptions) {
    return this.model.findOneAndDelete(filter, options);
  }
}
src/core/database/database.dao.ts
import { Model, FilterQuery, UpdateQuery, QueryOptions, SaveOptions } from 'mongoose';
 
export abstract class DAO<T> {
  constructor(protected readonly model: Model<T>) {}
 
  async create(doc: Partial<T>, options?: SaveOptions) {
    const createdDoc = new this.model(doc);
    return createdDoc.save(options);
  }
 
  async find(filter: FilterQuery<T> = {}, options?: QueryOptions) {
    return this.model.find(filter, null, options).lean();
  }
 
  async findOne(filter: FilterQuery<T>, options?: QueryOptions) {
    return this.model.findOne(filter, null, options).lean();
  }
 
  async updateOne(filter: FilterQuery<T>, update: UpdateQuery<T>, options?: QueryOptions) {
    return this.model.findOneAndUpdate(filter, update, { new: true, ...options });
  }
 
  async deleteOne(filter: FilterQuery<T>, options?: QueryOptions) {
    return this.model.findOneAndDelete(filter, options);
  }
}

This abstraction, through the generic <T>, enables the DAO class to be highly reusable across any model type.

With this foundation, we create specific DAOs for the Book and Bookmark models, each inheriting the base DAO's generic CRUD capabilities while also addressing model-specific needs.

src/features/book/book.dao.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Book, BookDocument } from './book.schema';
import { DAO } from '../../core/database/database.dao';
 
@Injectable()
export class BookDAO extends DAO<BookDocument> {
  constructor(@InjectModel(Book.name) model: Model<BookDocument>) {
    super(model);
  }
}
src/features/book/book.dao.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Book, BookDocument } from './book.schema';
import { DAO } from '../../core/database/database.dao';
 
@Injectable()
export class BookDAO extends DAO<BookDocument> {
  constructor(@InjectModel(Book.name) model: Model<BookDocument>) {
    super(model);
  }
}

Similarly, the Bookmark model receives its specialized DAO in the 'src/features/bookmark' directory through a 'bookmark.dao.ts' file:

src/features/bookmark/bookmark.dao.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Bookmark, BookmarkDocument } from './bookmark.schema';
import { DAO } from '../../core/database/database.dao';
 
@Injectable()
export class BookmarkDAO extends DAO<BookmarkDocument> {
  constructor(@InjectModel(Bookmark.name) model: Model<BookmarkDocument>) {
    super(model);
  }
  
  async findByBookId(bookId: string) {
    return this.model.find({ bookId });
  }
}
src/features/bookmark/bookmark.dao.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Bookmark, BookmarkDocument } from './bookmark.schema';
import { DAO } from '../../core/database/database.dao';
 
@Injectable()
export class BookmarkDAO extends DAO<BookmarkDocument> {
  constructor(@InjectModel(Bookmark.name) model: Model<BookmarkDocument>) {
    super(model);
  }
  
  async findByBookId(bookId: string) {
    return this.model.find({ bookId });
  }
}

The @InjectModel decorator injects the Mongoose model for the specified class into the DAO, facilitating database operations.

This setup enables standard CRUD operations and allows adding custom functions tailored to specific needs, such as findByBookId. Custom DAOs can implement model-specific queries.

The final step is to register these DAOs as providers within their respective modules to make them available for injection into services. The 'book.module.ts' and 'bookmark.module.ts' are then updated to include their respective DAOs in the providers array.

src/features/book/book.module.ts
@Module({
  imports: [MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])],
  providers: [BookDAO]
})
export class BookModule {}
src/features/book/book.module.ts
@Module({
  imports: [MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])],
  providers: [BookDAO]
})
export class BookModule {}
src/features/bookmark/bookmark.module.ts
@Module({
  imports: [MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])],
  providers: [BookmarkDAO]
})
export class BookmarkModule {}
src/features/bookmark/bookmark.module.ts
@Module({
  imports: [MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])],
  providers: [BookmarkDAO]
})
export class BookmarkModule {}

Our application's data access is now modular and maintainable with shared DAOs, which minimize redundant code and enhance scalability through a uniform method for database operations.

Services on Top of DAOs

Services serve as the intermediary between controllers, which manage incoming requests, and DAOs, responsible for database operations.

Starting with the Book entity, we create a service in the 'src/features/book' directory. Here, a 'book.service.ts' file outlines a service class utilizing BookDAO for operations related to books:

src/features/book/book.service.ts
import { Injectable } from '@nestjs/common';
import { FilterQuery, QueryOptions } from 'mongoose';
import { BookDAO } from './book.dao';
import { Book, BookDocument } from './book.schema';
 
@Injectable()
export class BookService {
  constructor(private readonly bookDAO: BookDAO) {}
 
  async save(book: Book) {
    return this.bookDAO.save(book);
  }
 
  async find(
    filter: FilterQuery<BookDocument>,
    options?: QueryOptions<BookDocument>,
  ) {
    return this.bookDAO.find(filter, options);
  }
 
  async findOne(id: string, options?: QueryOptions<BookDocument>) {
    return this.bookDAO.findById(id, options);
  }
 
  async updateOne(
    id: string,
    book: Partial<Book>,
    options?: QueryOptions<BookDocument>,
  ) {
    return this.bookDAO.updateById(id, book, options);
  }
 
  async deleteOne(id: string, options?: QueryOptions<BookDocument>) {
    return this.bookDAO.deleteById(id, options);
  }
}
src/features/book/book.service.ts
import { Injectable } from '@nestjs/common';
import { FilterQuery, QueryOptions } from 'mongoose';
import { BookDAO } from './book.dao';
import { Book, BookDocument } from './book.schema';
 
@Injectable()
export class BookService {
  constructor(private readonly bookDAO: BookDAO) {}
 
  async save(book: Book) {
    return this.bookDAO.save(book);
  }
 
  async find(
    filter: FilterQuery<BookDocument>,
    options?: QueryOptions<BookDocument>,
  ) {
    return this.bookDAO.find(filter, options);
  }
 
  async findOne(id: string, options?: QueryOptions<BookDocument>) {
    return this.bookDAO.findById(id, options);
  }
 
  async updateOne(
    id: string,
    book: Partial<Book>,
    options?: QueryOptions<BookDocument>,
  ) {
    return this.bookDAO.updateById(id, book, options);
  }
 
  async deleteOne(id: string, options?: QueryOptions<BookDocument>) {
    return this.bookDAO.deleteById(id, options);
  }
}

By injecting BookDAO, this class abstracts DAO operations for easy controller access.

Similarly, the Bookmark entity receives its service within the 'src/features/bookmark' directory, with 'bookmark.service.ts' mirroring the BookService in structure and functionality but utilizing BookmarkDAO for bookmark-specific operations:

src/features/bookmark/bookmark.service.ts
import { Injectable } from '@nestjs/common';
import { FilterQuery, QueryOptions } from 'mongoose';
import { BookmarkDAO } from './bookmark.dao';
import { Bookmark, BookmarkDocument } from './bookmark.schema';
 
@Injectable()
export class BookService {
  constructor(private readonly bookmarkDAO: BookmarkDAO) {}
 
  async save(book: Bookmark) {
    return this.bookmarkDAO.save(book);
  }
 
  async find(
    filter: FilterQuery<BookmarkDocument>,
    options?: QueryOptions<BookmarkDocument>,
  ) {
    return this.bookmarkDAO.find(filter, options);
  }
 
  async findOne(id: string, options?: QueryOptions<BookmarkDocument>) {
    return this.bookmarkDAO.findById(id, options);
  }
 
  async updateOne(
    id: string,
    bookmark: Partial<Bookmark>,
    options?: QueryOptions<BookmarkDocument>,
  ) {
    return this.bookmarkDAO.updateById(id, bookmark, options);
  }
 
  async deleteOne(id: string, options?: QueryOptions<BookmarkDocument>) {
    return this.bookmarkDAO.deleteById(id, options);
  }
}
src/features/bookmark/bookmark.service.ts
import { Injectable } from '@nestjs/common';
import { FilterQuery, QueryOptions } from 'mongoose';
import { BookmarkDAO } from './bookmark.dao';
import { Bookmark, BookmarkDocument } from './bookmark.schema';
 
@Injectable()
export class BookService {
  constructor(private readonly bookmarkDAO: BookmarkDAO) {}
 
  async save(book: Bookmark) {
    return this.bookmarkDAO.save(book);
  }
 
  async find(
    filter: FilterQuery<BookmarkDocument>,
    options?: QueryOptions<BookmarkDocument>,
  ) {
    return this.bookmarkDAO.find(filter, options);
  }
 
  async findOne(id: string, options?: QueryOptions<BookmarkDocument>) {
    return this.bookmarkDAO.findById(id, options);
  }
 
  async updateOne(
    id: string,
    bookmark: Partial<Bookmark>,
    options?: QueryOptions<BookmarkDocument>,
  ) {
    return this.bookmarkDAO.updateById(id, bookmark, options);
  }
 
  async deleteOne(id: string, options?: QueryOptions<BookmarkDocument>) {
    return this.bookmarkDAO.deleteById(id, options);
  }
}

For these services to be injectable into controllers, they must be registered as providers in their respective modules. Thus, the 'book.module.ts' and 'bookmark.module.ts' files are updated to include BookService and BookmarkService, respectively:

src/features/book/book.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './book.schema';
import { BookDAO } from './book.dao';
import { BookService } from './book.service';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])
  ],
  providers: [BookDAO, BookService]
})
export class BookModule {}
src/features/book/book.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './book.schema';
import { BookDAO } from './book.dao';
import { BookService } from './book.service';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])
  ],
  providers: [BookDAO, BookService]
})
export class BookModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
import { Bookmark, BookmarkSchema } from './bookmark.schema';
import { MongooseModule } from '@nestjs/mongoose';
import { BookmarkDAO } from './bookmark.dao';
import { BookmarkService } from './bookmark.service';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])
  ],
  providers: [BookmarkDAO, BookmarkService],
})
export class BookmarkModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
import { Bookmark, BookmarkSchema } from './bookmark.schema';
import { MongooseModule } from '@nestjs/mongoose';
import { BookmarkDAO } from './bookmark.dao';
import { BookmarkService } from './bookmark.service';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])
  ],
  providers: [BookmarkDAO, BookmarkService],
})
export class BookmarkModule {}

These services act as the bridge, ensuring a clean separation of concerns. Next, we will explore how to create controllers to handle incoming requests and interact with these services to perform operations on our books and bookmarks.

Handling Routes with Controllers

Controllers handle requests and respond to clients by leveraging services to execute business logic and data manipulation, which decouples HTTP handling from the application's core logic.

In the 'src/features/book' directory, we start with a 'book.controller.ts' file. This controller orchestrates API endpoints for book-related actions, relying on the BookService for CRUD operations:

src/features/book/book.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { BookService } from './book.service';
import { Book } from './book.schema';
 
@Controller('books')
export class BookController {
  constructor(private readonly bookService: BookService) {}
 
  @Post()
  async createBook(@Body() bookData: Book) {
    return this.bookService.createBook(bookData);
  }
 
  @Get()
  async getAllBooks() {
    return this.bookService.findAllBooks();
  }
 
  @Get(':id')
  async getBookById(@Param('id') id: string) {
    return this.bookService.findBookById(id);
  }
 
  @Put(':id')
  async updateBook(@Param('id') id: string, @Body() bookData: Partial<Book>) {
    return this.bookService.updateBook(id, bookData);
  }
 
  @Delete(':id')
  async deleteBook(@Param('id') id: string) {
    return this.bookService.deleteBook(id);
  }
}
src/features/book/book.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { BookService } from './book.service';
import { Book } from './book.schema';
 
@Controller('books')
export class BookController {
  constructor(private readonly bookService: BookService) {}
 
  @Post()
  async createBook(@Body() bookData: Book) {
    return this.bookService.createBook(bookData);
  }
 
  @Get()
  async getAllBooks() {
    return this.bookService.findAllBooks();
  }
 
  @Get(':id')
  async getBookById(@Param('id') id: string) {
    return this.bookService.findBookById(id);
  }
 
  @Put(':id')
  async updateBook(@Param('id') id: string, @Body() bookData: Partial<Book>) {
    return this.bookService.updateBook(id, bookData);
  }
 
  @Delete(':id')
  async deleteBook(@Param('id') id: string) {
    return this.bookService.deleteBook(id);
  }
}

The @Controller decorator signals that this class is a controller with a base route. Decorators like @Get, @Post, @Put, and @Delete designate handlers for various HTTP requests.

Similarly, the Bookmark entity:

src/features/bookmark/bookmark.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { BookmarkService } from './bookmark.service';
import { Bookmark } from './bookmark.schema';
 
@Controller('bookmarks')
export class BookmarkController {
  constructor(private readonly bookmarkService: BookmarkService) {}
 
  @Post()
  async createBookmark(@Body() bookmarkData: Bookmark) {
    return this.bookmarkService.createBookmark(bookmarkData);
  }
 
  @Get()
  async getAllBookmarks() {
    return this.bookmarkService.findAllBookmarks();
  }
 
  @Get(':id')
  async getBookmarkById(@Param('id') id: string) {
    return this.bookmarkService.findBookmarkById(id);
  }
 
  @Put(':id')
  async updateBookmark(@Param('id') id: string, @Body() bookmarkData: Partial<Bookmark>) {
    return this.bookmarkService.updateBookmark(id, bookmarkData);
  }
 
  @Delete(':id')
  async deleteBookmark(@Param('id') id: string) {
    return this.bookmarkService.deleteBookmark(id);
  }
}
src/features/bookmark/bookmark.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { BookmarkService } from './bookmark.service';
import { Bookmark } from './bookmark.schema';
 
@Controller('bookmarks')
export class BookmarkController {
  constructor(private readonly bookmarkService: BookmarkService) {}
 
  @Post()
  async createBookmark(@Body() bookmarkData: Bookmark) {
    return this.bookmarkService.createBookmark(bookmarkData);
  }
 
  @Get()
  async getAllBookmarks() {
    return this.bookmarkService.findAllBookmarks();
  }
 
  @Get(':id')
  async getBookmarkById(@Param('id') id: string) {
    return this.bookmarkService.findBookmarkById(id);
  }
 
  @Put(':id')
  async updateBookmark(@Param('id') id: string, @Body() bookmarkData: Partial<Bookmark>) {
    return this.bookmarkService.updateBookmark(id, bookmarkData);
  }
 
  @Delete(':id')
  async deleteBookmark(@Param('id') id: string) {
    return this.bookmarkService.deleteBookmark(id);
  }
}

To function within NestJS, these controllers must be registered in their respective modules. Thus, the 'book.module.ts' and 'bookmark.module.ts' are updated to include BookController and BookmarkController, making them an integral part of the module setup.

src/features/book/book.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './book.schema';
import { BookDAO } from './book.dao';
import { BookService } from './book.service';
import { BookController } from './book.controllers';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }]),
  ],
  providers: [BookDAO, BookService],
  controllers: [BookController],
})
export class BookModule {}
src/features/book/book.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Book, BookSchema } from './book.schema';
import { BookDAO } from './book.dao';
import { BookService } from './book.service';
import { BookController } from './book.controllers';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }]),
  ],
  providers: [BookDAO, BookService],
  controllers: [BookController],
})
export class BookModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
import { Bookmark, BookmarkSchema } from './bookmark.schema';
import { MongooseModule } from '@nestjs/mongoose';
import { BookmarkDAO } from './bookmark.dao';
import { BookmarkService } from './bookmark.service';
import { BookmarkController } from './bookmark.controllers';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])
  ],
  providers: [BookmarkDAO, BookmarkService],
  controllers: [BookmarkController],
})
export class BookmarkModule {}
src/features/bookmark/bookmark.module.ts
import { Module } from '@nestjs/common';
import { Bookmark, BookmarkSchema } from './bookmark.schema';
import { MongooseModule } from '@nestjs/mongoose';
import { BookmarkDAO } from './bookmark.dao';
import { BookmarkService } from './bookmark.service';
import { BookmarkController } from './bookmark.controllers';
 
@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }])
  ],
  providers: [BookmarkDAO, BookmarkService],
  controllers: [BookmarkController],
})
export class BookmarkModule {}

With our controllers in place, we're ready to test our application's endpoints to ensure everything works as expected. Testing can be done using tools like Postman or Curl, where you can make HTTP requests to create, retrieve, update, and delete books and bookmarks, observing the responses from your NestJS application.

Let's test two primary endpoints: POST /books to add a new book and GET /books to retrieve all books. (I will use Httpie client for my testing)

POST /books:

DAO Architectural Diagram illustrating a three-tiered application structure

GET /books:

DAO Architectural Diagram illustrating a three-tiered application structure

Conclusion

In this guide, we've explored the DAO pattern that helps separate business logic from data management. We created a new NestJS project and used Mongoose to develop data models. Then, we created DAOs to ensure efficient data access and crafted controllers and services to manage data flow.

Our application's scalability and maintainability can be significantly improved by adopting DAOs. This approach creates a strong foundation for future development, leading us toward modularity, manageability, and scalability.