NodeJS Microservice with gRPC and TypeScript
It is very difficult for an engineering team to manage a very large application. Thus instead of building a large monolith application, companies are writing multiple microservices which can be managed by multiple small teams. These microservices are loosely coupled to each other and can be developed, deployed, and scaled independently. However, in this microservice architecture, the microservices often need to communicate with each other via a lightweight communication protocol. For this inter-process communication, gRPC is the popular choice of developers.
gRPC is an open-source framework for Remote Procedure Calls. It is developed by Google. It is a lightweight, high-performant, and type-safe framework for inter-service communication. It uses http/2 for transport and protocol buffers for serializing structured data.
In this blog, we will write microservices with nodejs and use gRPC for communication between them.
Repo Setup
We will create a monorepo where we will keep all our microservices and protobuf files. We will have a server called product-service
and one client to test the server.
Let's create our project repo and change the current working directory to it.
mkdir nodejs-microservices
cd nodejs-microservices
We will create an empty node project inside it. Here we will pass the -y
flag to accept all the defaults.
We will use npm workspace
for local shared dependencies resolution.
npm init -y
We will create 2 directories packages
, and services
. In the packages
directory, we will keep all our reusable libraries like generated Typescript files from protobuf etc. And in the services
directory, we will keep all our microservices.
mkdir packages services
Let's create the protos
package first.
NOTE: This is not an ideal way to use protobuf files, as the version is not locked in the services. So any changes we make in the protos package will be auto-reflected in services. It is better to keep protobuf files in a separate repo and publish generated Typescript files to a registry. And use that in services as an npm package with a fixed version. You can read about publishing this package to npm in my other blog post
npm init -w packages/protos --scope=@nodejs-microservices -y
In the protos
package, we will create 2 directories src
and dist
. In the src
directory, we will keep our protobuf files and in the dist
directory, we will keep the generated Typescript files from the protobuf files.
cd packages/protos
mkdir src dist
After this, the project structure should look something like this.
.
├── package.json
├── packages
│ └── protos
│ ├── dist
│ ├── package.json
│ └── src
└── services
Now we will create protobuf files in the src
directory. This protos
package will contain multiple protobuf files. So, we will create subdirectories inside the src
directory as per the proto package name.
Create Protobuf file
For the product-service
, we will create product.proto
file inside the product
subdirectory. We will define the Product
message here. Read more about the protobuf message here
src/product/product.proto
syntax = "proto3";
package product;
import "google/protobuf/timestamp.proto";
message Product {
int32 id = 1;
string name = 2;
string description = 3;
string image = 4;
repeated string tags = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
We will create a service called ProductService
and define the schemas of Request and Response for the rpc methods. The ProductService
will have 3 rpc methods CreateProduct
, GetProduct
, and ListProducts
.
syntax = "proto3";
package product;
import "google/protobuf/timestamp.proto";
message Product {
int32 id = 1;
string name = 2;
string description = 3;
string image = 4;
repeated string tags = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message CreateProductRequest {
string name = 1;
string description = 2;
string image = 3;
repeated string tags = 4;
}
message CreateProductResponse {
Product product = 1;
}
message GetProductRequest {
int32 id = 1;
}
message GetProductResponse {
Product product = 1;
}
message ListProductsRequest {
}
message ListProductsResponse {
repeated Product products = 1;
}
service ProductService {
rpc CreateProduct(CreateProductRequest) returns (CreateProductResponse);
rpc GetProduct(GetProductRequest) returns (GetProductResponse);
rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
}
Generating Typescript files from Protobuf
To Generate the TypeScript files, we need to first install the protobuf
compiler. The protobuf
compiler will be installed globally in the system. Here I am installing in macos via brew. It will different for other operating systems. check the github repo for installation instructions.
brew install protobuf
We also need to install the ts-proto
npm package to use it as a plugin for protoc
. We will create a build script to generate typescript files from protbuf. In the build script, we will set dist
as the output directory and src
as the source directory for protobuf files.
npm i -D ts-proto typescript
touch build.sh
build.sh
#!/bin/bash
protoc --plugin=$(npm root)/.bin/protoc-gen-ts_proto \
--ts_proto_out=dist \
--ts_proto_opt=outputServices=grpc-js \
--ts_proto_opt=esModuleInterop=true \
-I=src/ src/**/*.proto
In the build script, we are using $(npm root)
instead of ./node_modules
because due to the npm workspace, the binary is installed in the root node_modules
. Read more about npm root
here.
We will make the build script executable and run it.
chmod +x build.sh
./build.sh
After executing the build script, the typescript files will be generated in the dist
folder, and folder structures will look like this.
.
├── dist
│ ├── google
│ │ └── protobuf
│ │ └── timestamp.ts
│ └── product
│ └── product.ts
├── package.json
└── src
└── product
└── product.proto
Create gRPC Server
Let's create the product-service
. The product-service
will be a gRPC server that will store the product data in the postgres database. We will use typeorm
in this service.
From the root of the repo, we will run the npm init command to scaffold the package.json file.
npm init -w services/product-service --scope=@nodejs-microservices -y
Then change the current working directory to product-service
. In the product-service
directory, we will create src
, and dist
subdirectories similar to the protos
package.
cd services/product-service
mkdir src dist
Let's install the dependencies and dev dependencies for the server.
npm i -D typescript ts-node
npm i @grpc/grpc-js typeorm reflect-metadata pg
We will create the tsconfig.json
file inside the server directory next to the package.json
file.
tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
Let's create a Model for the products
table. The id
is the primary key of the table and will be auto-generated with an auto-increment value. The createdAt & updatedAt fields will be auto-generated too and set during insert and update operations.
src/models/product.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
@Column()
description!: string;
@Column()
image!: string;
@Column("text", { array: true })
tags!: string[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
Let's configure the database connection. We will create db/index.ts
file inside the src
directory.
We have to import the Product model here and add it to the entities property.
src/db/index.ts
import { DataSource } from "typeorm";
import { Product } from "../models/product";
const dataSource = new DataSource({
type: "postgres",
host: process.env.POSTGRES_HOST || "localhost",
port: Number(process.env.POSTGRES_PORT) || 5432,
username: process.env.POSTGRES_USER || "postgres",
password: process.env.POSTGRES_PASSWORD || "postgres",
database: process.env.POSTGRES_DB || "postgres",
entities: [Product],
logging: true,
synchronize: true,
});
export default dataSource;
Let's setup the gRPC server. We will create a main.ts
file inside the src
directory. Here we will import the dataSource
. After initializing the dataSource
, we will get the db
instance. We will start the server
only after the database is connected.
src/main.ts
import "reflect-metadata";
import dataSource from "./db";
import { Server, ServerCredentials } from "@grpc/grpc-js";
const server = new Server();
const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT) || 50051;
const address = `${HOST}:${PORT}`;
dataSource
.initialize()
.then((db) => {
server.bindAsync(
address,
ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
throw error;
}
console.log("server is running on", port);
server.start();
}
);
})
.catch((error) => console.log(error));
Update the scripts in the package.json
file to run and build the server.
package.json
{
...
"scripts": {
"dev": "ts-node src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
}
We can test the server and database connection by running it. It should create the products
table in the database.
npm run dev
or
npm run build
npm start
Let's start implementing the methods for the server. First, we need to install the protos
package. Then we create a server.ts
file.
npm i @nodejs-microservices/protos
touch src/server.ts
We will create a getProductServer
function in the server.ts
file which will return an object with createProduct
, getProduct
, and listProducts
methods. We are using this server object instead of creating a class due to the server signature issue
We will define the business logic later, right now all the methods will return the status UNIMPLEMENTED
in the response. We are doing this to test the server.
src/server.ts
import { sendUnaryData, ServerUnaryCall, status } from "@grpc/grpc-js";
import {
CreateProductRequest,
CreateProductResponse,
GetProductRequest,
GetProductResponse,
ListProductsRequest,
ListProductsResponse,
ProductServiceServer,
} from "@nodejs-microservices/protos/dist/product/product";
import { DataSource } from "typeorm";
export function getProductServer(db: DataSource): ProductServiceServer {
async function createProduct(
call: ServerUnaryCall<CreateProductRequest, CreateProductResponse>,
callback: sendUnaryData<CreateProductResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
async function getProduct(
call: ServerUnaryCall<GetProductRequest, GetProductResponse>,
callback: sendUnaryData<GetProductResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
async function listProducts(
call: ServerUnaryCall<ListProductsRequest, ListProductsResponse>,
callback: sendUnaryData<ListProductsResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
return {
createProduct,
getProduct,
listProducts,
};
}
We will import the getProductServer
function and ProductService definition in the main.ts
file and then add the product service to the gRPC server.
src/main.ts
import "reflect-metadata";
import dataSource from "./db";
import { Server, ServerCredentials } from "@grpc/grpc-js";
import { getProductServer } from "./server";
import { ProductServiceService } from "@nodejs-microservices/protos/dist/product/product";
const server = new Server();
const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT) || 50051;
const address = `${HOST}:${PORT}`;
dataSource
.initialize()
.then((db) => {
server.addService(ProductServiceService, getProductServer(db));
server.bindAsync(
address,
ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
throw error;
}
console.log("server is running on", port);
server.start();
}
);
})
.catch((error) => console.log(error));
After running the server, We will test the service methods with grpcurl
. Read more about grpcurl
here
Let's test the CreateProduct
method, we need to pass the proto file to the grpcurl
command. I am calling the grpcurl
from the root of the monorepo, thus the path to the proto files are relatives to the root. After that, We need to pass the request body as a JSON string. Then pass the address to the server and service method name.
grpcurl -import-path packages/protos/src -proto product/product.proto -d '{ "name": "product 1", "description": "foo bar", "image": "", "tags": ["tag 1"]}' -plaintext localhost:50051 product.ProductService.CreateProduct
ERROR:
Code: Unimplemented
Message: Unknown Error
After running the grpcurl
call, we should get an error from the server. Here we are getting the status code Unimplemented
as we expected.
Let's add the controller
for product
model. We will create product.controller.ts
file inside src/controllers
directory. In this file we will write the logic for database operations. We create the createProduct
function, the function accepts the database instance and request body as the arguments.
We are defining the interface for createProductReq
here, We can also use CreateProductRequest
from the protos
package instead of this. It is just my personal preference to define the interface inside the server as we are mapping the request fields with Product
model fields. So, If in the future there is a change in CreateProductRequest
then we can easily transform CreateProductRequest
to createProductReq
src/controllers/product.controller.ts
import { DataSource } from "typeorm";
import { Product } from "../models/product";
interface createProductReq {
name: string;
description: string;
image: string;
tags: string[];
}
export const createProduct = async (
db: DataSource,
req: createProductReq
): Promise<Product> => {
const productRepository = db.getRepository(Product);
const product = new Product();
product.name = req.name;
product.description = req.description;
product.image = req.image;
product.tags = req.tags;
return productRepository.save(product);
};
We then import the ProductController
in the server.ts
file and update the createProduct
method. We will transform the result from the database insert operation to the Product
message by passing it to the fromJSON
method. We will wrap this logic inside a try-catch block and return the status code INTERNAL
in case of an error.
src/server.ts
import { sendUnaryData, ServerUnaryCall, status } from "@grpc/grpc-js";
import {
CreateProductRequest,
CreateProductResponse,
GetProductRequest,
GetProductResponse,
ListProductsRequest,
ListProductsResponse,
Product,
ProductServiceServer,
} from "@nodejs-microservices/protos/dist/product/product";
import { DataSource } from "typeorm";
import * as ProductController from "./controllers/product.controller";
export function getProductServer(db: DataSource): ProductServiceServer {
async function createProduct(
call: ServerUnaryCall<CreateProductRequest, CreateProductResponse>,
callback: sendUnaryData<CreateProductResponse>
) {
try {
const product = await ProductController.createProduct(db, call.request);
const productPB = Product.fromJSON(product);
const response: CreateProductResponse = {
product: productPB,
};
callback(null, response);
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
async function getProduct(
call: ServerUnaryCall<GetProductRequest, GetProductResponse>,
callback: sendUnaryData<GetProductResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
async function listProducts(
call: ServerUnaryCall<ListProductsRequest, ListProductsResponse>,
callback: sendUnaryData<ListProductsResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
return {
createProduct,
getProduct,
listProducts,
};
}
Let's test the CreateProduct
method again with grpcurl
. We should get the response back from the server now instead of the status UNIMPLEMENTED
error.
grpcurl -import-path packages/protos/src -proto product/product.proto -d '{ "name": "product 1", "description": "foo bar", "image": "", "tags": ["tag 1"]}' -plaintext localhost:50051 product.ProductService.CreateProduct
{
"product": {
"id": 1,
"name": "product 1",
"description": "foo bar",
"tags": [
"tag 1"
],
"createdAt": "2023-03-29T00:07:23.426Z",
"updatedAt": "2023-03-29T00:07:23.426Z"
}
}
Let's add the listProducts
and getProduct
functions to the product controller. Both functions will accept database instance as an argument similar to the createProduct
function
src/controllers/product.controller.ts
import { DataSource } from "typeorm";
import { Product } from "../models/product";
interface createProductReq {
name: string;
description: string;
image: string;
tags: string[];
}
export const createProduct = async (
db: DataSource,
req: createProductReq
): Promise<Product> => {
const productRepository = db.getRepository(Product);
const product = new Product();
product.name = req.name;
product.description = req.description;
product.image = req.image;
product.tags = req.tags;
return productRepository.save(product);
};
export const listProducts = async (db: DataSource): Promise<Product[]> => {
const productRepository = db.getRepository(Product);
return productRepository.find();
};
export const getProduct = async (
db: DataSource,
id: number
): Promise<Product | null> => {
const productRepository = db.getRepository(Product);
return productRepository.findOneBy({ id: id });
};
We will update the getProduct
and listProducts
methods in the server.ts
file.
In the getProduct
method, we will check if the database returns the product
, then only we return the response. Otherwise, we will return the status code NOT_FOUND
with the message.
In the listProducts
method, we will get an array of product
objects from the database, we will run the map
function to transform each of them into the Product
message.
src/server.ts
import { sendUnaryData, ServerUnaryCall, status } from "@grpc/grpc-js";
import {
CreateProductRequest,
CreateProductResponse,
GetProductRequest,
GetProductResponse,
ListProductsRequest,
ListProductsResponse,
Product,
ProductServiceServer,
} from "@nodejs-microservices/protos/dist/product/product";
import { DataSource } from "typeorm";
import * as ProductController from "./controllers/product.controller";
export function getProductServer(db: DataSource): ProductServiceServer {
async function createProduct(
call: ServerUnaryCall<CreateProductRequest, CreateProductResponse>,
callback: sendUnaryData<CreateProductResponse>
) {
try {
const product = await ProductController.createProduct(db, call.request);
const productPB = Product.fromJSON(product);
const response: CreateProductResponse = {
product: productPB,
};
callback(null, response);
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
async function getProduct(
call: ServerUnaryCall<GetProductRequest, GetProductResponse>,
callback: sendUnaryData<GetProductResponse>
) {
try {
const product = await ProductController.getProduct(db, call.request.id);
if (product) {
const productPB = Product.fromJSON(product);
const response: GetProductResponse = {
product: productPB,
};
callback(null, response);
} else {
callback(
{
code: status.NOT_FOUND,
message: `Product ${call.request.id} not found`,
},
null
);
}
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
async function listProducts(
call: ServerUnaryCall<ListProductsRequest, ListProductsResponse>,
callback: sendUnaryData<ListProductsResponse>
) {
try {
const products = await ProductController.listProducts(db);
const productsPB = products.map(Product.fromJSON);
const response: ListProductsResponse = {
products: productsPB,
};
callback(null, response);
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
return {
createProduct,
getProduct,
listProducts,
};
}
We will test the ListProducts
and GetProduct
methods with grpcurl
. They shouldn't be returning an error with the status UNIMPLEMENTED
.
grpcurl -import-path packages/protos/src -proto product/product.proto -plaintext localhost:50051 product.ProductService.ListProducts
{
"products": [
{
"id": 1,
"name": "product 1",
"description": "foo bar",
"tags": [
"tag 1"
],
"createdAt": "2023-03-29T00:07:23.426Z",
"updatedAt": "2023-03-29T00:07:23.426Z"
}
]
}
grpcurl -import-path packages/protos/src -proto product/product.proto -d '{"id": 1}' -plaintext localhost:50051 product.ProductService.GetProduct
{
"product": {
"id": 1,
"name": "product 1",
"description": "foo bar",
"tags": [
"tag 1"
],
"createdAt": "2023-03-29T00:07:23.426Z",
"updatedAt": "2023-03-29T00:07:23.426Z"
}
}
Create gRPC Client
Let's create a client to test the product-service
. We will create a simple program that will connect to the server, make the call and log the response in the console.
From the root of the repo, we will run the npm init
command to scaffold the package.json file and change the current working directory to the test-client
. Inside the test-client
we will create the src
and dist
directories.
npm init -w services/test-client --scope=@nodejs-microservices -y
cd services/test-client/
mkdir src dist
We will install the dependencies and dev dependencies for the client. We will install the @nodejs-microservices/protos
package to get the client stub.
npm i -D typescript ts-node
npm i @grpc/grpc-js @nodejs-microservices/protos
We will create the tsconfig.json
file inside test-client
, the tsconfig.json
is the same as in the product-service
tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
We will update the scripts
in the package.json
file to run and build the client.
package.json
{
...
"scripts": {
"dev": "ts-node src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
}
Let's create the main.ts
file inside the src
directory. We will create a main function and test each service method separately.
First, We need to import the credentials
object from the grpc-js
package and ProductServiceClient
from our protos
package. Then we will create a gRPC client. While creating the client instance, we need to pass the server url and authentication credentials. The product-service
doesn't have any authentication thus we are passing credentials.createInsecure()
as authentication credentials.
After creating the client instance, we will call the service method name. Here we are calling the createProduct
method on the client. We will create the request object for the call and then call the method with the request object and callback function.
src/main.ts
import { credentials } from "@grpc/grpc-js";
import {
CreateProductRequest,
ProductServiceClient,
} from "@nodejs-microservices/protos/dist/product/product";
const PRODUCT_SERVICE_URL = process.env.USER_SERVICE_URL || "0.0.0.0:50051";
function main() {
const client = new ProductServiceClient(
PRODUCT_SERVICE_URL,
credentials.createInsecure()
);
const req: CreateProductRequest = {
name: "test product",
description: "foo bar",
image: "https://example.com/image",
tags: ["tag-1"],
};
client.createProduct(req, (err, resp) => {
if (err) {
console.error(err);
} else {
console.log("Response :", resp);
}
});
}
main();
We can similarly call and test the getProduct
and listProducts
methods. I am skipping that part in this blog.
All the source code for this blog is available on GitHub.