- Introduction to NestJS
- 1. Create a NestJS application
- 2. Application configuration
- 3. NestJS Application Lifecycle
- 4. Request Lifecycle
- 5. Request context
First, let's set up the working environment to ensure we have the correct Node.js version and project dependencies.
-
Install Node Version Manager (NVM): Use NVM to manage Node.js versions.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
-
Reload Shell Configuration and Verify Installation:
command -v nvm
-
Install Node.js v18:
nvm use 18
-
Fork the Project Repository: Start by forking and cloning the project repository.
gh repo fork getlarge/ticketing --clone
Tip
You can also use the ⑂ Fork button on the GitHub repository page to fork the project.
-
Install Project Dependencies: Navigate to the project directory and install dependencies using Yarn.
cd ticketing yarn
Next, we'll create a new NestJS application using the Nx plugin for NestJS.
-
Generate a New Nx Project:
npx nx generate @nx/nest:app moderation --directory=apps/moderation --tags=scope:moderation,type:app,platform:server
-
Remove Unnecessary Dependencies: When generating a new NestJS application using Nx, the NestJS plugin automatically adds the
@nestjs/platform-express
package as a dependency. Since we will use Fastify instead of Express, we need to remove this dependency (@nestjs/platform-fastify
should already be installed). -
Verify Project Structure: Generate and inspect the project structure.
tree apps/moderation
Expected Output:
apps/moderation ├── jest.config.ts ├── project.json ├── src │ ├── app │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.spec.ts │ │ └── app.service.ts │ ├── assets │ └── main.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── webpack.config.js
At the moment of writing (02/01/2024), a few adjustments are required to make NestJS work with Nx when using ESM modules.
Create a file apps/moderation/webpack.config.cjs
with the following content:
// apps/moderation/webpack.config.cjs
const { composePlugins, withNx } = require("@nx/webpack");
const nodeExternals = require("webpack-node-externals");
// workaround to load ESM modules in node
// @see https://github.com/nrwl/nx/pull/10414
// @see https://github.com/nrwl/nx/issues/7872#issuecomment-997460397
// Nx plugins for webpack.
module.exports = composePlugins(withNx(), (config) => {
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
".js": [".ts", ".js"],
".mjs": [".mts", ".mjs"],
};
return {
...config,
externalsPresets: {
node: true,
},
output: {
...config.output,
module: true,
libraryTarget: "module",
chunkFormat: "module",
filename: "[name].mjs",
chunkFilename: "[name].mjs",
library: {
type: "module",
},
environment: {
module: true,
},
},
experiments: {
...config.experiments,
outputModule: true,
topLevelAwait: true,
},
externals: nodeExternals({
importType: "module",
}),
};
});
Note: Please note the
.cjs
extension, which is required to load the configuration file as a CommonJS module.
To make TypeScript work with ESM, we need to update the tsconfig.*.json
files.
Update the Typescript config for the application in apps/moderation/tsconfig.app.json
:
// apps/moderation/tsconfig.app.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"emitDecoratorMetadata": true,
"moduleResolution": "Bundler",
"module": "esnext",
"target": "es2022",
"types": ["node"]
},
"exclude": ["**/*.spec.ts", "**/*.e2e-spec.ts", "jest.config.ts"],
"include": ["**/*.ts"]
}
Update the Typescript config for tests in apps/moderation/tsconfig.spec.json
:
// apps/moderation/tsconfig.spec.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"moduleResolution": "node",
"module": "esnext",
"target": "es2022",
"emitDecoratorMetadata": true,
"types": ["jest", "node"],
"allowJs": true
},
"include": [
"**/*.e2e-spec.ts",
"**/*.mock.ts",
"**/*.spec.ts",
"**/*.d.ts",
"jest.config.ts"
]
}
In addition to the above changes, we need to update the project configuration to use the custom Webpack configuration.
Inside apps/moderation/project.json
:
// apps/moderation/project.json
{
"name": "moderation",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/moderation/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/moderation",
"outputFileName": "main.mjs",
"main": "apps/moderation/src/main.ts",
"tsConfig": "apps/moderation/tsconfig.app.json",
"assets": ["apps/moderation/src/assets"],
"generatePackageJson": true,
"target": "node",
"compiler": "tsc",
"webpackConfig": "apps/moderation/webpack.config.cjs",
"isolatedConfig": true
},
"configurations": {
"development": {},
"production": {}
}
},
"serve": {
"executor": "@nx/js:node",
"defaultConfiguration": "local",
"options": {
"buildTarget": "moderation:build"
},
"configurations": {
"development": {
"buildTarget": "moderation:build:development"
},
"production": {
"buildTarget": "moderation:build:production"
},
"local": {
"buildTarget": "moderation:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/moderation/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/moderation/jest.config.ts"
}
}
},
"tags": ["scope:moderation", "type:app", "platform:server"]
}
Update the default main.ts
file to bootstrap the NestJS application using Fastify.
// apps/moderation/src/main.ts
import { Logger } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
import { GLOBAL_API_PREFIX } from "@ticketing/microservices/shared/constants";
import { AppModule } from "./app/app.module";
const DEFAULT_PORT = 3090;
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.setGlobalPrefix(GLOBAL_API_PREFIX);
const port = process.env.PORT ?? DEFAULT_PORT;
await app.listen(port, "0.0.0.0", () => {
Logger.log(`Listening at http://localhost:${port}/${GLOBAL_API_PREFIX}`);
});
}
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
});
Note: By default, Fastify listens only on the localhost 127.0.0.1 interface, to accept connections on other hosts (such as local network IP address), you should specify '0.0.0.0'.
How do you configure a NestJS application in a reliable and scalable way?
The ConfigModule can receive a validate
function in its parameters to validate environment variables at load time. Let’s make the best out of it; maybe we can make the best out of it?
Create a .env
file in the root of the project with the following content:
.env
NODE_ENV=development
LOG_LEVEL=info
JWT_ALGORITHM=ES256
JWT_EXPIRES_IN=15m
JWT_ISSUER=localhost
JWT_PRIVATE_KEY=
JWT_PUBLIC_KEY=
SESSION_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
# NETWORK
PROXY_SERVER_URLS=http://localhost
FRONTEND_URL=http://localhost
FRONTEND=host.docker.internal
FRONTEND_PORT=4200
AUTH_SERVICE=host.docker.internal
AUTH_SERVICE_PORT=3000
ORDERS_SERVICE=host.docker.internal
ORDERS_SERVICE_PORT=3020
PAYMENTS_SERVICE=host.docker.internal
PAYMENTS_SERVICE_PORT=3040
TICKETS_SERVICE=host.docker.internal
TICKETS_SERVICE_PORT=3010
DOMAIN=localhost
CONNECT_SRC="http://localhost:4455 http://localhost:4433 http://localhost:4000 http://localhost:8080"
DEFAULT_SRC="http://localhost:4455 http://localhost:4433 http://localhost:4000 http://localhost:8080"
MEDIA_SRC=""
SCRIPT_SRC="'unsafe-inline'"
STYLE_SRC=""
STYLE_SRC_ELEM=""
STYLE_SRC_ATTR=""
FONT_SRC="data:"
FRAME_SRC="http://localhost:4455 http://localhost:4433 http://localhost:4000"
IMG_SRC=""
FORM_ACTION="http://localhost:8080"
# ORY
log_level="trace"
# ORY KRATOS
kratos_dsn="memory"
selfservice_default_browser_return_url="http://127.0.0.1:8080/"
selfservice_allowed_return_urls="http://127.0.0.1:8080, http://127.0.0.1:4455"
selfservice_flows_ui_base_url="http://127.0.0.1:4455"
selfservice_flows_errors_ui_url="http://127.0.0.1:4455/error"
selfservice_flows_settings_ui_url="http://127.0.0.1:4455/settings"
selfservice_flows_login_ui_url="http://127.0.0.1:4455/login"
selfservice_flows_registration_ui_url="http://127.0.0.1:4455/register"
selfservice_flows_recovery_ui_url="http://127.0.0.1:4455/recovery"
selfservice_flows_verification_ui_url="http://127.0.0.1:4455/verification"
selfservice_flows_login_after_hook_config_url="http://host.docker.internal:8080/api/users/on-sign-in"
selfservice_flows_login_after_hook_config_auth_config_value="unsecure_api_key"
selfservice_flows_login_after_hook_config_can_interrupt="false"
selfservice_flows_login_after_hook_config_response_ignore="false"
selfservice_flows_login_after_hook_config_response_parse="false"
selfservice_flows_registration_after_hook_config_url="http://host.docker.internal:8080/api/users/on-sign-up"
selfservice_flows_registration_after_hook_config_auth_config_value="unsecure_api_key"
selfservice_flows_registration_after_hook_config_can_interrupt="true"
selfservice_flows_registration_after_hook_config_response_ignore="false"
selfservice_flows_registration_after_hook_config_response_parse="true"
oauth2_provider_url="http://hydra:4445/"
secrets_cookie="cookie_secret_not_good_not_secure"
secrets_cipher="32-LONG-SECRET-NOT-SECURE-AT-ALL"
serve_admin_base_url="http://kratos:4434/"
serve_public_base_url="http://127.0.0.1:4433/"
serve_public_cors_enabled="true"
serve_public_cors_allowed_origins="http://127.0.0.1:4433, http://127.0.0.1:4455, http://127.0.0.1:8080"
# ORY KETO
keto_dsn="memory"
# ORY HYDRA
hydra_dsn="memory"
urls_self_issuer="http://127.0.0.1:4444"
urls_self_public="http://127.0.0.1:4444"
urls_consent="http://127.0.0.1:4455/consent"
urls_login="http://127.0.0.1:4455/login"
urls_logout="http://127.0.0.1.4455/logout"
urls_identity_provider_publicUrl="http://127.0.0.1:4433"
urls_identity_provider_url="http://kratos:4434"
secrets_system="system_secret_not_good_not_secure"
oidc_subject_identifiers_pairwise_salt="not_secure_salt"
oauth2_token_hook_url="http://host.docker.internal:8080/api/clients/on-token-request"
oauth2_token_hook_auth_config_value="unsecure_api_key"
Create a file in libs/microservices/shared/env
named my-api-environment-variables.ts
, with the following content and export the class in the index.ts
file:
// libs/microservices/shared/env/src/my-api-environment-variables.ts
import { Expose } from "class-transformer";
import { IsOptional, IsString, IsUrl } from "class-validator";
import { decorate } from "ts-mixer";
export class MyApiEnvironmentVariables {
@decorate(Expose())
@decorate(
IsUrl({
require_protocol: true,
require_valid_protocol: true,
require_host: true,
require_tld: false,
}),
)
@decorate(IsOptional())
MY_API_URL?: string = "http://localhost:3000";
@decorate(Expose())
@decorate(IsString())
@decorate(IsOptional())
MY_API_KEY?: string = null;
}
[!NOTE]
- The
decorate
function is used to allow decorators to be used and inherited in mixins. See ts-mixer for more details.- The
@Expose
decorator is used to expose the property when using theplainToClass
method fromclass-transformer
, with the optionexcludeExtraneousValues
. See class-transformer for more details.
Create a file in apps/moderation/src/app/env
with the following content:
// apps/moderation/src/app/env/index.ts
import { ConfigService } from "@nestjs/config";
import {
BaseEnvironmentVariables,
MyApiEnvironmentVariables,
} from "@ticketing/microservices/shared/env";
import { Exclude } from "class-transformer";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Mixin } from "ts-mixer";
export type AppConfigService = ConfigService<EnvironmentVariables, true>;
export class EnvironmentVariables extends Mixin(
BaseEnvironmentVariables,
MyApiEnvironmentVariables,
) {
@Exclude()
private pkg: { [key: string]: unknown; name?: string; version?: string } =
JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
APP_NAME?: string = "moderation";
APP_VERSION?: string = this.pkg?.version || "0.0.1";
}
[!NOTE]
- The
Mixin
function is used to allow multiple inheritance of classes. See ts-mixer.- The
@Exclude
decorator is used to exclude the property when using theplainToClass
method fromclass-transformer
.
We will now register the configuration module in the AppModule
and use the EnvironmentVariables
class to validate the environment variables.
// apps/moderation/src/app/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { validate } from "@ticketing/microservices/shared/env";
import { resolve } from "node:path";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { EnvironmentVariables } from "./env";
@Module({
imports: [
ConfigModule.forRoot({
// cache: true,
isGlobal: true,
envFilePath: resolve("apps/moderation/.env"),
validate: validate(EnvironmentVariables),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
[!IMPORTANT]
- The
validate
function is used to validate the environment variables at load time. It receives theEnvironmentVariables
class as a parameter and uses theclass-validator
andclass-transformer
libraries to validate the environment variables.- The
cache
option is set totrue
to enable caching of the environment variables, which can improve performance by reducing the number of file reads.- The
isGlobal
option is set totrue
to make the configuration module available to the entire application.
After you have ensured that the environment variables are correctly set up for each project and docker containers (check the examples if needed), you can run the following commands from the root of the project:
# build custom Nginx Proxy
yarn docker:proxy:build
# Generate Ory network configuration from .env
yarn ory:generate:kratos
yarn ory:generate:keto
# start the Storage and Broker dependencies (mongo, redis, rabbitmq)
yarn docker:deps:up
# start Ory network (Kratos and Keto with database)
yarn docker:ory:up
# start Nginx Proxy (for backend services and frontend app)
yarn docker:proxy:up
# or fox linux machines
yarn docker:proxy-linux:up
# start existing backend services in a single terminal
yarn start:backend
# or start existing backend services in separate terminals
npx nx serve auth
npx nx serve expiration
npx nx serve orders
npx nx serve payments
npx nx serve tickets
# finally start the moderation app
npx nx serve moderation
[!TIP]:
- When running docker commands from a Linux machine, you will need to uncomment the
extra_hosts
options in docker.compose.yaml.- If you use an older version of docker, replace calls to
docker compose
bydocker-compose
inpackage.json
scripts.
It is common to store environment variables in files when working locally while directly exporting them in the current process when deploying remotely. Difficulties arise when you must share those with your coworkers or when a DevOps team member should change the remote environment variables.
Dotenv vault provides a great solution that can be easily applied to your NestJS project.
First of all, to make our life easier, let's create some Nx targets in apps/moderation/project.json
:
// apps/moderation/project.json
{
"name": "moderation",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/moderation/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/moderation",
"outputFileName": "main.mjs",
"main": "apps/moderation/src/main.ts",
"tsConfig": "apps/moderation/tsconfig.app.json",
"assets": ["apps/moderation/src/assets"],
"generatePackageJson": true,
"target": "node",
"compiler": "tsc",
"webpackConfig": "apps/moderation/webpack.config.cjs",
"isolatedConfig": true
},
"configurations": {
"development": {},
"production": {}
}
},
"serve": {
"executor": "@nx/js:node",
"defaultConfiguration": "local",
"options": {
"buildTarget": "moderation:build"
},
"configurations": {
"development": {
"buildTarget": "moderation:build:development"
},
"production": {
"buildTarget": "moderation:build:production"
},
"local": {
"buildTarget": "moderation:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/moderation/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/moderation/jest.config.ts"
}
},
"dotenv-push": {
"executor": "nx:run-commands",
"options": {
"commands": ["cd apps/moderation && dotenv-vault push"]
},
"cwd": ".",
"parallel": false
},
"dotenv-pull": {
"executor": "nx:run-commands",
"options": {
"commands": ["node tools/utils/dotenv-pull.js -p moderation -v"]
},
"parallel": false,
"cwd": "."
},
"dotenv-build": {
"executor": "nx:run-commands",
"options": {
"commands": ["cd apps/moderation && dotenv-vault build"]
},
"cwd": ".",
"parallel": false
},
"dotenv-keys": {
"executor": "nx:run-commands",
"options": {
"commands": ["cd apps/moderation && dotenv-vault keys"]
},
"cwd": ".",
"parallel": false
}
},
"tags": ["scope:moderation", "type:app", "platform:server"]
}
Create an account on Dotenv.
- Register your team on Dotenv and create an organization.
- Invite your team members to the organization.
- Create a project on Dotenv under your organization here.
Connect to the remote vault using the vault ID provided by Dotenv (starting with vlt_).
This will create a .env.vault
file in the apps/moderation
folder.
Now, it's time to log in to Dotenv, this will create a .env.me
file in the apps/moderation
folder and allow you to push and pull environment variables (if you have the right permissions).
Now, you can sync your local environment variables with the remote vault.
Let's start by pulling the remote environment variables to your local .env
file.
cd apps/moderation
npx dotenv-vault pull
cd -
# which, unless you modified the default environment, implicitly runs :
npx dotenv-vault pull development .env
And then you can modify your local environment variables and push them to the remote vault.
echo -e "#.env \BONJOUR="MONDE" \n" >> apps/moderation/.env
cd apps/moderation
npx dotenv-vault push
cd -
%%{
init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#6fa8dc',
'primaryTextColor': '#000',
'primaryBorderColor': '#7C0000',
'lineColor': '#77acff',
'secondaryColor': '#f1e35e',
'tertiaryColor': '#fff'
}
}
}%%
flowchart TB
subgraph B["OnModuleInit"]
B1["Controllers and Providers"] -.-> B2[Module]
end
subgraph C["OnApplicationBootstrap"]
C1["Controllers and Providers"] -.-> C2[Module]
end
A["Bootstrapping starts"]--
"For each module, after module resolution
• await child controller & provider onModuleInit() methods
• await module onModuleInit() method"--> B
B --"For each module, before opening connections
• await child controller & provider onApplicationBootstrap() methods
• await module onApplicationBootstrap() method"--> C
C --> D["Start listeners"]
D --"For HTTP/WS servers and each microservice:
• await connections open/read"--> E["Application ready"]
E --> F[Application is running]
subgraph J["OnModuleDestroy"]
J1["Controllers and Providers"] -.-> J2[Module]
end
subgraph K["BeforeApplicationShutdown"]
K1["Controllers and Providers"] -.-> K2[Module]
end
subgraph M["OnApplicationShutdown"]
M1["Controllers and Providers"] -.-> M2[Module]
end
I[Exit signal] --"For each module, after a termination signal
• await child controller & provider onModuleDestroy() methods
• await module onModuleDestroy() method"--> J
J --"For each module, after onModuleDestroy promises are resolved
• await child controller & provider beforeApplicationShutdown() methods
• await module beforeApplicationShutdown() method"--> K
K --> L["Stop Listeners"]
L --"For each module, after connections closed
• await child controller & provider onApplicationShutdown() methods
• await module onApplicationShutdown() method"--> M
M --> N["Process exits"]
This diagram represents the sequence of events during the NestJS application lifecycle, starting from module initialization, followed by application bootstrap, and concluding with module destruction and application shutdown. Each component in the lifecycle can react to its respective initialization and destruction events, facilitating the setup and teardown of resources throughout the application's lifecycle.
onModuleDestroy
, beforeApplicationShutdown
and onApplicationShutdown
are only triggered if you explicitly call app.close() or if the process receives a special system signal (such as SIGTERM) and you have correctly called enableShutdownHooks at application bootstrap (see below Application shutdown part).
Important
Lifecycle hooks methods are not invoked in lazy loaded modules and services.
Update the default module apps/moderation/src/app/app.module.ts
to implement all lifecycle hooks methods by adding the following content:
// apps/moderation/src/app/app.module.ts
import {
BeforeApplicationShutdown,
Logger,
Module,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { validate } from "@ticketing/microservices/shared/env";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { EnvironmentVariables } from "./env";
@Module({
imports: [
ConfigModule.forRoot({
cache: true,
isGlobal: true,
validate: validate(EnvironmentVariables),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown
{
readonly logger = new Logger(AppModule.name);
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
}
Update the default controller apps/moderation/src/app/app.controller.ts
to implement all lifecycle hooks methods by adding the following content:
// apps/moderation/src/app/app.controller.ts
import {
BeforeApplicationShutdown,
Controller,
Get,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { AppService } from "./app.service";
@Controller()
export class AppController
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown
{
readonly logger = new Logger(AppController.name);
constructor(private readonly appService: AppService) {}
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
@Get()
getData(): { message: string } {
return this.appService.getData();
}
}
Update the default provider apps/moderation/src/app/app.service.ts
to implement all lifecycle hooks methods by adding the following content:
// apps/moderation/src/app/app.service.ts
import {
BeforeApplicationShutdown,
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
@Injectable()
export class AppService
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown
{
readonly logger = new Logger(AppService.name);
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
getData(): { message: string } {
return { message: "Welcome to moderation!" };
}
}
Tip
Interfaces are technically optional because they do not exist after TypeScript compilation. Nonetheless, it's good practice to use them in order to benefit from strong typing and editor tooling. To register a lifecycle hook, implement the appropriate interface.
Update the default apps/moderation/src/main.ts
file to enable shutdown hooks.
// apps/moderation/src/main.ts
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
import { GLOBAL_API_PREFIX } from "@ticketing/microservices/shared/constants";
import { AppModule } from "./app/app.module";
import { EnvironmentVariables } from "./app/env";
const DEFAULT_PORT = 3090;
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.setGlobalPrefix(GLOBAL_API_PREFIX);
app.enableShutdownHooks();
const configService =
app.get<ConfigService<EnvironmentVariables, true>>(ConfigService);
const port = configService.get("PORT", { infer: true }) ?? DEFAULT_PORT;
await app.listen(port, "0.0.0.0", () => {
Logger.log(`Listening at http://localhost:${port}/${GLOBAL_API_PREFIX}`);
});
}
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
});
[!IMPORTANT] When you enable shutdown hooks with
app.enableShutdownHooks()
, the onModuleDestroy(), beforeApplicationShutdown() and onApplicationShutdown() hooks are called in the terminating phase (in response to an explicit call to app.close() or upon receipt of system signals such as SIGTERM if opted-in).
Starting the application with npx nx serve moderation
should display the following logs:
...
[Nest] <pid> - <timestamp> LOG [AppController] initialized
[Nest] <pid> - <timestamp> LOG [AppService] initialized
[Nest] <pid> - <timestamp> LOG [AppModule] initialized
[Nest] <pid> - <timestamp> LOG [AppController] bootstraped
[Nest] <pid> - <timestamp> LOG [AppService] bootstraped
[Nest] <pid> - <timestamp> LOG [AppModule] bootstraped
...
Stopping the application with Ctrl+C
should display the following logs:
[Nest] <pid> - <timestamp> LOG [AppController] destroyed
[Nest] <pid> - <timestamp> LOG [AppService] destroyed
[Nest] <pid> - <timestamp> LOG [AppModule] destroyed
[Nest] <pid> - <timestamp> LOG [AppController] before shutdown SIGINT
[Nest] <pid> - <timestamp> LOG [AppService] before shutdown SIGINT
[Nest] <pid> - <timestamp> LOG [AppModule] before shutdown SIGINT
[Nest] <pid> - <timestamp> LOG [AppController] shutdown SIGINT
[Nest] <pid> - <timestamp> LOG [AppService] shutdown SIGINT
[Nest] <pid> - <timestamp> LOG [AppModule] shutdown SIGINT
In a real-world NestJS application, a controller's endpoint (or a microservice listener) might be wrapped in multiple layers of pre and post-processing steps. Understanding and keeping in mind the request lifecycle in NestJS is crucial for several reasons:
-
Debugging and Troubleshooting: Having a clear understanding of the request lifecycle helps with debugging and troubleshooting during development. It allows developers to track the flow of the request and identify potential issues or bottlenecks at each stage of the lifecycle.
-
Orderly Execution of Middleware, Guards, and Interceptors: NestJS follows a specific order in executing middleware, guards, and interceptors. Understanding this order ensures that these components are executed in the desired sequence, enabling developers to implement the logic effectively.
-
Data Transformation and Validation with Pipes: NestJS pipe functions play a vital role in transforming and validating incoming data. Knowing the lifecycle allows developers to leverage pipes at the correct stages, ensuring that data is processed reliably and appropriately before it reaches the controller or service layer.
-
Applying Authorization and Access Control with Guards: Guards provide authorization and access control mechanisms in NestJS. Understanding the request lifecycle helps identify the appropriate stages for applying global, controller-specific, or route-specific guards, ensuring that security requirements are effectively met.
-
Handling Exceptions with Filters: Exception filters in NestJS catch and handle errors that occur during the request lifecycle. Being aware of the lifecycle allows developers to place filters at the appropriate stages to capture and handle exceptions in a controlled and consistent manner.
-
Optimizing Performance: By understanding the request lifecycle, developers can analyze the flow of operations and identify areas where to target optimization efforts. This knowledge helps in optimizing code execution, reducing response time, and improving overall application performance.
Keeping the request lifecycle in mind while developing a NestJS application enables developers to design and implement robust, secure, and performant solutions. It ensures the application flow aligns with the framework's expectations, resulting in a more efficient and maintainable codebase.
%%{
init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#77acff',
'primaryTextColor': '#fff',
'primaryBorderColor': '#f1e35e',
'lineColor': '#F8B229',
'secondaryColor': '#f1e35e',
'tertiaryColor': '#fff'
}
}
}%%
flowchart LR
subgraph A[Middlewares]
direction TB
A1[Global Middlewares] --> A2[Module Middlewares]
end
subgraph B[Guards]
direction TB
B1[Global Guards] --> B2[Controller Guards]
B2 --> B3[Route Guards]
end
subgraph C["Interceptors (pre-request)"]
direction TB
C1[Global Interceptors] --> C2[Controller Interceptors]
C2 --> C3[Route Interceptors]
end
subgraph D[Pipes]
direction TB
D1[Global Pipes] --> D2[Controller Pipes]
D2 --> D3[Route Pipes]
D3 --> D4[Route parameters Pipes]
end
subgraph E["Processing"]
direction TB
E1[Controller] --> E2[Service]
end
subgraph F["Interceptors (post-request)"]
direction TB
F1[Route Interceptors] --> F2[Controller Interceptors]
F2 --> F3[Global Interceptors]
end
subgraph G["Exception Filters"]
direction TB
G1[Route Filters] --> G2[Controller Filters]
G2 --> G3[Global Filters]
end
start["Incoming Request"] --> A
A --> B
B --> C
C --> D
D --> Y1[end pre processing]
Y2[start processing] --> E
E --> F
F --> G
G --> stop["Server Response"]
In NestJS, middlewares are used to intercept the request and response objects. They are executed before the route handler and before the guards. For example, they can be used to log the request, parse the request body, add headers to the request/response, etc.
Create a file apps/moderation/src/app/middlewares/global.middleware.ts
with the following content:
[!IMPORTANT] You cannot access the DI container in the global middleware constructor.
// apps/moderation/src/app/middlewares/global.middleware.ts
import { Logger, NestMiddleware } from "@nestjs/common";
import type { FastifyReply, FastifyRequest } from "fastify";
export class GlobalMiddleware implements NestMiddleware {
use(
req: FastifyReply["raw"],
res: FastifyRequest["raw"],
next: () => void,
): void {
Logger.log("Global middleware");
next();
}
}
export function globalMiddleware(
req: FastifyReply["raw"],
res: FastifyRequest["raw"],
next: () => void,
): void {
return new GlobalMiddleware().use(req, res, next);
}
Global middlewares are registered in apps/moderation/src/main.ts
:
// ...
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.use(globalMiddleware);
//...
Create a file apps/moderation/src/app/middlewares/module.middleware.ts
with the following content:
// apps/moderation/src/app/middlewares/module.middleware.ts
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import type { FastifyReply, FastifyRequest } from "fastify";
@Injectable()
export class ModuleMiddleware implements NestMiddleware {
use(
req: FastifyReply["raw"],
res: FastifyRequest["raw"],
next: () => void,
): void {
Logger.log("Module middleware");
next();
}
}
Module middlewares are registered in apps/moderation/src/app/app.module.ts
:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [EnvironmentVariables],
validate,
}),
...
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(ModuleMiddleware).forRoutes('(.*)');
}
}
Guards are commonly used to secure routes; they are executed after middleware and before interceptors, pipes, and route handlers.
Note
- Guards should have a single responsibility and should not be used to perform business logic.
- Guards have a big advantage over middlewares, beyond knowing the request details, they are aware which controller and handler they are protecting.
Create a file apps/moderation/src/app/guards/global.guard.ts
with the following content:
// apps/moderation/src/app/guards/global.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
} from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class GlobalGuard implements CanActivate {
canActivate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
Logger.log("Global guard");
return true;
}
}
Global guards are registered in main.ts
:
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.useGlobalGuards(new GlobalGuard());
Or as a provider in apps/moderation/src/app/app.module.ts
:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [EnvironmentVariables],
validate,
}),
...
],
controllers: [AppController],
providers: [AppService,
{
provide: APP_GUARD,
useClass: GlobalGuard,
},
],
})
export class AppModule {
...
}
Create a file apps/moderation/src/app/guards/controller.guard.ts
with the following content:
// apps/moderation/src/app/guards/controller.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
} from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class ControllerGuard implements CanActivate {
canActivate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
Logger.log("Controller guard");
return true;
}
}
Controller guards are declared at the controller level as class decorators.
@Controller()
@UseGuards(ControllerGuard)
export class AppController {
constructor(private readonly appService: AppService) {}
...
}
Create a file apps/moderation/src/app/guards/route.guard.ts
with the following content:
// apps/moderation/src/app/guards/route.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
} from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class RouteGuard implements CanActivate {
canActivate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
Logger.log("Route guard");
return true;
}
}
Route guards are declared at the controller's method level as method decorators.
@Controller()
@UseGuards(ControllerGuard)
export class AppController {
constructor(private readonly appService: AppService) {}
...
@Get()
@UseGuards(RouteGuard)
getHello(): string {
return this.appService.getHello();
}
}
Interceptors are used to intercept a request before it reaches a route handler. They are executed after the guards and before the pipes.
Note
- Interceptors can inject extra logic before and/or after the handler execution.
- Interceptors can transform the response returned by the handler.
- Interceptors can catch exception thrown by the handler.
- Interceptors can bypass the handler execution by returning a value.
Create a file apps/moderation/src/app/interceptors/global.interceptor.ts
with the following content:
// apps/moderation/src/app/interceptors/global.interceptor.ts
import {
CallHandler,
ExecutionContext,
Logger,
NestInterceptor,
} from "@nestjs/common";
import { Observable, tap } from "rxjs";
export class GlobalInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> | Promise<Observable<unknown>> {
Logger.log("Global interceptor pre-request");
return next.handle().pipe(
tap(() => {
Logger.log("Global interceptor post-request");
}),
);
}
}
Global interceptors are registered in apps/moderation/src/main.ts
:
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.useGlobalInterceptors(new GlobalInterceptor());
Or as a provider in apps/moderation/src/app/app.module.ts
:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [EnvironmentVariables],
validate,
}),
...
],
controllers: [AppController],
providers: [AppService,
{
provide: APP_INTERCEPTOR,
useClass: GlobalInterceptor,
},
],
})
export class AppModule {
// ...
}
Create a file apps/moderation/src/app/interceptors/controller.interceptor.ts
with the following content:
// apps/moderation/src/app/interceptors/controller.interceptor.ts
import {
CallHandler,
ExecutionContext,
Logger,
NestInterceptor,
} from "@nestjs/common";
import { Observable, tap } from "rxjs";
export class ControllerInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> | Promise<Observable<unknown>> {
Logger.log("Controller interceptor pre-request");
return next.handle().pipe(
tap(() => {
Logger.log("Controller interceptor post-request");
}),
);
}
}
Controller interceptors are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
@UseInterceptors(ControllerInterceptor)
export class AppController {
constructor(private readonly appService: AppService) {}
// ...
}
Create a file apps/moderation/src/app/interceptors/route.interceptor.ts
with the following content:
// apps/moderation/src/app/interceptors/route.interceptor.ts
import {
CallHandler,
ExecutionContext,
Logger,
NestInterceptor,
} from "@nestjs/common";
import { Observable, tap } from "rxjs";
export class RouteInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> | Promise<Observable<unknown>> {
Logger.log("Route interceptor pre-request");
return next.handle().pipe(
tap(() => {
Logger.log("Route interceptor post-request");
}),
);
}
}
Route interceptors are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
@UseInterceptors(ControllerInterceptor)
export class AppController {
constructor(private readonly appService: AppService) {}
// ...
@Get()
@UseInterceptors(RouteInterceptor)
getHello(): string {
return this.appService.getHello();
}
}
Pipes are used to transform and/or validate the request data before it reaches the route handler. They are executed after the interceptors and before the route handler.
Note
- Pipes can transform the request data.
- Pipes can validate the request data.
- Pipes can throw an exception and bypass route handlder if the request data is invalid.
Create a file apps/moderation/src/app/pipes/global.pipe.ts
with the following content:
// apps/moderation/src/app/pipes/global.pipe.ts
import {
ArgumentMetadata,
Injectable,
Logger,
PipeTransform,
} from "@nestjs/common";
@Injectable()
export class GlobalPipe<T, R> implements PipeTransform<T, R> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
transform(value: T, _metadata: ArgumentMetadata): R {
Logger.log("Global pipe");
return value as unknown as R;
}
}
Global pipes are registered in apps/moderation/src/main.ts
:
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.useGlobalPipes(new GlobalPipe());
Create a file apps/moderation/src/app/pipes/controller.pipe.ts
with the following content:
// apps/moderation/src/app/pipes/controller.pipe.ts
import {
ArgumentMetadata,
Injectable,
Logger,
PipeTransform,
} from "@nestjs/common";
@Injectable()
export class ControllerPipe<T, R> implements PipeTransform<T, R> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
transform(value: T, _metadata: ArgumentMetadata): R {
Logger.log("Controller pipe");
return value as unknown as R;
}
}
Controller pipes are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
@UsePipes(ControllerPipe)
export class AppController {
constructor(private readonly appService: AppService) {}
...
}
Create a file apps/moderation/src/app/pipes/route.pipe.ts
with the following content:
// apps/moderation/src/app/pipes/route.pipe.ts
import {
ArgumentMetadata,
Injectable,
Logger,
PipeTransform,
} from "@nestjs/common";
@Injectable()
export class RoutePipe<T, R> implements PipeTransform<T, R> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
transform(value: T, _metadata: ArgumentMetadata): R {
Logger.log("Route pipe");
return value as unknown as R;
}
}
Route pipes are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
@UsePipes(ControllerPipe)
export class AppController {
constructor(private readonly appService: AppService) {}
...
@Get()
@UsePipes(RoutePipe)
getHello(): string {
return this.appService.getHello();
}
}
Create a file apps/moderation/src/app/pipes/route-params.pipe.ts
with the following content:
// apps/moderation/src/app/pipes/route-params.pipe.ts
import {
ArgumentMetadata,
Injectable,
Logger,
PipeTransform,
} from "@nestjs/common";
@Injectable()
export class RouteParamsPipe<T, R> implements PipeTransform<T, R> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
transform(value: T, _metadata: ArgumentMetadata): R {
Logger.log("RouteParams pipe");
return value as unknown as R;
}
}
Route parameters pipes are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
//...
@Get(":id")
getHello(@Param("id", RouteParamsPipe) id: string): string {
return this.appService.getHello();
}
}
Exception filters are used to catch exceptions thrown by application code. They are executed after the route handler and after the interceptors.
Note
- Exception filters can catch exceptions thrown by HTTP exceptions, pipes, guards, interceptors, microservices, etc.
- A filter should aways return a response to the client, either by throwing an exception, by returning a value or using the FastifyReply instance.
- A filter can be used to transform the response returned by the handler.
Create a file apps/moderation/src/app/filters/global.filter.ts
with the following content:
// apps/moderation/src/app/filters/global.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
@Catch()
export class GlobalFilter implements ExceptionFilter {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch(exception: unknown, _host: ArgumentsHost): void {
Logger.log("Global filter");
throw exception;
}
}
Global filters are registered in apps/moderation/src/main.ts
:
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.useGlobalFilters(new GlobalFilter());
Or as a provider in apps/moderation/src/app/app.module.ts
:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [EnvironmentVariables],
validate,
}),
...
],
controllers: [AppController],
providers: [AppService,
{
provide: APP_FILTER,
useClass: GlobalFilter,
},
],
})
export class AppModule {
// ...
}
Create a file apps/moderation/src/app/filters/controller.filter.ts
with the following content:
// apps/moderation/src/app/filters/controller.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
@Catch()
export class ControllerFilter implements ExceptionFilter {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch(exception: unknown, _host: ArgumentsHost): void {
Logger.log("Controller filter");
throw exception;
}
}
Controller filters are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
@UseFilters(ControllerFilter)
export class AppController {
constructor(private readonly appService: AppService) {}
...
}
Create a file apps/moderation/src/app/filters/route.filter.ts
with the following content:
// apps/moderation/src/app/filters/route.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import type { FastifyReply } from "fastify";
@Catch()
export class RouteFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
Logger.log("Route filter");
const context = host.switchToHttp();
const response = context.getResponse<FastifyReply>();
void response.status(500).send(exception);
}
}
Route filters are registered in apps/moderation/src/app/app.controller.ts
:
@Controller()
@UseFilters(ControllerFilter)
export class AppController {
constructor(private readonly appService: AppService) {}
...
@Get()
@UseFilters(RouteFilter)
getHello(): string {
return this.appService.getHello();
}
}
apps/moderation/src/main.ts
:
// apps/moderation/src/main.ts
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
import { GLOBAL_API_PREFIX } from "@ticketing/microservices/shared/constants";
import { AppModule } from "./app/app.module";
import { EnvironmentVariables } from "./app/env";
import { GlobalFilter } from "./app/filters/global.filter";
import { GlobalGuard } from "./app/guards/global.guard";
import { GlobalInterceptor } from "./app/interceptors/global.interceptor";
import { globalMiddleware } from "./app/middlewares/global.middleware";
import { GlobalPipe } from "./app/pipes/global.pipe";
const DEFAULT_PORT = 3090;
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: true,
bodyLimit: +process.env.MAX_PAYLOAD_SIZE || 1048576,
}),
{ bufferLogs: true, abortOnError: false },
);
app.setGlobalPrefix(GLOBAL_API_PREFIX);
app.enableShutdownHooks();
app.use(globalMiddleware);
app.useGlobalGuards(new GlobalGuard());
app.useGlobalInterceptors(new GlobalInterceptor());
app.useGlobalPipes(new GlobalPipe());
app.useGlobalFilters(new GlobalFilter());
const configService =
app.get<ConfigService<EnvironmentVariables, true>>(ConfigService);
const port = configService.get("PORT", { infer: true }) ?? DEFAULT_PORT;
await app.listen(port, "0.0.0.0", () => {
Logger.log(`Listening at http://localhost:${port}/${GLOBAL_API_PREFIX}`);
});
}
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
});
apps/moderation/src/app/app.module.ts
:
// apps/moderation/src/app/app.module.ts
import {
BeforeApplicationShutdown,
Logger,
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { validate } from "@ticketing/microservices/shared/env";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { EnvironmentVariables } from "./env";
import { ModuleMiddleware } from "./middlewares/module.middleware";
@Module({
imports: [
ConfigModule.forRoot({
cache: true,
isGlobal: true,
validate: validate(EnvironmentVariables),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown
{
readonly logger = new Logger(AppModule.name);
configure(consumer: MiddlewareConsumer): void {
consumer.apply(ModuleMiddleware).forRoutes(AppController);
}
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
}
apps/moderation/src/app/app.controller.ts
:
// apps/moderation/src/app/app.controller.ts
import {
BeforeApplicationShutdown,
Controller,
Get,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
Param,
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from "@nestjs/common";
import { AppService } from "./app.service";
import { ControllerFilter } from "./filters/controller.filter";
import { RouteFilter } from "./filters/route.filter";
import { ControllerGuard } from "./guards/controller.guard";
import { RouteGuard } from "./guards/route.guard";
import { ControllerInterceptor } from "./interceptors/controller.interceptor";
import { RouteInterceptor } from "./interceptors/route.interceptor";
import { ControllerPipe } from "./pipes/controller.pipe";
import { RoutePipe } from "./pipes/route.pipe";
import { RouteParamsPipe } from "./pipes/route-params.pipe";
@Controller()
@UseGuards(ControllerGuard)
@UseInterceptors(ControllerInterceptor)
@UsePipes(ControllerPipe)
@UseFilters(ControllerFilter)
export class AppController
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown
{
readonly logger = new Logger(AppController.name);
constructor(private readonly appService: AppService) {}
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
@Get()
@UseGuards(RouteGuard)
@UseInterceptors(RouteInterceptor)
@UsePipes(RoutePipe)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getData(@Param("id", RouteParamsPipe) _id?: string): { message: string } {
return this.appService.getData();
}
@Get("exception")
@UseGuards(RouteGuard)
@UseInterceptors(RouteInterceptor)
@UsePipes(RoutePipe)
@UseFilters(RouteFilter)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getException(@Param("id", RouteParamsPipe) _id?: string): string {
throw new Error("Exception");
}
}
Start the application:
npx nx serve moderation
Send a request to the application:
curl http://localhost:3090/api
You should see the following logs:
[Nest] <pid> - <timestamp> LOG Global middleware
[Nest] <pid> - <timestamp> LOG Module middleware
[Nest] <pid> - <timestamp> LOG Global guard
[Nest] <pid> - <timestamp> LOG Controller guard
[Nest] <pid> - <timestamp> LOG Route guard
[Nest] <pid> - <timestamp> LOG Global interceptor pre-request
[Nest] <pid> - <timestamp> LOG Controller interceptor pre-request
[Nest] <pid> - <timestamp> LOG Route interceptor pre-request
[Nest] <pid> - <timestamp> LOG Global pipe
[Nest] <pid> - <timestamp> LOG Controller pipe
[Nest] <pid> - <timestamp> LOG Route pipe
[Nest] <pid> - <timestamp> LOG RouteParams pipe
[Nest] <pid> - <timestamp> LOG Route interceptor post-request
[Nest] <pid> - <timestamp> LOG Controller interceptor post-request
[Nest] <pid> - <timestamp> LOG Global interceptor post-request
Send a request to the application:
curl http://localhost:3090/api/exception
You should see the following logs:
[Nest] <pid> - <timestamp> LOG Global middleware
[Nest] <pid> - <timestamp> LOG Module middleware
[Nest] <pid> - <timestamp> LOG Global guard
[Nest] <pid> - <timestamp> LOG Controller guard
[Nest] <pid> - <timestamp> LOG Route guard
[Nest] <pid> - <timestamp> LOG Global interceptor pre-request
[Nest] <pid> - <timestamp> LOG Controller interceptor pre-request
[Nest] <pid> - <timestamp> LOG Route interceptor pre-request
[Nest] <pid> - <timestamp> LOG Global pipe
[Nest] <pid> - <timestamp> LOG Controller pipe
[Nest] <pid> - <timestamp> LOG Route pipe
[Nest] <pid> - <timestamp> LOG RouteParams pipe
[Nest] <pid> - <timestamp> LOG Route filter
# Controller filter only if the exception is not caught by the route filter
# [Nest] <pid> - <timestamp> LOG Controller filter
[Nest] <pid> - <timestamp> LOG Global filter
Here are a few reasons why propagating request context is beneficial:
Maintain Request Data Consistency: When handling complex requests that go through multiple layers, it's important to maintain data consistency and integrity. By propagating the request context, you ensure that the same consistent dataset is available across different modules and layers of your application.
Sharing Information across Middleware: Middleware functions in Node.js can intercept and modify requests. By propagating the request context, you can share information between different middleware functions, making it easy to pass authentication details, request headers, or other relevant information.
Cross-cutting Concerns: Contextual information, such as logging or error tracking data, often needs to be captured at multiple points during request processing. Propagating the request context helps incorporate this information consistently across various components, enhancing observability and debugging capabilities.
This is particularly important in frameworks like NestJS that rely on middleware, guards, interceptors, and other modules that process requests at various stages.
Request Scope:
Libraries like cls-hooked
or async_hooks
in Node.js can help manage request-specific context throughout the request lifecycle.
My opinion The best option, even though it involves a bit of black magic by creating implicit context, available to a given asynchronous call chain. NestJS proposes the following recipe to get started.
Dependency Injection: In NestJS, it is possible to scope the injection to a request, instantiating the given class or value for each request.
Caution
involve building services aware of their short lifetime, might incur a slight performance penalty.
Passing Context Manually:
This solution has the advantage of avoiding any Typescript / Javascript voodoo but comes at the cost of writing redundant and heavy function signatures.
It is also challenging to spread context in certain situations as it requires controlling the whole operation flow to propagate context to all functions and methods.
Caution
This approach is not recommended for complex applications.
The specific implementation and propagation mechanism will depend on the architecture and libraries you are using in your Node.js application. Choosing an approach that aligns with your application's framework, design patterns, and specific requirements is essential.
We will use the AsyncLocalStorage API to store the request context.
Important
- The
AsyncLocalStorage
might appear like a simple key-value store, but it is much more than that. It is a store that is scoped to the current async operation, which means that it is safe to use in asynchronous environment. - Tracking the current async operation is started by calling
enterWith
orrun
and ended by callingexit
. - In NestJS, we can initialize the
AsyncLocalStorage
when a request is received and destroy it when the request is completed. - Exit is called automatically when the async operation is completed, but it is a good practice to call
exit
manually to avoid memory leaks in long running operations.
# generate a NestJS library
npx nx generate @nx/nest:lib microservices-shared-async-local-storage --directory=libs/microservices/shared/async-local-storage \
--tags=scope:shared,type:lib,platform:server \
--importPath=@ticketing/microservices/shared/async-local-storage
# check the generated files and folders :
tree libs/microservices/shared/async-local-storage
# expected output:
libs/microservices/shared/async-local-storage
├── README.md
├── jest.config.ts
├── project.json
├── src
│ ├── index.ts
│ └── lib
│ └── microservices-shared-async-local-storage.module.ts
├── tsconfig.json
├── tsconfig.lib.json
└── tsconfig.spec.json
Create a file in libs/microservices/shared/async-local-storage/src/lib/async-local-storage.service.ts
with the following content:
// libs/microservices/shared/async-local-storage/src/lib/async-local-storage.service.ts
import { Inject, Injectable } from "@nestjs/common";
import { AsyncLocalStorage } from "node:async_hooks";
type Key = string | symbol | object;
export type StoreMap = Map<Key, unknown>;
const noOp = (): void => undefined;
@Injectable()
export class AsyncLocalStorageService {
readonly instance: AsyncLocalStorage<StoreMap>;
// eslint-disable-next-line @typescript-eslint/naming-convention
private static _instance: AsyncLocalStorage<StoreMap>;
constructor(
@Inject("ASYNC_LOCAL_STORAGE") instance: AsyncLocalStorage<StoreMap>,
) {
// ensure that AsyncLocalStorage is a singleton
AsyncLocalStorageService._instance ??= instance;
this.instance = AsyncLocalStorageService.instance || instance;
}
static get instance(): AsyncLocalStorage<StoreMap> {
if (!this._instance) {
throw new Error(
"AsyncLocalStorageService is not initialized. Call AsyncLocalStorageService.forRoot() first.",
);
}
return this._instance;
}
static get store(): StoreMap | undefined {
return this.instance?.getStore();
}
static enterWith(value: StoreMap = new Map()): void {
this.instance?.enterWith(value);
}
static enter(): void {
this.enterWith();
}
static exit(cb: () => void = noOp): void {
this.instance?.exit(cb);
}
private static isStoreInitialized(x: unknown): x is StoreMap {
return !!x;
}
// AsyncLocalStorage methods and properties
run<R, TArgs extends unknown[]>(
store: StoreMap,
callback: (...args: TArgs) => R,
...args: TArgs
): R {
return this.instance.run(store, callback, ...args);
}
enterWith(value: StoreMap = new Map()): void {
this.instance.enterWith(value);
}
enter(): void {
this.enterWith();
}
exit(cb: () => void = noOp): void {
this.instance.exit(cb);
}
get store(): StoreMap | undefined {
return this.instance.getStore();
}
private get safeStore(): StoreMap {
if (AsyncLocalStorageService.isStoreInitialized(this.store)) {
return this.store;
}
throw new Error(
"Store is not initialized. Call 'enterWith' or 'run' first.",
);
}
get<K extends Key>(key: K): unknown {
return this.safeStore.get(key);
}
set<K extends Key, T = unknown>(key: K, value: T): this {
this.safeStore.set(key, value);
return this;
}
}
And export the AsyncLocalStorageService
in libs/microservices/shared/async-local-storage/src/index.ts
:
Create a file in libs/microservices/shared/async-local-storage/src/lib/async-local-storage.module.ts
with the following content:
// libs/microservices/shared/async-local-storage/src/lib/async-local-storage.module.ts
import { DynamicModule, Module, Provider } from "@nestjs/common";
import { AsyncLocalStorage } from "node:async_hooks";
import {
AsyncLocalStorageService,
StoreMap,
} from "./async-local-storage.service";
@Module({})
export class AsyncLocalStorageModule {
public static forRoot(): DynamicModule {
const providers: Provider[] = [
{
provide: "ASYNC_LOCAL_STORAGE",
useValue: new AsyncLocalStorage<StoreMap>(),
},
{
provide: AsyncLocalStorageService,
inject: ["ASYNC_LOCAL_STORAGE"],
useFactory(store: AsyncLocalStorage<StoreMap>) {
return new AsyncLocalStorageService(store);
},
},
];
return {
global: true,
module: AsyncLocalStorageModule,
providers,
exports: providers,
};
}
}
And finally, export the AsyncLocalStorageModule
in libs/microservices/shared/async-local-storage/src/index.ts
:
Create a file in apps/moderation/src/app/middlewares/request-context.middleware.ts
with the following content:
// apps/moderation/src/app/middlewares/request-context.middleware.ts
import { Injectable, NestMiddleware } from "@nestjs/common";
import { AsyncLocalStorageService } from "@ticketing/microservices/shared/async-local-storage";
import type { FastifyReply, FastifyRequest } from "fastify";
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(
private readonly asyncLocalStorageService: AsyncLocalStorageService,
) {}
use(
req: FastifyRequest["raw"],
res: FastifyReply["raw"],
next: () => void,
): void {
this.asyncLocalStorageService.enterWith(new Map());
this.asyncLocalStorageService.set("REQUEST_CONTEXT", {
requestId: req.headers["x-request-id"] ?? req["id"],
userAgent: req.headers["user-agent"],
ip: req.headers["x-forwarded-for"] ?? req.socket.remoteAddress,
user: req["user"],
});
next();
}
}
[!NOTE] The
RequestContextMiddleware
is responsible for creating a new store available for the given async operation. Beware that this middleware must be registered before any other middleware that requires access to the request context and will only work with NestJS HTTP applications. For microservices an alternative approach would be to use a customGuard
instead.
Update the AppModule
to import the AsyncLocalStorageModule
and register the RequestContextMiddleware
:
// apps/moderation/src/app/app.module.ts
import {
BeforeApplicationShutdown,
Logger,
MiddlewareConsumer,
Module,
NestModule,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AsyncLocalStorageModule } from "@ticketing/microservices/shared/async-local-storage";
import { validate } from "@ticketing/microservices/shared/env";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { EnvironmentVariables } from "./env";
import { ModuleMiddleware } from "./middlewares/module.middleware";
import { RequestContextMiddleware } from "./middlewares/request-context.middleware";
@Module({
imports: [
ConfigModule.forRoot({
cache: true,
isGlobal: true,
validate: validate(EnvironmentVariables),
}),
AsyncLocalStorageModule.forRoot(),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown,
NestModule
{
readonly logger = new Logger(AppModule.name);
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(ModuleMiddleware)
.forRoutes(AppController)
.apply(RequestContextMiddleware)
.forRoutes(AppController);
}
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
}
Update the AppController
in apps/moderation/src/app/app.controller.ts
:
// apps/moderation/src/app/app.controller.ts
import {
BeforeApplicationShutdown,
Controller,
Get,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
OnModuleDestroy,
OnModuleInit,
Param,
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from "@nestjs/common";
import { AsyncLocalStorageService } from "@ticketing/microservices/shared/async-local-storage";
import { AppService } from "./app.service";
import { ControllerFilter } from "./filters/controller.filter";
import { RouteFilter } from "./filters/route.filter";
import { ControllerGuard } from "./guards/controller.guard";
import { RouteGuard } from "./guards/route.guard";
import { ControllerInterceptor } from "./interceptors/controller.interceptor";
import { RouteInterceptor } from "./interceptors/route.interceptor";
import { ControllerPipe } from "./pipes/controller.pipe";
import { RoutePipe } from "./pipes/route.pipe";
import { RouteParamsPipe } from "./pipes/route-params.pipe";
@Controller()
@UseGuards(ControllerGuard)
@UseInterceptors(ControllerInterceptor)
@UsePipes(ControllerPipe)
@UseFilters(ControllerFilter)
export class AppController
implements
OnModuleDestroy,
OnModuleInit,
OnApplicationBootstrap,
OnApplicationShutdown,
BeforeApplicationShutdown
{
readonly logger = new Logger(AppController.name);
constructor(
private readonly appService: AppService,
private readonly asyncLocalStorageService: AsyncLocalStorageService,
) {}
onModuleInit(): void {
this.logger.log(`initialized`);
}
onApplicationBootstrap(): void {
this.logger.log(`bootstraped`);
}
onModuleDestroy(): void {
this.logger.log(`destroyed`);
}
beforeApplicationShutdown(signal?: string): void {
this.logger.log(`before shutdown ${signal}`);
}
onApplicationShutdown(signal?: string): void {
this.logger.log(`shutdown ${signal}`);
}
@Get()
@UseGuards(RouteGuard)
@UseInterceptors(RouteInterceptor)
@UsePipes(RoutePipe)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getData(@Param("id", RouteParamsPipe) _id?: string): { message: string } {
return this.appService.getData();
}
@Get("exception")
@UseGuards(RouteGuard)
@UseInterceptors(RouteInterceptor)
@UsePipes(RoutePipe)
@UseFilters(RouteFilter)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getException(@Param("id", RouteParamsPipe) _id?: string): string {
throw new Error("Exception");
}
@Get("request-context")
getRequestContext(): unknown {
return this.asyncLocalStorageService.get("REQUEST_CONTEXT");
}
}
Start the application:
npx nx run moderation:serve
Make a request to the /request-context
endpoint:
curl http://localhost:3090/api/request-context
You should see a similar response:
{ "requestId": "req-1", "userAgent": "curl/8.4.0", "ip": "127.0.0.1" }