As our projects get bigger and bigger, we need more and more advanced architecture. Therefore, as a software engineer, I would like to introduce you to the modern popular microservice architecture that follows the concept of SOA (Service Oriented Architecture).
In this article, I want to talk about the difference between monolithic and microservice architectures and show how to build them using NestJS
, TCP
and Typescript
. Let’s first dive into what microservices are.
git clone [email protected]:Gapur/nestjs-microservices.git
npm install
nx serve api-gateway
nx serve auth-microservice
Microservices are an architectural approach to software development in which software is composed of small, independent services that communicate through well-defined APIs. Each service supports a specific task or business goal and uses an API to communicate with other modules and services. This makes it easier to scale and faster to develop applications, enabling innovation and bringing new features to market faster.
What are the key differences between monolithic and microservice architectures? If monolithic, then all functions and services in the application are combined and work as a single unit. But a microservice breaks down the underlying logic into different tasks or services, each of which can be developed, deployed separately and exposed via an API.
For a better understanding, we will develop a microservices project together in NestJS.
Before we start, I would like to highlight two main aspects of our project:
In short, when a user logs in with credentials through the /api/login
endpoint, they are connected to the API Gateway. The API Gateway then sends and receives a message from the authentication microservice using a request-response style message pattern. This is roughly how our app will work.
Since we’ll be building multiple services, it’s best to have a monorepo project, which is a single version-controlled code repository that includes various apps and libraries. Hence, we are going to use the Nx tool for mono-repository management, which allows you to build and scale web apps and services in a mono-repository.
First, let’s just create a monorepo project with the following command:
npx create-nx-workspace nestjs-microservices --preset=nest
Specify the app name as api-gateway
.
Now let’s install the project dependencies by running the following commands:
cd nestjs-microservices
npm i @nestjs/microservices class-validator class-transformer
Since our project is created, nx has already created an API Gateway
service application for us. Now we will create an auth module in our API Gateway app that is responsible for handling authentication related requests.
When a user makes a request to our app, then the API Gateway receives and sends the request to the microservices. So they will use the same data type and it makes sense to create a shared library in our monorepo and avoid duplicating the same code all over the place with the following command:
nx g @nx/nest:lib shared
Now, let’s create a dto folder and add create-user.dto.ts
file:
// shared/src/lib/dto/create-user.dto.ts
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
username: string;
@IsNotEmpty()
password: string;
}
Also we can add a path entry in the tsconfig.base.json
and import them with absolute paths:
{
...
"compilerOptions": {
...
"paths": {
"@nestjs-microservices/shared": ["shared/src/index.ts"]
}
},
...
}
NestJS transports messages between different microservice instances using the default TCP transport layer. NestJS provides a ClientsModule
which exposes the static register()
method that takes as an argument an array of objects describing the microservice transporters. Let’s add auth.service.ts
and register AUTH_MICROSERVICE
using the following lines of code:
// apps/api-gateway/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
ClientsModule.register([
{
name: 'AUTH_MICROSERVICE',
transport: Transport.TCP,
options: {
host: 'localhost',
port: 3001,
},
},
]),
],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
Above, each transporter has a name property, an optional transport property (default is Transport.TCP), and an optional transporter-specific options property.
Once the module has been imported, we can inject a ClientProxy
instance configured as specified using the AUTH_MICROSERVICE transporter parameters using the @Inject()
decorator in the auth.service.ts
as shown below:
// apps/api-gateway/src/auth/auth.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { CreateUserDto, User } from '@nestjs-microservices/shared';
@Injectable()
export class AuthService {
constructor(
@Inject('AUTH_MICROSERVICE') private readonly authClient: ClientProxy
) {}
getUser(createUserDto: CreateUserDto) {
return this.authClient.send<User, CreateUserDto>('get_user', createUserDto);
}
createUser(createUserDto: CreateUserDto) {
return this.authClient.send<User, CreateUserDto>('create_user', createUserDto);
}
}
As shown above, we can send a message to the authentication microservice using the get_user
or create_user
patterns. We will use them when the user logs in or registers.
The send method is designed to call a microservice and returns an Observable as a response. This takes two arguments:
Last, we’ll create an AuthController
class with two API endpoints for login and signup:
// apps/api-gateway/src/auth/auth.controller.ts
import { Body, Controller, Post, BadRequestException } from '@nestjs/common';
import { lastValueFrom } from 'rxjs';
import { CreateUserDto, User } from '@nestjs-microservices/shared';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(@Body() createUserDto: CreateUserDto) {
const user: User = await lastValueFrom(this.authService.getUser(createUserDto), {
defaultValue: undefined,
});
if (!user) {
throw new BadRequestException('Invalid credentials');
}
const isMatch = user.password === createUserDto.password;
if (!isMatch) {
throw new BadRequestException('Incorrect password');
}
console.log(`User ${user.username} successfully logged in.`);
return user;
}
@Post('signup')
async signup(@Body() createUserDto: CreateUserDto) {
const user: User = await lastValueFrom(this.authService.getUser(createUserDto), {
defaultValue: undefined,
});
if (user) {
throw new BadRequestException(
`Username ${createUserDto.username} already exists!`
);
}
return this.authService.createUser(createUserDto);
}
}
As mentioned earlier, the getUser
and createUser
auth client methods return an Obserable
, which means you need to explicitly subscribe to it before the message is sent. But we can convert an Observable to a Promise using the lastValueFrom
method imported from rxjs
.
Now we will create our first authentication microservice by running the following command:
nx g @nx/nest:app auth-microservice
Let’s update the bootstrap() function boilerplate code in the main.ts file of the auth-microservice app with the NestFactory.createMicroservice() method:
// apps/auth-microservice/src/main.ts
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
transport: Transport.TCP,
options: {
host: 'localhost',
port: 3001,
},
}
);
await app.listen();
Logger.log('🚀 Auth microservice is listening');
}
bootstrap();
The createMicroservice()
method of the NestFactory class creates an instance of a microservice.
Then we’ll create a User entity in a shared library that we’ll use in the UsersRepository
class to do things like storing user data and retrieving the user.
// shared/src/lib/entities/user.entity.ts
export class User {
id?: number;
username: string;
password: string;
}
We will not use any database and for brevity we will store the data in memory in this demo. Let’s create a simple user.repository.ts
file with UserRepository
class:
// apps/auth-microservice/src/app/user.repository.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto, User } from '@nestjs-microservices/shared';
@Injectable()
export class UserRepository {
private users: User[] = [];
save(user: CreateUserDto): User {
const newUser = new User();
newUser.id = this.users.length + 1;
newUser.username = user.username;
newUser.password = user.password;
this.users.push(newUser);
return newUser;
}
findOne(username: string): User | undefined {
return this.users.find((user) => user.username === username);
}
}
Now we are going to add createUser()
and getUser()
to create and find a user respectively using the UserRepository
methods in the app.service.ts
:
// apps/auth-microservice/src/app/app.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto, User } from '@nestjs-microservices/shared';
import { UserRepository } from './user.repository';
@Injectable()
export class AppService {
constructor(private readonly userRepository: UserRepository) {}
createUser(newUser: CreateUserDto): User {
return this.userRepository.save(newUser);
}
getUser(username: string): User | undefined {
return this.userRepository.findOne(username);
}
}
Finally, we’ll create message handler methods based on the request-response paradigm using the @MessagePattern()
decorator, which is imported from the @nestjs/microservices
package.
// apps/auth-microservice/src/app/app.controller.ts
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@MessagePattern('get_user') // listens for the get_user message pattern
handleGetUser(user: CreateUserDto) {
return this.appService.getUser(user.username);
}
@MessagePattern('create_user') // listens for the create_user message pattern
handleCreateUser(newUser: CreateUserDto) {
return this.appService.createUser(newUser);
}
}
In the above code, the handleGetUser()
message handler listens for messages matching the get_user message pattern. The message handler takes a single argument — the user as the CreateUserDto
type passed from the client.
To test all services, we need to run the following commands individually on separate terminals:
nx serve api-gateway
nx serve auth-microservice
To test the app, we can use Postman or any other API client.
Thanks for reading — I hope you found this piece useful. Happy coding!
Building Microservices with NestJS, TCP and Typescript