During the development of my recent project (more info on my website), I wanted to make it more modular.
It consists of 3 main components:
api which is the API server;
a process that is responsible for RSS feed discovery called
and a process responsible for sending out emails called
They all depend on a database.
I wanted all of them be independent executables.
Using a monorepo for such use case, is great.
But settings up dependencies between them, is complicated.
npm 7 has support for workspaces, which simplify the management of monorepos.
Not only that, but using workspaces also helps you to keep your
node_modules more lean (as much as possible).
How it works
Let’s say you have 3 projects:
infra which responsible for working with the database
api which is your API server
worker which is some kind of asynchronous processing worker
You want to keep them all separated, with their own set of dependencies.
worker need to depend on
You could just create 3 different projects, publish the
infra on some internal repository, and install it as a dependency for
But this really complicates things.
Instead, you can setup a workspace using
npm (we will see in a moment how to do that).
On top of all that,
npm will optimize your
So if both
worker depend on the same version of
uuid package, instead of installing the package two times, it will be installed only once.
The general project structure also looks different:
│ ├── api
│ ├── worker
│ └── infra
You get only one
node_modules at the top level.
All dependencies will be there, including symlinks to your internal packages:
├── api -> ../packages/api
├── worker -> ../packages/worker
└── infra -> ../packages/infra
So this means you can install them just like regular packages!
Let’s dive in.
Setting up npm workspaces
We are also going to setup typescript along the way, as there are some caveats.
Inside your top level folder, you will need to create a
package.json (either manually, or via
The most important line is
"workspaces": ["packages/*"], which instructs
npm to treat this package as a workspaces root.
All the actual packages will be inside the
By the way, you can name this folder whatever you want.
I also added some dev dependencies for typescript.
While we are inside the root folder, it’s also a good time to create some typescript boilerplate.
First, we need a base
This is the base typescript configuration for all the packages.
Since we want to keep everything consistent throughout the monorepo, we will change any project wide settings there.
Next, we need another config, this time for building the entire monorepo:
And while we are here, let’s also add a build script to the root
"build": "tsc --build --verbose tsconfig.build.json",
This way, when we run
npm run build in the root of the monorepo, the entire app will be built.
Creating a package
Now, we need to create individual packages.
This can be done either manually, or via
For example, in order to create the
infra package, we will execute
npm init --workspace packages/infra -y.
This will create a default
package.json inside the
I recommend you use scoping for your project.
Scoping helps you avoid any confusion with existing packages.
In order to use scoping, when calling
npm init provide a flag
This way, all your packages will be prefixed with
@mycompany, and won’t cause any confusion.
Here, we work regularly.
We install dependencies as if it was a standalone package.
npm script can be executed from inside the
packages/infra directory, or from the root directory using
npm run <script-name> --workspace packages/infra.
If you want to execute a script across all packages, you can also use
npm run <script-name> --workspaces.
This will iterate over all the packages in a workspace, and execute the said script (or emit error if no such script is defined).
This is very handy for running tests, for example.
In order to install a dependency inside a package, we will execute
npm install --save uuid --workspace/infra.
However, as you will notice, there won’t be a
Instead, all the dependencies will be put in a top level
Finally, let’s answer the question of how to share
infra with both
Repeat the above steps in order to create
And now, when you have all of them ready, just execute
npm install --save @mycompany/infra --workspace packages/api (and repeat it for
worker as well).
Here, scoping comes into play.
npm registry has a package called
infra, so the above command is ambiguous without scoping.
Setting up typescript
Lastly, we want to set up typescript for the individual packages.
Inside every package, create a
tsconfig.json that looks like this:
First, we extend the global
tsconfig.json that we created.
This is necessary in order to keep the general guidelines among all packages—the same.
Next, we override some
It’s needed in order to tell typescript where are the source files and where is the output directory.
We can’t include it in the base config, since it’s relative to the package path.
worker packages, we need two more things:
paths allows us to use scoped imports such as
import x from @mycompany/infra.
references allows us to use the
.d.ts files of the dependent package.
If you want to learn more about
references, consider reading the official documentation.
infra package does not need to have
references since it does not depend on any internal packages.
Now, when we execute
npm run build, all the packages will be built.
According to our internal
tsconfig.json files, the output will be placed inside
While workspaces in
npm are great to keep things clean, they come with some caveats.
One problem I’ve read about online, but haven’t encountered myself, is improper dependency resolution.
Since all dependencies are flattened and put into a global
node_modules directory, some people reported that it can cause bugs when the wrong dependency is imported.
Another downside of this approach, is that you won’t be able to produce atomic units for deployment.
node_modules is shared among all packages, it needs to be included with each and every executable unit.
Consider a scenario where
api depends on a lot of packages, but
worker depends only on
@mycompany/infra (which in turn depends on some database package).
In order to deploy
worker independently, you will have to copy the entire
node_modules along with it.
This is less than ideal if you deploy
worker on separate docker containers or machines.
However, if you have one Docker container that runs both using
pm2—it’s not a problem.