Ok, let's create a simple CRUD backend API for your app using the:
NestJS
MongoDB
Docker
Docker Compose
Mongoose
Typescript
What you're learn
In this tutorial you will learn how to create a basic API using Nestjs as the core backend, MongoDB as your database, Mongoose as the database client to connect with NestJS, Docker & Docker Compose as your application runner in the container.
We will make something basic just running the crud operation for user data including the name, email, and bio. You can customize and expand this tutorial based on your preference. At the end of the tutorial, I will share the project repository, so you can easily clone and try it on your machine computer.
Architecture
So, before creating the application let's see how the application work, and how the application can be structured.
Create application
First, we need to create our base application using NestJS. Start to create boilerplate code by running
npx @nestjs/cli new nestjs-backend-api
This command will trigger the NestJS cli to create a Nest application starter. Nest also already install some dependencies for the application. Wait a second after the installation is finished.
Install dependencies
Let's install some dependencies to support the application. I will cover and explain what, and why you should install this dependency in your application.
Now run this command in your application terminal.
npm i -S @nestjs/config @nestjs/mongoose @nestjs/swagger @nestjs/throttler class-transformer class-validator compression helmet mongoose
By running the command above you will be install several dependencies including:
@nestjs/config - Allow to resolve the configuration including using the .env file
@nestjs/mongoose - The mongoose module in nestjs
@nestjs/swagger - Allow NestJS to use OpenAPI Specification, and use the mapped type including partial, omit, etc.
@nestjs/throttler - Rate limiter module for application
class-transformer, class-validator - Used to manage the application object transform and validation.
compression - Allow to compress the body response through Gzip
helmet - App protection to avoid the brute force XSS
mongoose - Using to connect MongoDB to the application
Update the project script
Update the script to become more clear. We need to change the preview and start using a new command.
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"preview": "nest start",
"dev": "nest start --watch",
"debug": "nest start --debug --watch",
"start": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
After changing to this object, now you can easily run applications using
npm run dev To run on a development server
npm run preview Running the prebuild application to preview
npm run start To run the application on production mode
Bootstrap the application
Now let's jump into the src/main.ts file. Then change the code using this code.
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { ValidationPipe, VersioningType } from '@nestjs/common'
import helmet from 'helmet'
import * as compression from 'compression'
const PORT = parseInt(process.env.PORT, 10) || 4000
async function bootstrap() {
const app = await NestFactory.create(AppModule)
// register all plugins and extension
app.enableCors({ origin: '*' })
app.useGlobalPipes(new ValidationPipe({}))
app.enableVersioning({ type: VersioningType.URI })
app.use(helmet())
app.use(compression())
await app.listen(PORT, () => {
console.log(`๐ Application running at port ${PORT}`)
})
}
bootstrap()
lets me explain. First, we need to import all dependencies, and settings into. Then start to use some middleware including the compression, and helmet. Then start o enable the cross-origin sharing resources. You also can change the Cors settings.
Enable to global validation pipe, by using global validation, we automatically use the class-transform and class-validator class. When something error happens in the object, the error will be shown as a response.
Enable the versioning by using URI Type. You can easily manage the version in the controller. This way helps you add some changes in your API without effect the below version.
Wrap the app module
To ensure all applications running you should import all feature modules and another module into the application module. Because if you see in the main.ts file the best application is to create the app from AppModule.
Change the app module using this code
import { Module } from '@nestjs/common'
import { AppController } from './app.controller'
import { ConfigModule } from '@nestjs/config'
import { ThrottlerModule } from '@nestjs/throttler'
import { UserModule } from './user/user.module'
import { MongooseModule } from '@nestjs/mongoose'
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot({ limit: 10, ttl: 60 }),
MongooseModule.forRoot(process.env.DATABASE_URI, {
dbName: process.env.DATABASE_NAME,
auth: {
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASS,
},
}),
// feature module
UserModule,
],
controllers: [AppController],
})
export class AppModule {}
First, we define the class with a module decorator and define the properties inside. Inside the import is used to import the application module. From, your local module or from dependencies.
So first let's import the Config module, remember the isGlobal is set to true. This will allow you to use your configuration, environment, and settings inside all of your application scopes.
Add the Throttler module and give the limit and ttl. This will protect our backend API from brute-force attacks. The user will only allow access to the same API resource up to 10 times, then wait a minute to try. If you wonder about this, you can learn more about brute force attack security.
Install the Mongoose module to connect with MongoDB. Please specify the URI, and the authentication using username, and password. I will cover this using the .env file later. So stay tuned.
Install the feature module, we will cover this later.
Create User feature
Moving into the user feature, we will need set up and create a new module called. User. inside the user feature, we will handle the user information including username, password, bio, email, and full name.
In this module, we will handle the CRUD a.k.a (Create, Read, Update, Delete) operation for the user. So with that said let's jump into it.
Inside the src folder create a new folder called user. All of your module files including schema, service, provider, and model inside this folder.
so make a new file called user.schema.ts
. inside the model folder Then paste this code inside
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument } from 'mongoose'
export type UserDocument = HydratedDocument<User>
@Schema({ collection: 'users', timestamps: true })
export class User {
@Prop()
fullName: string
@Prop()
email: string
@Prop()
bio: string
@Prop()
password: string
}
export const UserSchema = SchemaFactory.createForClass(User)
this file that is used to manage the user collection on MongoDB. using timestamp allows us to automatically add the createdAt and updatedAt properties in Mongodb. After the model was defined. We need to create the user's schema using schema factory from Mongoose.
Create a new file called user.input.ts inside the model folder. Then put this code inside.
import { OmitType } from '@nestjs/swagger'
import { IsEmail, IsString } from 'class-validator'
export class CreateUserInput {
@IsString()
fullName: string
@IsEmail()
email: string
@IsEmail()
bio: string
@IsString()
password: string
}
export class UpdateUserInput extends OmitType(CreateUserInput, [
'password'
] as const) {}
This model is specific to using input from the controller. So we set all input in one place. Don't forget to use the class validator to validate the input before the user is processed.
The UpdateUserInput extends the omit type from CreateUserInput class. This comes from @nestjs/swagger. As I say you can use the same class and omit the type to remove the specific property. So you don't need to create it twice. You can explore more about Swagger later.
Create UserPayload
We need to create a type to specify what return to the client. That's why we called it payload. Create a new file called user.payload.ts
inside the model folder, then put this code inside.
import { PartialType } from '@nestjs/swagger'
import { User } from './user.schema'
export class UserPayload extends PartialType(User) {
createdA?: string
updateAt?: string
}
As I say we use the @nestjs/swagger package here. The PartialType allows us to use the same object to extend it.
Create User Service
Create a new file called user.service.ts then put this code inside the file. This will create a new logic to handle the user.
import { Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { User } from './model/user.schema'
import { Model } from 'mongoose'
import { CreateUserInput, UpdateUserInput } from './model/user.input'
import { UserPayload } from './model/user.payload'
@Injectable()
export class UserService {
constructor(@InjectModel(User.name) private userModel: Model<User>) {}
async createUser(body: CreateUserInput): Promise<UserPayload> {
const createdUser = new this.userModel(body)
const user = await createdUser.save()
return user
}
async findUser(id: string): Promise<UserPayload> {
const user = await this.userModel.findOne({ _id: id }).exec()
if (!user) {
throw new NotFoundException(`User with email id:${id} not found `)
}
return user
}
async listUser(): Promise<UserPayload[]> {
const users = await this.userModel.find()
return users
}
async updateUser(id: string, body: UpdateUserInput): Promise<UserPayload> {
await this.userModel.updateOne({ _id: id }, body)
const updatedUser = this.userModel.findById(id)
return updatedUser
}
async deleteUser(id: string): Promise<void> {
await this.userModel.deleteOne({ _id: id })
}
This service handles the CRUD Operation for users, including creating a new user, listing all users, reading details, deleting, and updating. Inside the service constructor, we inject the UserModel using UserSchema.
Create user controller
All the API endpoints, paths, versions, and methods are placed here. Now create a new file called user.controller.ts
inside the top-level user folder. Then put this code inside.
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'
import { CreateUserInput } from './model/user.input'
import { UserService } from './user.service'
@Controller({
path: 'users',
version: '1',
})
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
createUser(@Body() body: CreateUserInput) {
return this.userService.createUser(body)
}
@Get('/list')
listUser() {
return this.userService.listUser()
}
@Get('/:id')
findUser(@Param('id') id: string) {
return this.userService.findUser(id)
}
@Put('/:id')
updateUser(@Param('id') id: string, @Body() body: CreateUserInput) {
return this.userService.updateUser(id, body)
}
@Delete('/:id')
deleteUser(@Param('id') id: string) {
return this.userService.deleteUser(id)
}
}
Create user module
Now import all code and wrap it inside the user module. Create a new file called user.module.ts Inside the user. Then put this code inside
import { Module } from '@nestjs/common'
import { UserService } from './user.service'
import { UserController } from './user.controller'
import { MongooseModule } from '@nestjs/mongoose'
import { User, UserSchema } from './model/user.schema'
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
Don't forget to register the MongoDB schema Using forFeature
. then import the controller and service.
Creating environment
Now let's create a new file called .env Inside the root folder. Then put this code inside.
# APPS CONFIG
PORT = # YOUR_DATABASE_PORT
# DATABASE CONFIGS
DATABASE_NAME = # YOUR_DATABASE_NAME
DATABASE_USER = # YOUR_DATABASE_USER
DATABASE_PASS = # YOUR DATABASE_PASS
DATABASE_URI = # YOUR_DATABASE_URI, example: mongodb://localhost:2701
Change this environment using your own value.
Dockerize the application
We need to set up our database using docker. So we need to ensure the application running correctly.
Create Dockerfile
Let's create a new file called Dockerfile
in the root folder, then put this code inside.
# Application Docker file Configuration
# Visit https://docs.docker.com/engine/reference/builder/
# Using multi stage build
# Prepare the image when build
# also use to minimize the docker image
FROM node:14-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Build the image as production
# So we can minimize the size
FROM node:14-alpine
WORKDIR /app
COPY package*.json ./
ENV PORT=4000
ENV NODE_ENV=Production
RUN npm install
COPY --from=builder /app/dist ./dist
EXPOSE ${PORT}
CMD ["npm", "run", "start"]
This file will be used to configure our application to become a docker image. When running the docker-compose. Service will create a new image from our backend app. and then run it as a container.
The concept is just to copy the package.json and package-lock.json file into the working directory, then install the deps. The command will build the application by running npm run build
, then copy all dist folders into a last image builder. At least we also add an env file for PORT. So you can change the port dynamically without breaking the app.
Create Docker Compose
Docker Compose allow you to manage all service and container running inside your computer more easily. Compose allow you to define the service, image, and network in the same place without worrying about remembering command in your terminal. So that's why I recommended using docker-compose rather than using manual commands to run the container.
Create a new file called .docker-compose.yaml
inside your root folder, then put this code inside.
# Docker Compose Configuration
# visit https://docs.docker.com/compose/
version: '3.8'
services:
# app service for your backend
app:
container_name: backend
build:
context: ./
dockerfile: Dockerfile
environment:
DATABASE_NAME: # DATABASE_NAME
DATABASE_USER: # DATABASE_USER
DATABASE_PASS: # DATABASE_PASS
DATABASE_URI: # DATABASE_URI, example: mongodb://database:27017
ports:
- '4000:4000'
depends_on:
- database
# start the mongodb service as container
database:
image: mongo:6.0
container_name: mongodb
restart: always
ports:
- '27017:27017'
environment:
MONGO_INITDB_ROOT_USERNAME: # DATABASE_NAME
MONGO_INITDB_ROOT_PASSWORD: # DATABASE_USER
This compose will create two services called app and database. Inside the app, you can see that without an image found. however, we found a context that reference to Dockerfile. This will allow the user to create a new image for the service.
Otherside the mongo, it's come from dockerhub image with a 6.0 version. You also can define any variables and environment for the image including username, and password.
NOTE: when connect database using docker network in application you will no longer access localhost, intead, you access using service name like database. Example if in local you can connect to database using mongodb://localhost:27017, however after using docker you should use the service name like mongodb://database:27017 . This only work you don't expose the new network for database.
Run backend api
To run this application you can run by using this command.
docker-compose up -d
This command will trigger docker compose to run 2 service at the sametime, also pulling the image and tun as docker container. To access you application, you can type localhost:4000/v1/users.
Last words
Before jumping please explore and try to code by yourlsef or understand the flow. All of this code are placed in my repository You can clone or fork this repo to understand how it's work.