Working with Microservices with NestJS

Yuval Hazaz - Mar 30 '23 - - Dev Community

Node.js is one of the best options out there when it comes to building microservices. But on its own, it lacks many features that enterprise-level systems require; it has neither the ability to easily generate a scalable architecture, nor any type of helper function to build APIs.

That’s where NestJS (often referred to simply as Nest) comes in. Nest allows us to easily create back-end systems using Node.js and TypeScript. It also provides a clear and efficient pattern for defining these systems, and through the use of specific OOP tools (which will be covered in this article), we can also define scalable and extensible microservices.

In this article we’re going to build a set of microservices to power the back-end of a bookstore. Through this process, we’ll cover all the basic concepts we need to know to get started with Nest.

NestJS and Amplication

At Amplication, we help developers build production-ready GraphQL and REST API endpoints all on top of NestJS. If you end up liking this article and want to dive right in to building your next service with NestJS, be sure to sign up for Amplication at app.amplication.com.

Also, we’re closing in on the 10,000-star milestone on GitHub and would love it if you gave us a star too. Checkout our repo at https://github.com/amplication/amplication, thank you.

What Is Nest?

Nest is a framework that works on top of other frameworks, including Express (by default) or Fastify, which we’ll call “base frameworks.” As such, Nest provides a higher level of abstraction. While these base frameworks are powerful, they leave it up to us to determine how we structure our projects.They also let us decide what kind of abstractions we code into them to make them easier to maintain or to scale.

Nest adds several tools to these base frameworks in the form of abstractions and functions that help to solve scaling problems preemptively. These tools include:

  1. Modules: Nest uses a modular architecture, allowing developers to organize their code into reusable modules that can be shared and imported across the application with ease.

  2. Controllers: Controllers handle incoming HTTP requests and define the routes for the application. This separates the routing logic from the business logic of the application, and makes the code easier to understand and maintain. It’s also a major time saver if we need to maintain an application with hundreds of routes.

  3. Services: Services encapsulate business logic, and can be shared and injected across the application, thus making it easier to manage and test the application's logic.

  4. Guards and pipes: Nest includes built-in support for guards and pipes, which can be used to add middleware and validation logic to the application.

  5. Decorators: Decorators can be used to add metadata to a class and its members, which can then configure the application and manage its dependencies.

  6. Support for TypeScript: Nest was built with TypeScript. As such, we can use TypeScript to build our own applications as well, which provides a more robust type system and improved tooling capabilities for building large-scale applications.

  7. CLI: Nest comes with a powerful CLI tool that can help with the development and maintenance of our application, such as generating boilerplate code and scaffolding new modules, controllers, and services.

  8. Dependency injection: This is a pattern that allows us easily to add dependencies to our otherwise-generic code. Injecting services into our controllers allows us to swap services with minimal knock-on effect.

Nest’s documentation shows their offerings in full. Let’s dive into a practical example to see how it performs in action.

Building Microservices with Nest

We’re going to be building a simple yet scalable backend for a bookstore. To limit repeated code, let’s focus on the main API for such a business: the book handler. Let’s start at the beginning: installing Nest. Assuming we’re running a relatively recent version of Node, simply type:

npm install -g @nestjs/cli
Enter fullscreen mode Exit fullscreen mode

With Nest installed, we now have to plan our projects. Since we’re going to be building microservices, we’re not going to build one single project; instead, we want to build one project per microservice and one project per client.
Given we’re going to build one single microservice here, let’s build two projects in total:

nest new bookstore
nest new client
Enter fullscreen mode Exit fullscreen mode

“Bookstore” and “client” will end up being the names of the folders in which our projects will be saved. When asked about what package manager we want to use, any option works; for this tutorial we’ll use NPM (my favorite.)

Once the process is complete, we’ll have two brand new folders on our system with all the code we need to get started.

In-Depth Walkthrough on Building Nest Microservices

With both our projects created, let’s first focus on the microservice that will handle our books. There are already some files inside that project’s src folder. The app.controller.ts, app.module.ts, app.service.ts, and main.ts files are going to be our main targets.

First, though, we need to install a new dependency, because by default we’re missing that building block for our microservice:

npm install @nestjs/microservices
Enter fullscreen mode Exit fullscreen mode

Now open the main.ts file, and change it to call the createMicroservice method of the NestFactory:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule,{
  transport: Transport.TCP,
  options: {
      port: 3000
  }
  });
  await app.listen();
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

We’re setting up a microservice on port 3000 that uses TCP as the transport layer.

Now, let’s define the structure of our books; since we’re dealing with TypeScript we have to define the types we’re dealing with. We’re going to define a DTO (data transfer object) using an interface:

export interface BookDTO {
  id: string;
  title: string;
  author: string;
  release_date: Date;
}
Enter fullscreen mode Exit fullscreen mode

Our books will have a title, an author, and a release date. They will also have an ID, but we’ll auto-generate that piece of information.

Now that we have our DTO ready, and the application is building an actual microservice, let’s edit the controller file where we’ll handle the entry point for our messages. From there, we’ll connect with the service, which is where the actual business logic will reside.

The controller class will have the @Controller annotation, letting Nest know what they are and how they work. Inside this class, we’ll define one method per endpoint of our microservice. To specify that a method is meant to handle the incoming message, we’ll use the @MessagePattern annotation. The controller will look like this:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { MessagePattern } from '@nestjs/microservices';
import { BookDTO } from './book';

function delay(ms) {
  var start = new Date().getTime();
  var end = start;
  while (end < start + ms) {
    end = new Date().getTime();
  }
}

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @MessagePattern({cmd: 'new_book'})
  newBook(book: BookDTO): string{
  delay(10000)
  const result = this.appService.newBook(book);
    if(!result) {
        return "Book already exists"
    } else {
        return result;
    }
  }

  @MessagePattern({cmd: 'get_book'})
  getBook(bookID: string): BookDTO {
    return this.appService.getBookByID(bookID)
  }

  @MessagePattern({cmd: 'get_books'})
  getBooks(): BookDTO[] {
    return this.appService.getAllBooks()
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s defined three methods/endpoints:

  • newBook: receives and saves the data for a new book
  • getBook: returns the details of a single book, given its ID
  • getBooks: returns all books in our store

With the annotation on each method, we’re defining the command to which each method will respond. Our client application will not execute our method directly, but rather will send a message. Importantly, this separates the implementation from the client. It also gives us a chance to test two different ways of interacting with the method: messages and events. This is why we added a fake delay of ten seconds on the newBook method. We’ll use it to test both communication methods.

Let’s now take a look at the service where our business logic resides. Our service class is annotated as @Injectable, because Nest will inject it into our controller without us having to do anything special. To keep things simple, let’s skip database storage and validations. Instead, we’ll save everything in memory with this array:

import { Injectable } from '@nestjs/common';
import { BookDTO } from './book';


let bookStore:BookDTO[] = []

@Injectable()
export class AppService {

  getBookByID(bookID: string) {
    return bookStore.find( (b:BookDTO) => b.id == bookID)
  }

  getAllBooks() {
   return bookStore;
  }

  newBook(book: BookDTO) {
  const exists = bookStore.find( (b: BookDTO) => {
    return b.title == book.title &&
          b.author == book.author &&
          b.release_date == book.release_date
  })
  if(exists) return false;
  book.id = "Book_" + (bookStore.length + 1)
  bookStore.push(book)
  return book.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

These are the methods to which our controller will have access; they simply deal with an array of BookDTO objects. Now our microservice is ready! We can test it with npm run start:dev.

Here’s a brief review of where we are:

  • We built a controller and a service and never really had to connect the two.
  • Our service is listening on port 3000 and we just defined the port, we didn’t have to do anything else.
  • There are no routes defined anywhere, but rather some message patterns that we define per method.

Building microservices becomes a relatively simple process with NestJS.

Building the Client Application

Let’s now take a look at the client application, which is going to be our interface into the microservice we just built. Inside this project, we’ll find the same files as with the previous project. Our main.ts file will remain the same and we can simply update the port:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3001);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

This time around, we’ll ignore the app.service.ts file, but will change the app.module.ts file. Nest needs to know where the microservice is located so that it can interface successfully without any manual processes.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientProxyFactory, Transport } from '@nestjs/microservices';

@Module({
  imports: [ConfigModule.forRoot()],
  controllers: [AppController],
  providers: [{
  provide: 'BOOKS_SERVICE',
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => {
    return ClientProxyFactory.create({
      transport: Transport.TCP,
      options: {
        host: configService.get('BOOKSTORE_SERVICE_HOST'),
        port: configService.get('BOOKSTORE_SERVICE_PORT')
      }
    })
  }
  }],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Here, we’re specifying the controller for this application and the providers we’re using, which essentially means configuring the location of our microservice. Since the provider property is an array, we could potentially specify multiple microservices and access them all through the same client. Both BOOKSTORE_SERVICE_HOST and BOOKSTORE_SERVICE_PORT are environment variables, which have to be set.

Now that we’ve set up our provider (i.e the microservice) and named it (BOOKS_SERVICE), we need to create the controller. This controller will handle all incoming HTTP requests, and will redirect them to the microservice through the specified message protocol.

For this example, our client is going to be an HTTP API, and so we’ll set up some endpoints by defining our controller class and annotating it with the @Controller annotation. We’ll also give that annotation a value, which will act as the root for all the endpoints we define here.

With that done, we can start defining methods inside our controller class. Each method will correspond to an endpoint, specified with yet another annotation: @Get or @Post in our case, since we have an endpoint for creating new books. Each endpoint will be straightforward; it will send a message through to the client to our microservice. For each message, we’ll specify a different command—the one we defined in our message patterns back when we wrote the microservice. Here’s the code:

import { Body, Controller, Get, Inject, Param, Post } from '@nestjs/common';
import { BookDTO } from './book';
import { ClientProxy } from '@nestjs/microservices';

@Controller('booksstore')
export class AppController {
  constructor(@Inject('BOOKS_SERVICE') private client: ClientProxy) {}

  @Get()
  getAllBooks() {
    return this.client.send({cmd: 'get_books'}, {})
  }

  @Get(':id')
  getBookByID(@Param('id') id ) {
    return this.client.send({cmd: 'get_book'}, id)
  }

  @Post()
  createNewBook(@Body() book: BookDTO) {
    return this.client.send({cmd: 'new_book'}, book)
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how the constructor injects the service into our controller class. Just like before, we don’t need to perform manual processes (for example, from where we import the service, how we instantiate it, into what property should be inserted, etc.) The @Controller annotation receives the string booksstore, which makes the latter the string the root for all our requests. As such, to get the list of all books (for example) we need to query /bookstores/, and to get the details of the book with id Book_12 we query /booksstore/Book_12.

The key takeaway here is that we’re using the send method from the client property. This method sends a message to the microservice, and the send method will wait until the microservice successfully completes the operation and sends back a response before returning. To wrap up this tutorial, let’s take a quick look at both the message pattern and the event pattern.

Message Versus Events Pattern

Nest allows us to select one of these two ways to communicate with our microservice.

Message Pattern: Pros and Cons

The codes in this tutorial have been produced using the message pattern, also known as the request-response pattern. This is the most common pattern for HTTP services. The upside of the message pattern is that it’s simple to work with, and, perhaps most importantly, easy to debug. However, the downside is that the microservice could take too long to respond, resulting in our connection being locked until the end, running the risk of ruining users’ experience or even generating timeout errors.

As such, this pattern is a great choice only when interacting with services with a low response latency. For other instances, fortunately Nest allows us to use the events pattern.

Events Pattern to the Rescue

The events pattern allows us to define microservices that are waiting for specific events, and then have other services trigger these events. You can even register multiple event handlers (i.e., methods) for the same event, and when triggered, they will all fire in parallel. Events are asynchronous by default. This means that no matter how long the microservice takes to perform the operation, the connection is closed immediately.

Events pattern is a great choice when we want to send information or commands to the microservice without having to worry about response time. Usually, microservices emit other events as a result, thus triggering a network of communications that can result in a complex operation distributed across simple services. For example, in our tutorial earlier, if we tried to create a new book using the events pattern it would have taken ten seconds due to the fake delay we added. On the other hand, if we go to the createNewBook method on the client’s controller and change send to emit, the result is instantaneous (even though we no longer get the new book’s ID as a response.)

Event-based patterns are usually more flexible, as they offer the opportunity to create complex architectures that scale more easily and are highly responsive. Yes, they might require a bit more debugging if things don’t work perfectly the first time, but the superior end result warrants the extra upfront effort.

Conclusion

Building a microservice-based architecture in NestJS is a great experience, offering a wealth of tools. Remember that one project is required per microservice, each provider needs configuring inside the app.module.ts file so that we’re then able to build clients to communicate with one another. Finally, when communicating with another microservice, we can choose between using a classic request-response pattern, or going with the more scalable event-based pattern. Each has their pros and cons.

To see the full code of the projects discussed here, use the following repositories:

Intrigued by Nest and would like to try it on a future project? Consider using Amplication. Amplication auto-generates boilerplate code, leaving you free to focus on coding your business logic.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .