NestJS
Welcome, here is what you will be doing in the next two weeks:
- Brush up on your NestJS knowledge and read through the materials provided by DECODE.
- Develop a simple RESTful application to practice your NestJS skills.
- For example, a library application where users can borrow books (or any other idea for a simple application where a user has control over some resource).
- The focus is on the backend, the frontend part can be omitted.
- As you progress, make a new commit every time you finish implementing something (so we can track your progress and give you some useful feedback along the way).
- For example, when you finish setting up your project, make a new commit.
- Suggested "commit" points are marked as little code blocks like this -> Commit Point.
- Use Git Flow branching strategy as described on our confluence page.
- Each task is started on its own feature branch.
- After the task is done and when you see a Merge Request annotation, make a merge request from your feature branch to the develop branch for your onboarding buddy to take a final look.
You should have enough time to finish this project in 10 days, so take it slowly to learn as much as you can.
What you'll build
A RESTful API for a library system with:
- User authentication and authorization (sign up/login with JWT)
- Complete CRUD operations for books
- Input validation and error handling
- API pagination and filtering
- Automated testing
- API documentation with Swagger
- Database integration with Prisma ORM
- Containerized development environment
Before you start
- Install Node.js and nvm (or another node version manager of your choice). You can find detailed instructions in the official documentation.
- Get familiar with the official NestJS docs.
- We're going to keep it simple and use NestJS for setting up the project structure. If you want to explore more, check out the NestJS Project structure documentation.
- If you're not familiar with TypeScript, you should check out their official documentation and find out why it's so popular.
- If you're unfamiliar or have never worked with Git, read up on the basic commands. Also, some of us use SourceTree GUI, which provides a nice user interface for all Git-related actions. Check out Git - Book, Sourcetree | Free Git GUI for Mac and Windows, Git-FER.
- In this onboarding, we're using npm as the package manager, but of course, if you want to use yarn, that's also fine. Here are the yarn docs if you want more info.
- We will use Docker and Docker Compose to build and run our application. Check out the documentation here: Dockerfile spec, Compose file spec.
Setting up the boilerplate
As your first task, you'll be setting up your project boilerplate with the NestJS CLI. With npm installed, you can create a new NestJS project with the following commands in your terminal:
npm i -g @nestjs/cli
nest new <project-name>
Folder structure
Once NestJS is done with setup, the folder structure will look something like this:
├── src
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ └── main.ts
├── test
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── .gitignore
├── .prettierrc
├── eslint.config.mjs
├── nest-cli.json
├── package.json
├── README.md
├── tsconfig.build.json
└── tsconfig.json
We can clean up some files we will not need, like the app.controller.spec.ts, app.controller.ts, and app.service.ts. Remember to remove any unused imports from app.module.ts after deleting these files. Go through the remaining files and familiarize yourself with the repo.
Code style
ESLint is a powerful tool for identifying and fixing problems in your JavaScript and TypeScript code, enforcing consistent coding standards, and catching potential bugs early. Prettier is a code formatter that automatically formats your code according to a defined style, making it easier to read and maintain. Using both tools in a team ensures everyone writes code in a consistent way, minimizing style-related merge conflicts. If you have never used ESLint or Prettier before, make sure to check out their official documentation.
To automate code quality, you can set up linting and formatting to run on file save in your editor (e.g., by enabling the ESLint and Prettier extensions in VS Code and configuring "editor.formatOnSave": true in your settings). For even stricter enforcement, use lint-staged and husky to run ESLint and Prettier on staged files before each commit. Install them with:
npm install --save-dev husky lint-staged
Then configure lint-staged in your package.json:
"lint-staged": {
"*.{js,ts}": "npm run lint",
"*.{js,css,md,ts,html}": "npm run format"
}
Finally, initialize Husky:
npx husky init
And edit the contents of the created .husky/pre-commit file to enable the Husky pre-commit linting hook:
npx lint-staged
This setup ensures all code is automatically linted and formatted before it reaches the remote repository, keeping our codebase clean and consistent.
1st Commit point
We’ve finished setting up our project. Commit your changes and push them as an initial commit to your onboarding repo. Check out our standard for writing commit messages. During this onboarding, you will mostly add feat commits for your onboarding ticket. Your first commit message will look something like this:
feat(RD-305): initialize repository
RD-305 is an example ticket ID. Make sure to use your own onboarding ticket ID here and in future commits, so all your work is correctly linked to the ticket.
Now is a good time to check out our git policy. Create a new develop branch from the main branch and push it to the remote repository. From this point forward, direct commits on the main and develop branches are forbidden, and we will continue our work using feature branches and pull requests.
If you have found a bug or, of course, a better way to implement something, please share your knowledge with us. We will gladly accept your suggestions that benefit this onboarding!
Database setup
To develop and test your NestJS onboarding app, you'll need a local PostgreSQL database. Follow these steps to set up PostgreSQL for development.
Install and configure PostgreSQL
If you don’t have PostgreSQL installed, you can use Homebrew on macOS:
brew install postgresql
brew services start postgresql
Then, create a postgres user and an empty database to use in your project. For example:
psql postgres
CREATE USER postgres WITH PASSWORD 'test123';
CREATE DATABASE nestjs_onboarding;
GRANT ALL PRIVILEGES ON DATABASE nestjs_onboarding TO postgres;
Alternatively, you can use Docker to run PostgreSQL in a container. Here is an example docker-compose.yml which starts your database:
services:
postgres:
image: postgres:16
container_name: psql
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test123
POSTGRES_DB: nestjs_onboarding
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- nest_net
restart: unless-stopped
volumes:
postgres_data:
networks:
nest_net:
driver: bridge
If psql was started using the Docker container, the database set in POSTGRES_DB env will be created on startup. Otherwise, create a database using the psql CLI:
createdb nestjs_onboarding
Add your database connection details to a .env file in your project root:
DATABASE_URL="postgresql://postgres:test123@localhost:5432/nestjs_onboarding?schema=public"
Integrate database with ORM
NestJS supports several ORMs for database integration, such as TypeORM, Sequelize, and Prisma. Choose and set up the one that best fits your workflow.
For example, if you’re using Prisma, install it using npm, then initialize it in your project:
npm install @prisma/client
npm install --save-dev prisma
npx prisma init
This creates a prisma/schema.prisma file. Update the datasource block to use your DATABASE_URL, and define the User and Book models:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
firstName String?
lastName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
books Book[]
@@map("users")
}
model Book {
id Int @id @default(autoincrement())
isbn String @unique
title String
author String
publishYear Int
genre String?
description String?
isAvailable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
borrowedBy User? @relation(fields: [userId], references: [id])
userId Int?
@@map("books")
}
Read more about how to define your models in the Prisma Models documentation.
After defining your models in schema.prisma, run the following to create tables in the database and generate Prisma client files:
npx prisma migrate dev --name init
Make sure to familiarize yourself with all the Prisma Migrate options.
Test the connection by running Prisma Studio, where you can view and manage your database:
npx prisma studio
Now your PostgreSQL database is ready for development! This is a good Commit Point. Start a new branch named feat/<JIRA-ID>-user-auth (we will focus on this feature in the next chapter) and commit your changes to the database schema.
In a production database, our schema file will contain many more models. To keep the codebase clean, we usually split the models into several .prisma files. Read more about it in the Multi-file Prisma schema documentation.
Add modules to your project
Instead of Package by Layer (typical for monolithic architecture), a modular approach should be followed in our projects by using Package by Feature and Domain-driven design. All items related to a single feature should be placed into a single directory/package. The result is packages that are minimally coupled together but have high cohesion and modularity. In an ideal scenario, the package will not be used by any other feature in the application, and that way, it may be removed by simply deleting the directory. But the package-by-feature idea doesn't imply that one package can never use items from other packages.
If you are new to this approach, please read Package by Feature and Package by Layer vs Package by Feature.
For NestJS, modules are really important. The global file app.module.ts will be the main module of our app that will import the other modules. For instance, our library app will have the following modules: auth, book, database, and profile.
Read more about modules, controllers, and providers in the NestJS documentation.
Auth module
The goal of this task is to create an API for new users to sign up and log into our application. To create a module using the CLI, simply execute the command:
nest g module auth
Additionally, you can generate the authentication service and controller using the CLI:
nest g service auth --no-spec
nest g controller auth --no-spec
The --no-spec flag disables spec files generation.
Controller
We need to implement two endpoints: signup (for new users) and login (for existing users). Controllers are responsible for handling incoming requests and sending responses back to the client. The routing mechanism determines which controller will handle each request. Often, a controller has multiple routes, and each route can perform a different action.
Define two POST routes in the auth.controller.ts file, for example:
import { Controller, Post, Body } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { AuthDto } from "./dto";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Post("signup")
signup(@Body() dto: AuthDto) {
return this.authService.signup(dto);
}
@Post("login")
login(@Body() dto: AuthDto) {
return this.authService.login(dto);
}
}
DTO and validation
NestJS provides a robust validation system that helps ensure incoming data is correct and secure before it reaches your business logic. Validation is typically implemented using Data Transfer Objects (DTOs), which define the expected structure and rules for request payloads. By combining DTOs with NestJS's built-in validation pipes, you can automatically check and transform incoming data, reject invalid requests, and enforce strict contracts between your API and its consumers. This approach keeps your controllers clean and your application resilient against malformed or malicious input.
NestJS makes use of the powerful class-validator package. Check out the documentation for more options. Make sure to install the validator and transformer packages in your project:
npm install class-validator class-transformer
In our auth example, when a user signs up or logs in, the DTO specifies what fields (like email, password) must be present and their types, ensuring that incoming data is correct before processing.
Create a dto folder inside the auth resource and define the auth.dto.ts as:
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
export class AuthDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsNotEmpty()
@IsString()
password: string;
}
Make sure to include a barrel file (dto/index.ts) because it centralizes and simplifies access to DTOs. Instead of importing individual DTOs from their specific files, you can import everything from the dto directory, making your codebase cleaner and easier to maintain.
export * from "./auth.dto";
Another important step in securing and validating your API is enabling the global validation pipe with the whitelist: true option. This configuration ensures that only properties explicitly defined in your DTOs are accepted in incoming requests, automatically stripping out any unexpected fields. By doing so, you prevent clients from sending unwanted data and reduce the risk of security vulnerabilities. To enable this, add the following to your main.ts file:
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
})
);
await app.listen(3333);
}
bootstrap();
Service
Finally, we have to implement the auth service. NestJS services are classes designed to encapsulate business logic and reusable functionality within an application. Unlike controllers, which handle HTTP requests and responses, services focus on processing data, interacting with databases, and implementing core features. Services are typically injected into controllers using NestJS’s dependency injection system, promoting modularity and testability. By separating concerns, services help keep controllers clean and maintainable, making it easier to scale and organize complex applications.
Here is an example basic service for our auth module:
import { Injectable } from "@nestjs/common";
import { AuthDto } from "./dto/auth.dto";
@Injectable()
export class AuthService {
constructor() {}
signup(dto: AuthDto) {
return {
msg: `welcome ${dto.email}!`,
};
}
login(dto: AuthDto) {
return {
msg: `welcome back ${dto.email}.`,
};
}
}
Run your NestJS app (hint: npm run start:dev) and test it manually using Postman, cURL, or another tool of your choice. Try different endpoints (the app should return HTTP Status 404 for non-existing paths), or try sending an invalid email/password in the body (the app should return 400).
For now, the service only returns a response message, but in the next steps, we will implement the database integration.
Configuration
NestJS provides a robust configuration management system using the @nestjs/config package (install it using npm). This module allows you to load environment variables from .env files and access them throughout your application using dependency injection. Configuration values can be validated and organized into custom configuration files, making it easy to manage settings for different environments (development, production, etc.).
Set up a global ConfigModule in the app.module.ts:
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
],
})
Now we can import the ConfigService in any of our other modules and fetch environment variables (for example, the database URL).
Read all about configuration in the NestJS documentation. Give special focus to the Custom env file path, Custom configuration files, and Custom validate function chapters, as these are some concepts we regularly use in our projects.
Database global service
To integrate your ORM with NestJS and make its functionality available across your entire application, you should create a dedicated database service and register it as a global provider using the @Global() decorator. Once registered globally, the database service can be injected into any other module or provider throughout your app, streamlining database access and promoting code reuse. Check the documentation for Global modules.
To achieve this in our onboarding app, generate a database service and module:
nest g module database
nest g service database --no-spec
Mark the module as global and export the Prisma service:
import { Global, Module } from "@nestjs/common";
import { DatabaseService } from "./database.service";
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
Implement the Prisma client in the service:
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class DatabaseService extends PrismaClient {
constructor(config: ConfigService) {
super({
datasources: {
db: { url: config.get<string>("DATABASE_URL") },
},
});
}
}
The DatabaseService is now available to all our modules in the app.
Let's try it out in our AuthService. Inject the DatabaseService through the class constructor. Notice the use of the private keyword. This shorthand allows us to both declare and initialize the DatabaseService member in the same line, streamlining the process.
Now we can call the ORM methods in our service, so let's create a new user in the database on sign up:
import { ConflictException, Injectable } from "@nestjs/common";
import { AuthDto } from "./dto/auth.dto";
import { DatabaseService } from "../database/database.service";
import { Prisma } from "@prisma/client";
import * as bcrypt from "bcrypt";
@Injectable()
export class AuthService {
constructor(private db: DatabaseService) {}
async signup(dto: AuthDto) {
try {
// hash the password using bcrypt
const passwordHash = await bcrypt.hash(dto.password, 10);
// save the new user in the db
const user = await this.db.user.create({
data: {
email: dto.email,
password: passwordHash,
},
select: {
id: true,
},
});
return {
msg: `welcome ${user.id}.`,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
throw new ConflictException("Credentials taken");
}
}
throw error;
}
}
login(dto: AuthDto) {
return {
msg: `welcome back ${dto.email}.`,
};
}
}
In this example, we also catch the Prisma duplicate error and throw a ConflictException in that case (email is already claimed). If we didn't throw 409, the user would get a generic HTTP 500 status code, and that is not a good practice.
Implement the login method on your own using everything we learned so far. Keep it simple for now - check if the user exists in the database and check if the provided password matches the one in the database. On error return 401, on success return 200.
JWT strategy
JWT (JSON Web Token) is commonly used for authentication and authorization in web applications. If you are unfamiliar with the concept, be sure to check out the official documentation. The site also has a handy JWT Debugger tool for reading the payload and checking the signature.
NestJS integrates seamlessly with Passport, a popular authentication library for Node.js. To implement JWT authentication using Passport, follow these steps.
First, install all the necessary dependencies.
npm install @nestjs/passport passport passport-jwt @nestjs/jwt
Then import and configure the JwtModule in your auth.module.ts:
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.register({}),
// other imports...
],
// providers, controllers...
})
Generate a new file strategy/jwt.strategy.ts in your auth module:
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: any) {
return { userId: payload.sub, email: payload.email };
}
}
The validate method in the JwtStrategy class is called automatically by Passport when a request contains a JWT in the Authorization: Bearer <token> header, and you use the JWT guard to protect a route.
When a protected route is accessed, Passport extracts the JWT from the request header and verifies the token using the secret key. If the token is valid, Passport calls the validate method and passes the decoded payload, which can then be used in our code.
Finally, you have to register the strategy in your auth module’s providers.
Now you can use the JwtService to sign tokens after successful signup or login and return the generated access token in your AuthService:
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class AuthService {
constructor(private db: DatabaseService, private jwtService: JwtService) {}
async login(dto: AuthDto) {
// ...check user and password, then generate access token if OK
const payload = { sub: user.id, email: user.email };
const token = await this.jwt.signAsync(payload, {
expiresIn: "15m",
secret: process.env.JWT_SECRET,
});
return {
access_token: token,
};
}
}
For more details, see the NestJS JWT authentication and NestJS Passport documentation.
Profile module and NestJS guard
Now that we have a JWT strategy in place, let's create a route to protect. Generate a Profile module (with a controller and service) using NestJS CLI. Create two endpoints: GET /profile and PATCH /profile.
In order to protect the routes behind the JWT auth, use NestJS Guards.
Implement a simple JWT Guard in your auth module. Create a file auth/guard/jwt.guard.ts:
import { AuthGuard } from "@nestjs/passport";
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor() {
super();
}
}
Now apply the guard decorator to the endpoints you wish to protect. Here is an example profile.controller.ts with implemented guard:
import { Controller, Get, UseGuards, Patch, Body } from "@nestjs/common";
import type { User } from "@prisma/client";
import { AuthUser } from "../auth/decorator";
import { JwtAuthGuard } from "../auth/guard";
import { EditUserDto } from "./dto";
import { UserService } from "./user.service";
@UseGuards(JwtAuthGuard)
@Controller("profile")
export class UserController {
constructor(private userService: UserService) {}
@Get()
getProfile(@AuthUser() user: User) {
return this.userService.getProfile(user);
}
@Patch()
editProfile(@AuthUser("id") userId: number, @Body() dto: EditUserDto) {
return this.userService.editProfile(userId, dto);
}
}
Notice how the example uses the @AuthUser decorator. This is not one of the predefined NestJS decorators but a custom decorator created to extract the user information from the token. Create it in auth/decorator/user.decorator.ts:
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const AuthUser = createParamDecorator(
(field: string = "", ctx: ExecutionContext): unknown => {
const request: Express.Request = ctx.switchToHttp().getRequest();
if (request.user && typeof request.user === "object") {
return field
? (request.user as Record<string, unknown>)[field]
: request.user;
}
return undefined;
}
);
Finish the profile service on your own. Implement a getProfile() method that will fetch the logged in user's first and last name, and a editProfile() method that will accept a new EditUserDto and update the user's information in the database. Be careful to never return the user's password in the response JSON.
1st Merge Request
You have finished your first feature. Make sure all code is linted and formatted, then commit your changes using our guidelines. Open a pull request and assign your onboarding buddy as the reviewer.
While your buddy reviews the pull request (keep in mind that they may need some time to respond due to other work commitments), continue working by creating a new feature branch and progressing with the onboarding steps. You can come back to the PR if needed to resolve comments and update code.
Once the feature PR is approved, we can merge it into the develop branch. Always use the "Squash and Merge" strategy when merging a PR. This merges all commits into one, creating a clean history without multiple minor commits cluttering the project. The squash commit message should be meaningful and concise, summarizing the overall change.
CRUD API
In this chapter, we will focus on creating a CRUD API for managing books. The API should have end-to-end tests written in Jest (or another testing framework of your choice) and an OpenAPI Specification.
Create books resource
Start a new branch named feat/<JIRA-ID>-books-crud and generate the Book resource using NestJS CLI:
nest g resource book --no-spec
Implement the following endpoints:
- GET /books (return all books)
- GET /books/:id (return one book by its ID)
- POST /books (create book)
- PATCH /books/:id (update one book by its ID)
- DELETE /books/:id (delete one book by its ID)
This is a good Commit Point.
Write e2e tests
NestJS has built-in support for automated testing using Jest. It encourages writing both unit and end-to-end (e2e) tests to catch bugs early and ensure your application works as expected. Unit tests focus on individual components, while e2e tests validate the behavior of your API by simulating real HTTP requests.
NestJS comes with Supertest out of the box for HTTP API testing, but we use PactumJS in our projects instead, as it is more feature-rich. Make sure to install it:
npm i --save-dev pactum
Before writing tests, you need to prepare the testing environment. First, deploy a testing database (separate from your development environment) that you can freely reset each time you run tests. It would be best to run the testing database using Docker Compose and to prepare an npm script for restarting the database before each e2e test run.
An example docker-compose-test.yml:
psql-test:
postgres:
image: postgres:16
container_name: psql-test
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test123
POSTGRES_DB: test
ports:
- "5433:5432"
restart: unless-stopped
You will also need a different .env file that points to the test db instead of the development one, for example .env.test:
DATABASE_URL="postgresql://postgres:test123@localhost:5433/test?schema=public"
JWT_SECRET='super_secret_jwt_test_key'
Install the dotenv package globally to use it for loading test environment variables.
Lastly, update the package.json with a few new scripts:
"scripts": {
"db:test:rm": "docker-compose -f docker-compose-test.yaml rm psql-test -f -s -v",
"db:test:up": "docker-compose -f docker-compose-test.yaml up psql-test -d",
"pretest:e2e": "npm run db:test:rm && npm run db:test:up && sleep 1 && dotenv -e .env.test -- prisma migrate deploy",
"test:e2e": "dotenv -e .env.test -- jest --no-cache --config ./test/jest-e2e.json"
},
To set up our testing suite, we need to instantiate and compile the NestJS TestingModule as described in the NestJS testing documentation. For e2e tests, we utilize the createNestApplication() method to instantiate a full Nest runtime environment. Here is the example test suite with beforeAll and afterAll hooks:
import { Test } from "@nestjs/testing";
import { AppModule } from "../src/app.module";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import * as pactum from "pactum";
describe("App e2e", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
})
);
await app.init();
await app.listen(3333);
pactum.request.setBaseUrl("http://localhost:3333");
});
afterAll(async () => {
await app.close();
});
});
Here’s a simple e2e test suite for the Auth module using PactumJS:
describe("Auth", () => {
const dto: AuthDto = {
email: "test@example.com",
password: "test123",
};
describe("Sign UP", () => {
it("should throw if email empty", () => {
return pactum
.spec()
.post("/auth/signup")
.withBody({ password: dto.password })
.expectStatus(400);
});
it("should throw if password empty", () => {
return pactum
.spec()
.post("/auth/signup")
.withBody({ email: dto.email })
.expectStatus(400);
});
it("should throw if no body provided", () => {
return pactum.spec().post("/auth/signup").expectStatus(400);
});
it("should sign up if correct body", () => {
return pactum.spec().post("/auth/signup").withBody(dto).expectStatus(201);
});
it("should throw if duplicate email", () => {
return pactum.spec().post("/auth/signup").withBody(dto).expectStatus(409);
});
});
describe("Sign IN", () => {
it("should throw if email empty", () => {
return pactum
.spec()
.post("/auth/signin")
.withBody({ password: dto.password })
.expectStatus(400);
});
it("should throw if password empty", () => {
return pactum
.spec()
.post("/auth/signin")
.withBody({ email: dto.email })
.expectStatus(400);
});
it("should throw if no body provided", () => {
return pactum.spec().post("/auth/signin").expectStatus(400);
});
it("should throw if password empty", () => {
return pactum
.spec()
.post("/auth/signin")
.withBody({ email: dto.email })
.expectStatus(400);
});
it("should sign in", () => {
return pactum
.spec()
.post("/auth/signin")
.withBody(dto)
.expectStatus(200)
.stores("userAccessToken", "res.body.access_token");
});
});
});
PactumJS can extract values from the response and store them in variables using the stores() method. This allows us to refer to the saved data in subsequent tests using the special syntax $S{<variable>}. When testing guarded endpoints, send the saved userAccessToken in the Authorization header. Read up on PactumJS Data Management.
As a practice exercise, cover all other endpoints with tests. Test all positive scenarios (HTTP 2xx response) and as many negative scenarios as you can think of (HTTP 4xx response) to make sure your code handles even the unexpected. Check out the Node.js testing best practices for guidance.
To run your e2e tests, use the following command:
npm run test:e2e
This will trigger the pretest script (which restarts the test database and applies prisma migrations), then execute all tests in the test directory and show the results in your terminal.
Once you are satisfied with the test coverage, you reached another Commit Point.
Integrate swagger docs
OpenAPI is a widely adopted specification for describing RESTful APIs in a standardized, machine-readable format. Swagger is a set of tools that work with OpenAPI, enabling automatic generation of interactive API documentation. In NestJS, you can integrate Swagger using the @nestjs/swagger package, which reads your controller and DTO metadata to produce OpenAPI-compliant docs.
NestJS provides extensive documentation for OpenAPI/Swagger integration. To set up Swagger, install the package:
npm install --save @nestjs/swagger
Then, in your main.ts, initialize Swagger:
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle("Library API")
.setDescription("API documentation for the library system")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api", app, document);
await app.listen(3333);
}
bootstrap();
To document your endpoints, use decorators like @ApiTags, @ApiOperation, @ApiBody, and @ApiResponse:
import { ApiBody, ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
@ApiTags("books")
@Controller("books")
export class BookController {
@ApiOperation({ summary: "Create a new book" })
@ApiBody({ type: CreateBookDto })
@ApiResponse({ status: 201, description: "Book created" })
@ApiResponse({ status: 400, description: "Validation error" })
@ApiResponse({ status: 401, description: "Unauthorized" })
@Post()
createBook() {
/* ... */
}
}
You can add multiple @ApiResponse decorators to describe different possible HTTP responses, such as 400 for validation errors and 401 for unauthorized access. This helps consumers of your API understand what error responses to expect.
For DTOs, use the @ApiProperty decorator to describe each field:
import { ApiProperty } from "@nestjs/swagger";
export class CreateBookDto {
@ApiProperty({
example: "978-3-16-148410-0",
description: "The International Standard Book Number",
})
isbn: string;
@ApiProperty({
example: "The Goldsmith's Treasure",
description: "The book's title",
})
title: string;
}
This ensures your API documentation includes clear examples and descriptions for request and response payloads.
Once set up, start your app and visit http://localhost:3333/api to view and interact with your API documentation.
Once you have completed the documentation for all endpoints using Swagger decorators, this is a good Commit Point.
2nd Merge Request
Once all the tests are passing and the API documentation is ready, open a new PR and assign it to your buddy.
Finishing touches
As you wrap up your onboarding project, consider learning about some additional NestJS features that can further enhance your application and broaden your skill set. These tools and techniques are widely used in production environments and will help you build more robust, maintainable, and scalable APIs.
Feel free to explore as many topics as you can with the time you have left. Each feature you try will deepen your understanding and prepare you for real-world projects.
Logging middleware
NestJS Middleware functions run before route handlers and are useful for logging, authentication, or request modification.
Here’s how to create a logging middleware in NestJS that logs the time, HTTP method, and path. First, generate the middleware in common/logger.middleware.ts:
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(
`[${new Date().toISOString()}] Requested ${req.method} ${req.url}`
);
next();
}
}
Then, apply the middleware in your app.module.ts:
import { MiddlewareConsumer, Module, RequestMethod } from "@nestjs/common";
import { LoggerMiddleware } from "./common/logger.middleware";
@Module({
// ...imports, controllers, providers
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes({ path: "*", method: RequestMethod.ALL });
}
}
Restful API best practices
Go through our Restful API best practices guide and implement as many as you can in your onboarding project.
Add API versioning, fix endpoint paths as needed, check response API codes, add filtering and pagination to GET routes.
Task Scheduling
Task scheduling in NestJS allows you to automate recurring tasks such as sending notifications, cleaning up expired records, or generating reports. For example, in a library app, you might want to send reminder emails to users who have overdue books. Using the @nestjs/schedule package, you can set up a cron job that runs every day at midnight to check for overdue books and trigger email notifications. Here’s a simple example:
import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { BookService } from "./book.service";
@Injectable()
export class TasksService {
constructor(private bookService: BookService) {}
@Cron("0 0 * * *") // Runs every day at midnight
async handleOverdueReminders() {
const overdueBooks = await this.bookService.findOverdueBooks();
overdueBooks.forEach((book) => {
// Send reminder email to book.borrower
});
}
}
Caching
NestJS provides built-in caching support via the @nestjs/cache-manager package, which can help speed up frequently accessed endpoints by storing their responses in memory or external stores like Redis. For example, in a library app, you might want to cache the results of the GET /books endpoint to reduce database load. First, install the package:
npm install @nestjs/cache-manager
Then, import and configure the CacheModule in your app.module.ts:
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [
CacheModule.register({ isGlobal: true }),
// other modules...
],
})
In your BookService, you can use the injected Cache to store and retrieve cached data:
import { Injectable, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import type { Cache } from "@nestjs/cache-manager";
@Injectable()
export class BookService {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async getAllBooks(): Promise<Book[]> {
const cached = await this.cacheManager.get<Book[]>("books");
if (cached) {
return cached;
}
const books = await this.fetchBooksFromDb();
await this.cacheManager.set("books", books, 600000); // cache for 10 minutes
return books;
}
}
Containerization
Containerization allows you to package your application and its dependencies into a single, portable image, ensuring consistent behavior across different environments. Docker is the most popular containerization platform, and it integrates seamlessly with NestJS projects. By using a multi-stage Docker build, you can optimize your image size and separate the build environment from the runtime environment. Here’s an example Dockerfile for a NestJS project:
# Stage 1: Build
FROM node:22.19.0-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
# Stage 2: Production
FROM node:22.19.0-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/prisma ./prisma
EXPOSE 3333
CMD ["node", "dist/main"]
To build and run your container, use:
docker build -t nestjs-app .
docker run -p 3333:3333 nestjs-app
This approach ensures your final image contains only the compiled code and production dependencies, resulting in faster startup and smaller image size.
🎉 🎉 🎉 🎉 🎉
3rd Merge Request
As always, when you’ve finished all of the work, create a pull request for your feature branch and call in your buddy.