Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Solve code sharing and setup project with Lerna and monorepo

Code sharing is easy but doing it correctly is challenging. There are multiple ways you can do it, and your use case dictates what approach is right for you. The low hanging fruit is just copy-paste what you need from StackOverflow, GitHub, or your previous project. Despite the apparent benefit of being the most natural, copying and pasting wastes your time in the long run due to causing the maintenance burden of syncing changes manually.

Don't get me wrong, we all copy and paste the code from time to time, and I'm even not disregarding this naive approach as sometimes you want to move fast and worry later. However, what when you don't?

Why monorepo

When optimizing for long-term maintenance, we have a few choices. I like to bet on monorepo. A monolithic repository is a simple idea. You organize the code of all your services in a single repository. It has a few advantages over using a separate repository for each service.

Reusing code is easy. Once you abstract a coherent unit of code into a module, you can then import it from anywhere.

Continuous integration runs tests against the entire monorepo, so once PR is merged you bump the version of all sub-services and there is no doubt what versions are compatible with each other. Version 1.2 of service A is always compatible with version 1.2 of service B. This is why complex projects with multiple dependencies often use monorepo as well (Babel, React, Angular, Jest). Due to the very same reason, large-scale refactorings are also feasible.

You maintain one third-party dependencies tree. It's too easy, especially with all NPM goodies, to end up with two different versions of the same library and having to sync them across different repositories manually gives me a headache. Having one main package-lock.json is a real time-saver.

Monorepo forces collaboration, it encourages having the same coding style by having a single config for your linter/code formatter/module bundler and so on.

Setup step by step

Install Lerna and initialize the project.

npm i -D lerna
npx lerna init

I'm going to use Create React App to scaffold two packages: alice and bob.

cd packages
npx create-react-app alice
npx create-react-app bob

Let's create one more package called common in which we can place modules shared across alice and bob. Call lerna create from the root of the repository.

npx lerna create @yourproject/common -y

Using scoped names for your project packages is a clear way to distinguish them from publicly available NPM packages.

We can make sure we have the same version of the React and ReactDOM in each package by calling lerna add from the root of the repository.

npx lerna add react@^16.6.3
npx lerna add react-dom@^16.6.3

Let's create a simple component that we can reuse in the common package.

// packages/common/Heading.jsx
import React from "react";

function Heading({ level = "1", title }) {
  return React.createElement(`h${level}`, {}, title);
}

export default Heading;

Now call lerna add from the root of the repository to link the common packages to alice and bob.

npx lerna add @yourproject/common

Currently, each package maintains its node_modules with all dependencies it needs. Hoisting dependencies to the root of the repository is possible. Let's clean currently installed dependencies and try it.

npx lerna clean -y && npx lerna bootstrap --hoist

Now all packages are installed in the root of the repository, and node_modules local to packages contain only symlinks. We can now import modules from the common package.

// packages/alice/src/App.js
import React, { Component } from "react";
import Hello from "@yourproject/common/Hello";


class App extends Component {
  render() {
    return (
      <Hello title="Hello, World!" />
    );
  }
}

export default App;

Let's now run the alice service.

npx lerna run start --scope=alice

You can see how changes in the common package are immediately reflected in the alice app which makes for an excellent developer experience.

Deployment

Deployment side of things gets tricky with monorepo. You might be required to provide more guidance for your deployment tool or create a custom script to be able to deploy a single service to Heroku as you want to separate it from the rest of the project. I solved this using separate Dockerfiles, so it comes down to specifying a different path when running docker build.

This is an example of the Dockerfile that maximizes Docker caching (don't get scared by the long dependencies section). After the build, files are copied to the light nginx image as we don't need Node.js any more.

FROM node:10.13-alpine as builder

# Environment

WORKDIR /home/app
ENV NODE_ENV=production

# Dependencies

COPY package.json /home/app/
COPY package-lock.json /home/app/
COPY lerna.json /home/app/

COPY packages/alice/package.json /home/app/packages/alice/
COPY packages/alice/package-lock.json /home/app/packages/alice/

COPY packages/common/package.json /home/app/packages/common/
COPY packages/common/package-lock.json /home/app/packages/common/

RUN npm ci --ignore-scripts --production --no-optional
RUN npx lerna bootstrap --hoist --ignore-scripts -- --production --no-optional

# Build

COPY . /home/app/
RUN cd packages/alice && npm run build

# Serve

FROM nginx:1.15-alpine
COPY --from=builder /home/app/packages/alice/build /usr/share/nginx/html

You can now build the image and run the container. Execute the following commands from the root of the repository.

docker build -t yourproject/alice -f ./packages/alice/Dockerfile .
docker run --rm -p 8080:80 --name yourproject-alice yourproject/alice

Code is available on GitHub: MichalZalecki/lerna-monorepo-example.

Update: In the new article I've published on monorepo, I expand on micro frontends and module federation. Check it out to learn more on setting up your application, so it supports development by multiple independent teams.

Photo by Ben White on Unsplash.