NestJS, a progressive Node.js framework, has been gaining popularity for composing enterprise-grade server-side applications. Its out-of-the-box support for TypeScript, combined with features like dependency injection, decorators, and modular organization, makes it a go-to choice for developers aiming for clean, scalable code architectures.
Parallelly, Nx emerges as a powerhouse toolset for managing monorepos, facilitating code sharing and reuse across projects without losing sight of boundaries. Its ability to handle complex builds and dependencies across multiple frameworks and libraries streamlines development processes, especially in large-scale projects involving NestJS alongside other technologies like Angular.
In my journey over the past four years, NestJS has proven invaluable in maintaining solid code conventions and modular codebase. The last three years have seen Nx as a pivotal element in managing applications and libraries, ensuring consistency and efficiency in development workflows.
Note
You can follow the companion repository while reading this article.
The Synergy Between NestJS and Nx
Creating libraries within a NestJS application using Nx is more than a matter of convenience; it's about embracing a pattern that enhances maintainability and scalability. This synergy allows for workflows where code reuse becomes a norm, not an afterthought. The goal is to build libraries that are not just easy to use but also easy to maintain, a challenge many developers face in the lifecycle of a software project.
Nx brings to the table a set of tools that, when combined with NestJS's architecture, promote a level of abstraction and separation of concerns that is hard to achieve otherwise.
Workspace setup
The process begins with initializing an Nx workspace configured to cater to a NestJS environment. This step sets the stage for a packaged-based monorepo structure where applications and libraries co-exist, sharing dependencies and tooling yet operating independently.
Question
What is a packaged-based monorepo?
The following command initializes an Nx workspace optimized for package management with npm, Nx also provides a preset for NestJS, which sets up the workspace with the necessary configurations for developing NestJS applications and libraries. Since we focus on libraries, we will use the @nx/nest
plugin separately to generate libraries tailored for NestJS.
npx create-nx-workspace@18.1.2 your-workspace --preset=npm \
--no-interactive --nxCloud=skip --yes
cd your-workspace
# you can check the docs https://nx.dev/nx-api/nest to know more about its capabilities
npm install -D @nx/nest
Structuring the Workspace for Library Development
Within this workspace, we create libraries tailored for specific functionalities, such as interacting with external APIs or enhancing the application's core capabilities. The structure of these libraries is crucial, as it dictates their usability and ease of maintenance.
For example, your library could encapsulate the interactions with an Identity Provider, abstracting away the complexities of direct API calls and providing a simple, intuitive interface for the rest of the application. It could also contain a guard to check users' authentication and permissions in the NestJS application when importing your library. This encapsulation follows the principles of modular design, where each module or library has a well-defined responsibility and interface.
For instance, let's create two libraries: private-nestjs-library
and public-nestjs-library
. The former is an internal library not meant to be published in an NPM registry, while the latter is a public library intended for external consumption.
nx g @nx/nest:lib private-nestjs-library --directory packages/private-nestjs-library \
--buildable --importPath @your-npm-scope/private-nestjs-library \
--projectNameAndRootFormat as-provided --no-interactive
nx g @nx/nest:lib public-nestjs-library --directory packages/public-nestjs-library \
--publishable --importPath @your-npm-scope/public-nestjs-library \
--projectNameAndRootFormat as-provided --no-interactive
Note
- The
--publishable
flag is used to generate a buildable and publishable library, while the--importPath
flag specifies the import path for the library. This is particularly useful when publishing the library to an npm registry.- The
--directory
flag is used to specify the directory where the library will be created. This is particularly useful when organizing libraries into subdirectories within thepackages
directory.- The
--projectNameAndRootFormat
flag is used to ensure Nx only uses the provided project name and root format when generating the library files.
Now, let’s check the directory structure to be sure everything is correct by running tree . -d --gitignore
in the terminal. You should see something like this:
├── packages
│ ├── private-nestjs-library
│ │ └── src
│ │ └── lib
│ └── public-nestjs-library
│ └── src
│ └── lib
└── tools
└── scripts
Which would translate to the following alias paths in tsconfig.base.json
:
{
"compilerOptions": {
// ...
"paths": {
"@your-npm-scope/private-nestjs-library": [
"packages/private-nestjs-library/src/index.ts"
],
"@your-npm-scope/public-nestjs-library": [
"packages/public-nestjs-library/src/index.ts"
]
}
}
}
By leveraging Nx's capabilities to generate buildable and publishable libraries, we ensure that each library can be independently developed, tested, and versioned, encouraging modularization and reusability.
private-nestjs-library:
public-nestjs-library:
Tips
If you wonder how to visualize this project view, run thenx show project <project_name> --web
As you can see, only the public-nestjs-library
contains the nx-release-publish
target, which triggers the library publishing to an NPM registry. Our plan is that private-nestjs-library
will be imported and shipped with the public-nestjs-library
, while the public-nestjs-library
will be published and used by other applications.
Library Implementation
This article focuses on the theoretical aspects of creating libraries with Nx and NestJS. In practice, it will depend on the purpose of the library and the provided functionalities; however, a typical pattern is to iterate over the following steps:
- Create interfaces and constants to configure the module(s)
- Declare a dynamic Module that the consuming application will import and take care of instantiating the providers and, eventually, controllers
- Create a service that will handle the business logic and expose methods to be used by the consuming application
- Write unit test suites for the service
When implementing your library, keep in mind best practices in software development, including but not limited to:
- Abstraction and Encapsulation: Keeping implementation details hidden, exposing only necessary interfaces. This makes library usage and maintenance effortless.
- Single Responsibility Principle: Each library should have one purpose and not be overloaded with functionalities that can be decoupled.
- Dependency Injection: Facilitates testing and decouples the libraries from their dependencies.
- Modularization: Promotes code reuse and simplifies the maintenance of large codebases.
A typical entry point of a NestJS library could look like this:
// packages/public-nestjs-library/src/lib/public-nestjs-library.module.ts
import { Module } from '@nestjs/common';
import { PrivateNestjsLibraryModule } from '@your-npm-scope/private-nestjs-library';
import { PublicNestjsLibraryOptions } from './public-nestjs-library.interfaces';
import { PublicNestjsLibraryService } from './public-nestjs-library.service';
@Module({
imports: [PrivateNestjsLibraryModule],
controllers: [],
providers: [PublicNestjsLibraryService],
exports: [PublicNestjsLibraryService],
})
export class PublicNestjsLibraryModule {
static forRoot(
options: PublicNestjsLibraryOptions,
isGlobal?: boolean
): DynamicModule {
return {
module: PublicNestjsLibraryModule,
imports: [PrivateNestjsLibraryModule],
providers: [
{ provide: PublicNestjsLibraryOptions, useValue: options },
PublicNestjsLibraryService,
],
exports: [PublicNestjsLibraryOptions, PublicNestjsLibraryService],
global: isGlobal,
};
}
}
Resulting in the following Nx dependency graph:
Dependencies Management
One of the critical aspects of maintaining high code quality in a library-centric development environment is ensuring consistency and correctness in external dependency management. Nx addresses this with the @nx/dependency-checks
lint rule, which helps keep the integrity and consistency of package.json files across the workspace.
This tool automatically checks for missing dependencies, obsolete dependencies, and version mismatches. It's instrumental in ensuring that libraries are self-contained and their dependencies are accurately reflected and up-to-date, reducing integration and compatibility issues.
For our private library private-nestjs-library
, the setup is straightforward:
// packages/private-nestjs-library/.eslintrc.json
// ...
{
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"buildTargets": ["build"],
"checkMissingDependencies": true,
"checkObsoleteDependencies": true,
"checkVersionMismatches": true
}
]
}
},
// ...
However, the public-nestjs-library
, depends on private-nestjs-library
that should remain internal. As a result, Nx needs to include the private-nestjs-library
as part of the bundle, not as an external dependency. It should also include dependencies of the private-nestjs-library
.
The solution in two parts is:
- In
packages/public-nestjs-library/.eslintrc.json
, update@nx/dependency-checks
, this time using includeTransitiveDependencies and ignoredDependencies - In
packages/public-nestjs-library/project.json
, update thetargets.build.options
to set"external": "none"
packages/public-nestjs-library/.eslintrc.json:
// packages/public-nestjs-library/.eslintrc.json
// ...
{
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"buildTargets": ["build"],
"checkMissingDependencies": true,
"checkObsoleteDependencies": true,
"checkVersionMismatches": true,
"includeTransitiveDependencies": true,
"ignoredDependencies": [
"@your-npm-scope/private-nestjs-library"
]
}
]
}
},
// ...
packages/public-nestjs-library/project.json:
{
"targets": {
"build": {
"executor": "@nx/node:build",
"options": {
"outputPath": "dist/packages/public-nestjs-library",
"external": "none"
}
}
}
}
Finally, after running nx lint public-nest-library
, the package.json is generated with the correct dependencies, but I suggest moving the functional dependencies
to peerDependencies
to avoid version conflicts with the hosting NestJS app.
If a configuration does this automatically, please share the info in the comments.
packages/public-nestjs-library/package.json:
{
"name": "@your-npm-scope/public-nestjs-library",
"version": "0.0.1",
"publishConfig": {
"access": "public"
},
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"axios": "1.6.8"
},
"type": "commonjs",
"main": "./src/index.js",
"typings": "./src/index.d.ts"
}
Tips
- You can find some more in-depth information about
@nx/dependency-checks
rule in the Nx docs.- Remove the
publishConfig
field if you whish to keep the library private.
Continuous Integration
Using Nx with GitHub Actions is a powerful combination; in just few lines of code, you can end up with an efficient CI workflow that automates the testing of your libraries. And thanks to Nx, the verification only runs for the libraries affected by the current changes, saving you a lot of time and resources.
name: CI
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-CI
env:
CI: true
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- uses: nrwl/nx-set-shas@v4
- uses: 8BitJonny/gh-get-current-pr@2.2.0
id: current-pr
- if: steps.current-pr.outputs.number != 'null' && github.ref_name != 'main'
# This line is needed for nx affected to work when CI is running on a PR
run: git branch --track main origin/main
- run: npx nx format:check
- run: npx nx affected -t lint,test,build --parallel=3
Tips
You can find more information about the Nx GitHub Actions and the Affected Commands in the official documentation.
Versioning and Release Management
The lifecycle of a library extends beyond its initial development. Managing versions and releases is a critical aspect of ensuring that improvements, bug fixes, and new features reach the consumers of the library in a controlled and predictable manner. With its release CLI, Nx introduces a streamlined approach to versioning and release management.
This tool simplifies bumping versions, generating changelogs, tagging releases, and publishing packages. It supports both independent and synchronized versioning strategies across the monorepo, accommodating the unique needs of each library within the workspace.
The flow is as follows:
I found this configuration particularly useful when using GitHub to manage releases and changelogs and NPM to publish packages. It will create independent changelogs and versions for each library.
nx.json:
// ...
"release": {
"projects": ["packages/*", "!packages/private-nestjs-library"],
"projectsRelationship": "independent",
"changelog": {
"projectChangelogs": {
"createRelease": "github"
}
},
"version": {
"conventionalCommits": false,
"generatorOptions": {
"skipLockFileUpdate": true,
"currentVersionResolver": "git-tag"
}
},
"git": {
"commit": true,
"tag": true
}
}
"targetDefaults": {
// ...
"nx-release-publish": {
"options": {
"packageRoot": "dist/packages/{projectName}",
"registry": "http://localhost:4873/"
}
}
//...
}
Note
- The
projects
field specifies the projects to be released, while theprojectsRelationship
field specifies the versioning strategy. In this case, theindependent
strategy is used to version each project independently and theprivate-nestjs-library
is explicitly excluded from the release process.- The registry URL is set to a local Verdaccio instance, which is a private NPM registry. You can replace it with the URL of your preferred NPM registry. Verdaccio can be started with the following command:
node tools/scripts/local-registry.mjs
.
packages/public-nestjs-library/project.json:
{
// ...
"targets": {
// ...
"nx-release-publish": {
"dependsOn": ["build"]
}
}
}
Warning
In my previous experience,nx-release-publish
target could be fully configured innx.json
file, but during the writing of this articledependsOn
had to be set on a project basis to trigger the build target before the release.
And when it is time to release a new version, the following command gets the job done:
# for the first time you need to run the following command to create the initial tag
npx nx release --first-release
# after that, you can run the following command to release a new version
npx nx release
Tips
You can find more information about the Nx release CLI in the official documentation.
By leveraging Nx's release management capabilities, developers can ensure their libraries align with semantic versioning principles. This process makes tracking changes and managing dependencies easier but also integrates smoothly with continuous integration pipelines, ensuring that releases are consistent, reliable, and automated.
Conclusion
The combination of NestJS and Nx offers a robust framework for creating libraries that are not just powerful but also elegant and maintainable. By embracing the theoretical principles of modular design and best practices in software development, we can build a codebase that is both scalable and easy to manage.
Top comments (3)
NestJS has a serious problem with modularity. For example, its interceptors, guards, pipes and filters cannot be exported from the module, respectively - they cannot be imported into the consumer modules. Additionally, providers cannot be declared at the module level (only at the method, controller, or global level):
And if there is bad modularity, accordingly - it is bad to publish NestJS modules as libraries.
Thanks for sharing your view.
I understand that this might be a preference. Personally, I prefer having the ability to declare guards at the controller or route level rather than having Guards (or filters, or pipes) to be implicitely used in my controller because they are declared in the providers.
However, I don't see what it has to do with publishing NestJS modules as libraries.
No, why do you talk about implicitness? When you add any provider to the
providers
array, it doesn't mean that they will be implicitly used somewhere.If you cannot declare a provider at the module level, it means that the framework has poor modularity. And almost always, when we talk about the NestJS library for publishing, we mean publishing the module itself. Therefore, poor modularity is directly related to publishing the NestJS module as a library.