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 observer;
and a process responsible for sending out emails called distributor.
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.
Luckily, 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.
Both api and worker need to depend on infra.
You could just create 3 different projects, publish the infra on some internal repository, and install it as a dependency for api and worker.
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 node_modules.
So if both api and 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:
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 ./packages folder.
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.
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: 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 npm init.
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 packages/infra directory.
Here, we work regularly.
We install dependencies as if it was a standalone package.
Any npm script can be executed from inside the packages/infra directory, or from the root directory using npm run <script-name> --workspace packages/infra.
Another tip.
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 node_modules inside packages/infra.
Instead, all the dependencies will be put in a top level node_modules.
Finally, let’s answer the question of how to share infra with both api and worker.
Repeat the above steps in order to create packages/api and packages/worker.
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).
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 compilerOptions.
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.
On the api and worker packages, we need two more things: paths and references.
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.
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 packages/<package-name>/dist.
Caveats
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.
Since 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 api and worker on separate docker containers or machines.
However, if you have one Docker container that runs both using supervisord or pm2—it’s not a problem.