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.