Things I have learned while maintaining JavaScript monorepo with Lerna
Taming monorepos with Lerna and npm@7 workspaces

Things I have learned while maintaining JavaScript monorepo with Lerna

Written on

This is the second part of the article series dealing with monorepo management using Lerna. The first part dealt with monorepo management in depth using Lerna and npm local paths long before npm@7 workspaces were introduced. This article will try to describe monorepo management practices in the world of npm@7 workspaces. Nevertheless, I highly recommend you read the first part of this series to be familiar with how things used to be.

What is monorepo?

Let’s first define what monorepo is:

Monorepo is a software development strategy where code for many projects is stored in the same repository.

Monorepo code is usually divided into separate contexts, called packages. Every package contains code specific to its context and can depend on zero or more other packages within monorepo. Monorepo must have a mechanism to interconnect packages and allow us to use one package code within another.

In the following texts, I’m assuming you have your monorepo setup as described in the first part of this series.

Dependency management

Dependency managed has been simplified significantly with npm workspaces. npm workspaces require npm@7. This version of npm uses the new format of a package-lock.json file. You’ll need to convert your package-lock.json file to the new format for workspaces to work correctly.

A word on expressing dependencies between packages

We now use asterisk symbol to express dependencies between different monorepo packages. Given that following are dependencies of package-c which, requires package-a and package-b, this is how we express it in package.json:

1
2
3
4
5
6
"dependencies": {
  "ramda": "=0.27.0",
  "ramda-adjunct": "=2.27.0",
  "package-a": "*",
  "package-b": "*"
}

Notice that we no longer use explicit npm local paths but rather an asterisk symbol. Asterisk symbol is translated to npm local paths under the hood in a package-lock.json file.

Before npm workspaces, we had to list all packages in the dependencies field of the main monorepo package.json file.

1
2
3
4
5
"dependencies": {
  "package-a": "file:packages/package-a",
  "package-b": "file:packages/package-b",
  "package-c": "file:packages/package-c"
}

This is no longer required. npm workspaces support recognizing monorepo packages by defining a workspaces field in the main monorepo package.json file:

1
2
3
"workspaces": [
  "./packages/*"
]

We don’t need to add a new package explicitly in the dependency field whenever we want to add one. This saves time and prevents cryptic errors when we forget to add the package explicitly as a dependency.

A word on development dependencies

It is no longer required to keep all development dependencies in main the monorepo package.json file. Every monorepo package can now have its own development dependencies in a particular version, given the specific needs.

Before npm workspaces, the devDependencies field of every package package.json file was ignored. Now, npm@7 recognizes it and installs the npm packages listed in devDependencies.

IMHO it still makes sense to keep certain devDepedendies (like testing frameworks) in the main monorepo package.json file.

A word on topology

npm workspaces are aware of monorepo package topology. Workspaces know that package-c uses package-a and package-b as its dependencies. Let’s run the following command, which runs build npm script in all the monorepo packages:

 $ npm run build --workspaces

This command will run npm run build for all the monorepo packages. But it seems that npm workspaces don’t run the monorepo packages command in order as expected. Let’s say that package-a depends on package-b and package-c depends both on package-a and package-b. The order of execution you get from running the command is:

  • package-a
  • package-b
  • package-c

But the correct order of build should be:

  • package-b
  • package-a
  • package-c

Proper order of execution can be achieved by defining each package explicitly and in the correct order in the workspaces array of package.json. This array then needs to be maintained manually to reflect the monorepo topology.

{
  "workspaces": ["./packages/package-b", "./package/package-a", "./packages/package-c"]
}

Because of this, Lerna still plays a significant role here. Lerna will determine which packages have build npm script defined, and then it determines the proper execution order.

 $ lerna run build

Order of execution:

  • package-b
  • package-a
  • package-c

A word on installing a new dependency

npm workspaces completely simplified the process of adding a new production dependency. Let’s say we have a package-a, and we want to add a dependency of ramda-adjunct@2.27.0. The only thing we need to do is run the following command:

  $ npm install ramda-adjunct@2.27.0 --workspace=package-a --save

A word on installing a new development dependency

As already stipulated, development dependencies can be installed either in top-level monorepo (suitable for testing frameworks)

 $ npm install mocha --save-dev

Or installed as a specific development dependency of a particular monorepo package

 $ npm install rimraf --workspace=package-a --save-dev

A word on npm binaries

Before npm workspaces, to run a binary from one of the monorepo packages, we had to use the link-parent-bin npm package to link top-level npm binaries to all monorepo packages. This is no longer required as npm supports this natively.

Closing words

Given all that has been written here, I think npm workspaces still doesn’t replace Lerna. Instead, they just significantly simplified how we work with Lerna. Lerna still plays a significant part in change detection, releases, and running npm scripts in the proper order (given the package topology).

All the examples mentioned in the article can be found in the following Lerna monorepo repository I’ve setup-ed for you. Clone it, install it, play with it and have a lot of fun with it!


Maintaining Lerna monorepo series:

Fork me on GitHub