NestJS Simplified: A Beginner's Practical Guide to Its Core Architecture

NestJS Simplified: A Beginner's Practical Guide to Its Core Architecture

It's been over a year since I started working with NestJS to build server-side applications, and I can confidently say it was one of the best decisions I’ve made. Coming from an Express.js background, the structured approach of NestJS felt a bit overwhelming at first. But once I got the hang of it, I realized how much it simplifies backend development, making applications more scalable and maintainable.

Through my experience I’ve broken down NestJS architecture into six key concepts that form the foundation. In this article I’ll talk about these concepts through a practical example by building a User Module I’ll explain how I use each concept and the order in which I apply them when creating a module in NestJS so you can follow along and build with confidence

The Core Architecture of NestJS

When I started with NestJS, the biggest shift from Express.js was its structured architecture. Unlike Express, where you have complete freedom to organize files, NestJS enforces a clear structure that makes applications scalable and maintainable. While this structure may feel overwhelming, it reduces complexity and improves development efficiency.

NestJS is inspired by Angular’s modular approach and introduces concepts like Modules, Controllers, Services, Data Access Object (DAO), Data Transfer Object (DTO), and Schemas. These patterns help write clean, reusable, and testable code. In my experience, these patterns make development much smoother, especially when working on large-scale applications or microservices.

In this section, I’ll break down the core architecture of NestJS so you can see how everything connects.

The first thing you will, obviously, require is a Module. So we will being with implementing a Module.

Modules

Modules are a fundamental part of NestJS because they provide a way to organize related components, such as controllers, providers, and services, into a single unit. This helps keep the application structured, maintainable, and scalable by ensuring that related business logic is grouped together and is not being mixed with other components of the application

The User Module

To explain things practically, a user module is the easiest and most relatable module, as almost all applications include a basic user module.

How a Module is generated?

When you create a NestJS app, it always includes an App Module, which acts as the main entry point for the application and manages all other modules.

To create the Module in NestJS, there are are 2 ways either you manually create all the required files or using the Nest CLI

I prefer using the Nest CLI to create modules because it saves time, avoids boilerplate, and keeps the structure consistent. It also reduces errors and speeds up development.

Run the below command:

nest g module user        

Structure of the User Module

Once generated, the User Module will generally consist of the following core files:

  • Module file: The main module file that acts as the entry point for your module and imports related modules.
  • Service file: A service file where the business logic for your module is implemented.
  • Controller file: A controller file where the request handling logic is written for the module's routes.

The module file is where you organize and connect everything. Typically, it includes:

  1. Imports: The imports array where other modules, like database modules, or custom modules, are imported.
  2. Controllers: The controllers array, which includes the controllers responsible for handling incoming HTTP requests.
  3. Providers: The providers array, which includes the services and any other classes (like guards or DAOs) that the module will use.
  4. Schemas: The schemas for models (like a User schema) are generally defined here for interaction with the database.

Below is how the User Module looks in practice:

Article content
user.module.ts

In this example:

  • The UserSchema is imported and associated with the module using MongooseModule.forFeature(). This defines the MongoDB schema.
  • User Controller handles HTTP requests related to users, to manage CRUD operations
  • User Service contains business logic

This is how the User Module structure looks when using NestJS, and you can follow this same pattern for any other module you create.

Add User Module to the App Module (Manually)

After creating the UserModule, you need to register it in the AppModule. This step is important because NestJS only recognizes and loads modules that are explicitly imported into the main module tree.

Article content

If you created the module manually, you'll have to add this import yourself. But if you generated the module using the Nest CLI, it automatically updates the AppModule for you.

After setting up and configuring the module, the next thing I do is create a basic Data Transfer Object (DTO) because they provide a structure how will my schema look like and what all variables are going to be included, gives you a reference point for better understanding to your schema's structure.

Data Transfer Object (DTO)

DTO is not just a concept of Nestjs but it is a design pattern that is commonly used in software development to transfer data between different layers of an application.

To put in simpler terms a DTO is an object that defines how data will be sent over the network. It acts as a contract between the client and the server, ensuring that only the necessary data is transmitted and validated.

Why Use DTO?

  • Data Structure Definition: DTOs define the shape and structure of data as it travels, particularly for incoming requests in APIs.
  • Data Validation: DTOs work hand-in-hand with validation libraries, such as class-validator, to enforce data integrity and rules.
  • Decoupling: They help in decoupling the internal data models from the data exposed to the users.

How to create a DTO

In NestJS, DTOs are implemented using TypeScript classes. TO start working with DTOs ensure you have the necessary packages installed:

npm install class-validator class-transformer        

The use of above packages enhances DTOs with validation capabilities.

  • class-validator: Provides decorators for validating DTOs.
  • class-transformer: Converts plain objects to instances of DTO classes.

You can place a DTO anywhere in your project, but a good practice is to keep it inside the module it belongs to. Since we’re working on the User Module, all user-related DTOs should be stored within the user module.

Article content
user.dto.ts

Here, we’ve used decorators from class-validator to enforce validation rules:

  • @IsString(): Ensures the field is a string.
  • @IsNotEmpty(): Ensures the field is not empty.
  • @IsEmail(): Validates the email format.
  • @MinLength(8): Ensures the password is at least 8 characters long.

Implementing a DTO in a Controller

To understand how a DTO can be practically used in a use-case, we can take controller for an example, Controllers will be discussed in detailed.

A common practice followed to use a DTO is in controllers, where a request is received. A DTO will sanitize the incoming data and then forward it to the service.

Article content
user.controller.ts

  • The @Body() decorator extracts the request body and maps it to the CreateUserReqDTO class.
  • In order for validation, that has been mentioned in DTO, to take place you must pass the ValidationPipe() as a parameter in the @Body() decorator. The validation pipe looks at the validation decorators that are defined in the DTO and checks if the incoming data complies with the rules.

Once the DTO is in place and I know what data I'm working with the next step is to define the Schema This is where I actually shape how that data will be stored and handled in the database

Schemas

After setting up the DTO the next step is to define the Schema, this is where it is decide how the data will be stored in the database. For the user module it means outlining what a user document should look like including the fields it should have and how each one should behave.

One helpful practice I follow is extending the schema class with the DTO I defined earlier This inheritance ensures that all the fields I planned for are included in the schema keeping everything in sync and reducing the chance of missing anything.

What and Why?

A schema in NestJS is used to define the structure of documents that will be stored in database's collections. While the DTO helps structure incoming data the schema ensures that data is stored in a reliable and consistent way.

For our User Module a document in the schema can contain fields like name, email, and phone number along with their types and validation rules.

  • Clear data structure — they define exactly what fields exist in a document and what type of data each field holds
  • Consistency with DTOs — when extended from a DTO they keep both the incoming and stored data aligned
  • Validation at the database level — they help catch issues before invalid data is saved

How to create a Schema

In the user's module itself you can create a file for schema as well, where you will define the schema class.

Below is a basic example of how to implement a schema:

Article content
user.schema.ts

Here’s what’s happening in the above code:

  • @Schema() decorator marks the class as a Mongoose schema. You can also pass a custom collection name using the collection option, like this

@Schema({ collection: 'custom_users' })        

This will use custom_users as the collection name instead of the default.

  • The User class extends UserDto to reuse the structure defined in the DTO and maintain consistency.
  • @Prop() decorators are used to define individual fields in the schema and specify rules like required, unique, default, type, and enum. These decorators help ensure your data behaves as expected.
  • UserDocument type combines the User class with Mongoose's Document type for better typing.
  • SchemaFactory.createForClass(User) generates the schema from the class and helps create the MongoDB collection.

A logical next step after defining the schema is determining how to access the collection. While there are various approaches, I prefer implementing a Data Access Object (DAO) layer.

Data Access Object (DAO)

In backend development, one of the core responsibilities is interacting with the database, in order to do that you can add layer between service and the database called Data Access Object (DAO).

In NestJS, a DAO is a class specifically responsible for performing database operations like CRUD and you write your own custom queries in this class as well. It serves as an abstraction layer, isolating direct database access from the service layer.

Each module typically has its own DAO class that handles all database interactions for that module only. For example, the UserDAO in a UserModule is solely responsible for managing the users collection.

Why DAO?

Before understanding why, is DAO even needed? No

In fact, official NestJS documentation does not explicitly mention or suggest the use of DAO. Instead, it emphasizes integrating with various ORM tools and database libraries, such as TypeORM, Sequelize, and Mongoose, to manage database interactions.

Developers who come from Express.js, where the convention is to directly interact a service with the database, background always consider adding DAO as an extra step.

However, as your application grows, you’ll quickly realize the benefits of this pattern.

Here are the key reasons why using a DAO layer is a smart choice:

  • Separation of responsibility: DAO keeps the code that deals with the database separate from your main business logic. This means your service code only cares about what the app does, not how the data is stored or retrieved.
  • Easy refactoring: If you need to change how your database works or update its schema, you only need to change the DAO. This keeps the rest of your code intact and easier to manage.
  • Simplified Testing: You can more easily create mocks of your database because the complete database logic is in DAO . This makes it simpler to test your app’s business logic without needing a live database.

Creating DAO

As DAO is something we add to handle database operations smoothly therefore there isn't any command provided by Nest CLI to generate it. We need to create these files manually and then inject them where intended.

For practical demonstration we will continue to extend our User module and add the User DAO Class in the module

Step 1: Create the UserDAO Class

To begin, create a user.dao.ts file in the user module folder. Below is how the UserDAO class might look:

Article content
user.dao.ts

Step 2: Inject DAO into Service

To make the DAO class available to your service you can just inject it in your UserService class by importing it in the file.

Article content
user.service.ts

Step 3: Register DAO Module

Finally, you must register your DAO in the User Module so that NestJS can manage it as a provider and make it available to be used by any other class in the module. Without registering it, NestJS wouldn’t know how to instantiate or inject the DAO when needed.

Article content
user.module.ts

With the DAO now handling all database operations for the User module, the next step is to connect this layer to the core business logic. That’s where the Service Layer comes in, acting as the bridge between our application’s features and the data managed by the DAO.

Service

Once a request reaches a controller, the next step is to process that request using the application's business logic, and that’s where services come in. Services are typically used by controllers to perform various operations, such as querying a database, processing data, or interacting with external APIs.

In our User Module, the service will sit between the controller and the DAO, receiving input from the controller, applying any business rules, and delegating database actions to the DAO.

Creating and Using the Service

To create a service, we can use the CLI command below, the command will create the service inside the User Module.

nest generate service user        

In the user module, you will find two files:

  • user.service.ts (For logic)
  • user.service.spec.ts (For unit testing)

The UserService class is automatically decorated with @Injectable() and is ready to be used in your module.

UserService Implementation

Article content
user.service.ts

Explanation of Service class:

  • @Injectable(): This decorator tells NestJS that this class can be injected as a dependency elsewhere in the application, like in the controller
  • Constructor with userDAO: The UserService depends on UserDAO to perform database operations.
  • Functions in Service: Almost each function in the service class is tied to a function in the controller class, but you can added other helper functions which act as accessory functions to the class.

With the service layer in place, the core business logic and data flow between the DAO and the rest of the application is well-handled. The last layer that brings everything together is the Controller, responsible for handling incoming requests and connecting them to the right service methods.

Controllers

A controller is responsible for handling incoming requests, it acts as the entry point for the application’s routes. Each controller is linked to a specific route path and uses decorators like @Get(), @Post(), etc., to define how it responds to different types of requests.

The main purpose of a controller is to help navigate a request to its intended service it does not contain business logic, instead, they delegate tasks to services.

How to Create a Controller

You can create a controller manually or use the Nest CLI. The CLI is recommended as it sets up everything for you.

Since we've already created a UserModule, the next step is to define the UserController.

nest g controller user        

This command generates user.controller.ts and optionally a test file user.controller.spec.ts.

Structure of a Controller

A typical controller uses these decorators:

  • @Controller() defines the route prefix for the controller.
  • @Get(), @Post(), @Put(), @Delete() define HTTP method handlers.
  • @Body() extracts data from the request body.
  • @Param() gets route parameters.
  • @Query() retrieves query string parameters.
  • @Req() and @Res() access the full request or response objects if needed.

Routing with Controllers

In NestJS, routing is defined using decorators. These decorators are special functions prefixed with @, and they provide metadata to the framework, helping it understand how different routes should behave within your application.

Each route corresponds to a method in your controller, and you can use decorators like @Get(), @Post(), @Put(), and @Delete() to handle different types of HTTP requests.

Let’s extend our UserController to demonstrate how routing works with different HTTP methods:

Article content
user.controller.ts

  • @Controller('user'): Specifies that all routes in this controller will be prefixed with /user, this is the base of each route in the class.
  • @Get(), @Post(), @Put(), @Delete(): These decorators define the CRUD functionality.
  • @Body(): Extracts the body of the incoming request.
  • @Param(): Retrieves route parameters from the URL, such as /user/:id, where id is a route parameter.

By the end of setting up the controller, the module is completed with essentials, routes defined, services connected, and data flowing properly. This layer ties everything together and makes the module ready to handle real requests.

Ending Note

Looking back at my journey with NestJS, understanding these core building blocks, Modules, Controllers, Services, DTOs, Schemas, and DAOs, has made a huge difference in how I approach backend development.

At first, the structure felt like a lot to take in, but once I started using them together in real projects, everything started to make sense. These concepts aren’t just theory, they shape how I build and scale features every day.

I’ve shared how I use them, through my experience in building backend systems, step by step in a real module so you can see how they come together in practice. If you're just starting out or trying to structure your code better, I hope this breakdown gives you a solid starting point the way it once did for me.

Adeel Irshad

Backend & Cloud Engineer | Building High-Performance Systems in Fintech & HealthTech | Python, Node.js, Kubernetes, AWS

3mo

Well put, Ahmed

To view or add a comment, sign in

Others also viewed

Explore topics