Guided NestJS Conf & Health
Prerequisites
- Finished this tutorial
- Node.js (v18)
- NestJS CLI (
npm i -g @nestjs/cli
)
Sommaire
- 12-factor app
- Configuration
- Health Check
Configuration
Storing configuration in the environment of an API is extremely useful in a microservices architecture for several reasons:
Scalability: When deploying a large number of microservices, having a central configuration management system may become difficult to scale. By storing configuration in the environment of each API, it becomes easy to scale independently without affecting other services.
Consistency: When configuration is stored in the environment, it ensures that the configuration is consistent across all instances of the service. Developers can easily update the configuration by simply changing the environment variables of the API.
Security: Storing configuration in the environment reduces security risks because it eliminates the need to store sensitive configuration data in code or configuration files. Environment variables can be securely managed and encrypted by the cloud provider.
DevOps: It allows DevOps to easily manage and deploy microservices independently without needing to coordinate with developers to update configuration files.
Overall, storing configuration in the environment of an API simplifies the management of microservices, reduces the risk of configuration errors, and allows for better scalability and security.
Itâs essential to detect configuration errors early in the startup process to prevent unexpected errors, downtime, and cascading failures. Stopping an API immediately after a configuration failure can help ensure consistency across the entire system and prevent inconsistencies caused by incorrect configuration. Therefore, stopping a microservices API early when configuration fails is a crucial concept to consider in building a reliable and resilient architecture.
Letâs see how to handle configuration in a NestJS API. NestJS Documentation
Setup
First, install the config package:
bash
$ npm i --save @nestjs/config
Then, go to your app.module.ts
and add the following to the import section:
typescript
import { ConfigModule } from '@nestjs/config';@Module({imports: [ConfigModule.forRoot(),// Other modules],// ...})
Now, your API is able to read the variables from the environment but also from a .env
file. Note that the environment takes precedence if the same variable is set in both.
Also, it is important that the config module be loaded before anything else.
Some env variables are necessary for the API to function, while others are optional. We can define the rules with the joi
package, so letâs install it:
bash
$ npm install --save joi
We can now set our rules. In config/env.ts
, add the Joi schema validation:
typescript
import * as Joi from 'joi';export const envSchema = Joi.object({DATABASE_URL: Joi.string().required(),PORT: Joi.number().default(4000),});
The import of Joi is like import * as Joi from 'joi';
because the esModuleInterop
is set to false by default. If set to true, modify this line with import Joi from 'joi';
We want to have a string for the database URL set before starting our app, and we want the default port to be 4000. Letâs tell our configModule to use this schema, so head back to app.module.ts
and add the following:
typescript
import { ConfigModule } from '@nestjs/config';import { envSchema } from './config/env'@Module({imports: [ConfigModule.forRoot({ignoreEnvFile: process.env.NODE_ENV === 'production',validationSchema: envSchema,}),// Other modules],// ...})
SO, we ignore the .env
in production and we validate our env variables before anything else. Letâs test this. Remove the DATABASE_URL from your .env
if you have one, and start your API. You should see the following:
bash
$ npm start// blabla errorError: Config validation error: "DATABASE_URL" is required
Usage
To use our configuration with the default and the required values, we need to inject the ConfigService
. There is multiple way to inject it depending on where you are (main.ts
, provider
, function
âŠ). Letâs start with our port
.
Go to your grpc.option.ts
. Weâll need to inject the config service and use it to get the port:
typescript
import { ConfigService } from '@nestjs/config';export const grpcConfig = (cs: ConfigService): GrpcOptions =>addReflectionToGrpcConfig({transport: Transport.GRPC,options: {url: `0.0.0.0:${cs.get<number>(PORT)}`,package: HERO_V1ALPHA_PACKAGE_NAME,protoPath: join(__dirname, 'proto/hero/v1alpha/hero.proto'),},});
Since we updated a variable to a function, we have to update accordingly our main.ts
to fetch the config service and use it to compose our grpc options:
typescript
import { ConfigService } from '@nestjs/config';async function bootstrap() {const app = await NestFactory.create(AppModule);const cs = app.get(ConfigService);app.connectMicroservice(grpcConfig(cs));await app.startAllMicroservices();await app.listen(8888);}
Donât worry about the 8888 port; weâll make use of it later for the health check URL.
Lastly, we have to modify the app.module.ts
reflection module to use the config service:
typescript
import { ConfigService } from '@nestjs/config';import { envSchema } from './config/env'@Module({imports: [ConfigModule.forRoot({ignoreEnvFile: process.env.NODE_ENV === 'production',validationSchema: envSchema,}),GrpcReflectionModule.registerAsync({imports: [ConfigModule],useFactory: (cs: ConfigService) => grpcConfig(cs),inject: [ConfigService],}),// Other modules],// ...})
This is the async register method; we can pass options to customize initialization of modules. This async registration method is common in NestJS modules.
Exercise
Add a USER_PORT
env variable of type string and required, then use it in the grpc.option
for the userGrpcOptions
variable. Modify the user service accordingly.
Health Check
Health check endpoints are important for microservices architecture because they allow monitoring and alerting tools to continuously check the status of the service instance. It also helps in identifying failures or underperformance without having to monitor all possible endpoints. Health check endpoints provide valuable metrics about the serviceâs state, such as the amount of memory used, uptime, active connections, CPU usage, etc., which in turn helps in detecting any issues with the service. It also helps to quickly detect and isolate issues that might be causing problems in a multi-service environment. This helps to facilitate quick remediation and ensure that the service is highly available, reliable, and performant.
Letâs implement a basic health route in our NestJS API. NestJS Documentation
Setup
Start by installing the necessary package:
bash
$ npm install --save @nestjs/terminus
Then, create a health module:
bash
$ nest g module health$ nest g controller health
In the health/health.module.ts
, import terminus
:
typescript
import { Module } from '@nestjs/common';import { TerminusModule } from '@nestjs/terminus';@Module({imports: [TerminusModule]})export class HealthModule {}
And in the controller, add the basic terminus structure:
typescript
import { Controller, Get } from '@nestjs/common';import { HealthCheckService, HealthCheck } from '@nestjs/terminus';@Controller('health')export class HealthController {constructor(private health: HealthCheckService,) {}@Get()@HealthCheck()check() {return this.health.check([/** We'll put here the list of health checks **/]);}}
Exercise
Look at the NestJS documentation and implement the memory and disk health checks.
Custom Health Indicator
For Prisma, we are going to create a custom health indicator. In a new file called health/prisma.health.ts
, add the following:
typescript
import { Injectable } from '@nestjs/common';import {HealthCheckError,HealthIndicator,HealthIndicatorResult,} from '@nestjs/terminus';import { PrismaService } from 'src/prisma.service';@Injectable()export class PrismaHealthIndicator extends HealthIndicator {constructor(private readonly prismaService: PrismaService) {super();}async isHealthy(key: string): Promise<HealthIndicatorResult> {try {await this.prismaService.$queryRaw`SELECT 1`;return this.getStatus(key, true);} catch (e) {throw new HealthCheckError('Prisma check failed', e);}}}
As you can see, this health check performs a basic SELECT 1
query on our Prisma database to ensure that it is ready to be queried.
Letâs add this check to our health controller:
typescript
import { PrismaHealthIndicator } from './health.prisma';// ...constructor(private health: HealthCheckService,private pHI: PrismaHealthIndicator,) {}@Get()@HealthCheck()check() {return this.health.check([() => this.pHI.isHealthy('prisma_health')]);}// ...
Donât forget to update the health/health.module.ts
file with the necessary provider:
typescript
import { PrismaHealthIndicator } from './prisma.health';import { PrismaService } from 'src/prisma.service';@Module({imports: [TerminusModule],controllers: [HealthController],providers: [PrismaService, PrismaHealthIndicator],})export class HealthModule {}
And thatâs it! To test it out, start the app and curl the health route:
bash
$ npm start$ curl http://localhost:8888/health
Conclusion
In this guided tutorial, we covered some important aspects of building a microservices architecture with NestJS. We started by discussing the 12-factor app methodology and the benefits of storing configuration in the environment. We saw how to handle configuration in a NestJS API, including setting up a Joi schema validation and using the ConfigService to access the configuration variables throughout the application.
Next, we explored the importance of health check endpoints in a microservices architecture and implemented a basic health route using the Terminus library. We also created a custom health indicator for Prisma and added it to our health check endpoint.
Overall, we covered some essential concepts and techniques for building scalable, reliable and secure microservices with NestJS. By following these practices, you can ensure that your microservices architecture is well-designed, efficient, and easy to maintain over the long term.